Back to 0fee
0fee

Financial Compliance: OHADA 10-Year Retention Rules

How OHADA's 10-year document retention rules shaped 0fee.dev's deletion policy and archive system. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 10 min 0fee
complianceohadafinancial-regulationsoft-deletedata-retention

When you build a payment platform in Francophone Africa, you inherit a regulatory framework that most Silicon Valley startups have never heard of: OHADA. The Organisation pour l'Harmonisation en Afrique du Droit des Affaires (Organization for the Harmonization of Business Law in Africa) is a treaty-based system of uniform business laws adopted by 17 African countries, including Ivory Coast where we build 0fee.dev.

OHADA's Uniform Act on Accounting requires businesses to retain financial documents for a minimum of 10 years. This single requirement fundamentally shaped how 0fee.dev handles data deletion.

The Rule That Changes Everything

Article 24 of the OHADA Uniform Act on Accounting Law states that accounting books and supporting documents must be preserved for at least ten years from the date of the last entry. For a payment platform, "supporting documents" includes transaction records, invoices, receipts, and the application configurations that generated those transactions.

This means:

  • Transaction records can never be deleted. A transaction from 2026 must be retrievable in 2036.
  • Applications with live transactions can never be deleted. If you delete the app, you lose the context needed to understand its transactions.
  • User accounts with financial history can never be fully purged. The transactions they generated must remain.
  • Invoices, receipts, and billing records are permanent. They are the "supporting documents" the law references.

In Session 054, we implemented these constraints throughout the 0fee.dev backend.

Apps With Live Transactions Can Never Be Deleted

This is the most impactful rule. A developer creates an app, configures providers, processes payments. Then they decide to delete the app. In a typical SaaS platform, you would cascade-delete the app and all its associated data. Under OHADA rules, that is illegal if any live transactions exist.

python# services/app.py
async def can_delete_app(app_id: str) -> dict:
    """Check if an application can be permanently deleted."""

    # Count live transactions (non-test)
    live_tx_count = await db.scalar(
        select(func.count(Transaction.id)).where(
            Transaction.app_id == app_id,
            Transaction.mode == "live",
        )
    )

    # Count completed invoices
    invoice_count = await db.scalar(
        select(func.count(Invoice.id)).where(
            Invoice.app_id == app_id,
            Invoice.status.in_(["paid", "issued"]),
        )
    )

    can_delete = live_tx_count == 0 and invoice_count == 0

    reasons = []
    if live_tx_count > 0:
        reasons.append(
            f"App has {live_tx_count} live transaction(s). "
            f"OHADA requires 10-year retention of financial records."
        )
    if invoice_count > 0:
        reasons.append(
            f"App has {invoice_count} invoice(s). "
            f"Financial documents must be preserved per OHADA Article 24."
        )

    return {
        "can_delete": can_delete,
        "can_archive": True,  # Archiving is always available
        "live_transactions": live_tx_count,
        "invoices": invoice_count,
        "reasons": reasons,
    }

The can_delete_app function is called before any deletion attempt. The API returns clear reasons why deletion is blocked, referencing OHADA specifically. This is not a vague "cannot delete" -- it is a compliance-grounded explanation.

The Can-Delete Validation Endpoint

We exposed a dedicated endpoint that the frontend calls before showing delete or archive options:

python@router.get("/apps/{app_id}/can-delete")
async def check_app_deletability(
    app_id: str,
    current_user: User = Depends(get_current_user),
):
    app = await get_app_or_404(app_id, current_user.id)
    result = await can_delete_app(app_id)
    return result

The frontend uses this response to determine which options to show:

typescript// Frontend: app settings page
const deletability = await api.get(`/apps/${appId}/can-delete`);

if (deletability.can_delete) {
    // Show both archive and delete buttons
    showDeleteButton = true;
    showArchiveButton = true;
} else {
    // Only show archive button, with compliance explanation
    showDeleteButton = false;
    showArchiveButton = true;
    complianceWarning = deletability.reasons.join(' ');
}

Soft Delete With archived_at Timestamp

Since most apps cannot be deleted, archiving becomes the primary "removal" mechanism. Archived apps are invisible in the dashboard but remain in the database with all their data intact:

python# models/app.py
class App(Base):
    __tablename__ = "apps"

    id = Column(String, primary_key=True)
    user_id = Column(String, ForeignKey("users.id"), nullable=False)
    name = Column(String, nullable=False)
    mode = Column(String, default="test")
    is_active = Column(Boolean, default=True)
    archived_at = Column(DateTime, nullable=True)  # Soft delete timestamp
    archived_by = Column(String, nullable=True)     # Who archived it
    archive_reason = Column(String, nullable=True)  # Why it was archived
    created_at = Column(DateTime, server_default=func.now())
    updated_at = Column(DateTime, onupdate=func.now())

The archived_at field is the soft delete marker. When it is not null, the app is archived. We also store who archived it and why, because compliance is not just about retaining data -- it is about retaining context.

python# services/app.py
async def archive_app(
    app_id: str,
    user_id: str,
    reason: str = None,
) -> App:
    """Archive an application (soft delete)."""
    app = await get_app_or_404(app_id, user_id)

    if app.archived_at:
        raise HTTPException(400, "App is already archived")

    app.archived_at = datetime.utcnow()
    app.archived_by = user_id
    app.archive_reason = reason
    app.is_active = False  # Prevent new transactions

    # Disable all webhooks for this app
    await db.execute(
        update(Webhook).where(Webhook.app_id == app_id).values(is_active=False)
    )

    await db.commit()

    # Log the archive action for audit
    await create_audit_log(
        user_id=user_id,
        action="app_archived",
        resource_type="app",
        resource_id=app_id,
        details={"reason": reason},
    )

    return app

Archiving an app also disables its webhooks and sets is_active to False. This prevents new transactions from being created through the archived app, while preserving all historical data.

The Archive/Restore Flow

Archiving is reversible. A developer who archives an app can restore it:

python@router.post("/apps/{app_id}/archive")
async def archive_app_endpoint(
    app_id: str,
    data: ArchiveRequest = None,
    current_user: User = Depends(get_current_user),
):
    reason = data.reason if data else None
    app = await archive_app(app_id, current_user.id, reason)
    return {"message": "App archived successfully", "app": app}

@router.post("/apps/{app_id}/restore") async def restore_app_endpoint( app_id: str, current_user: User = Depends(get_current_user), ): app = await get_app_or_404(app_id, current_user.id) BLANK if not app.archived_at: raise HTTPException(400, "App is not archived") BLANK app.archived_at = None app.archived_by = None app.archive_reason = None app.is_active = True BLANK await db.commit() BLANK await create_audit_log( user_id=current_user.id, action="app_restored", resource_type="app", resource_id=app_id, ) BLANK return {"message": "App restored successfully", "app": app} ```

Note that restoring an app does not automatically re-enable webhooks. The developer must manually reconfigure webhook URLs after restoration, because the receiving endpoints may have changed during the archive period.

App Archiving vs. Deletion: The Decision Tree

The frontend presents this flow to the developer:

Developer clicks "Remove App"
         |
         v
  [Check can-delete endpoint]
         |
    +----+----+
    |         |
 Can delete  Cannot delete
    |         |
    v         v
 Show both   Show archive only
 options     + compliance warning
    |         |
    v         v
 "Delete     "Archive App"
  Forever"   (always available)
  or
 "Archive"

When deletion is possible (test-only apps with no live transactions), both options are shown. When it is not, only archiving is available, with a clear explanation:

typescript// Frontend: delete confirmation dialog
{#if !deletability.can_delete}
    <div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200
                dark:border-amber-800 rounded-lg p-4 mb-4">
        <h4 class="font-semibold text-amber-800 dark:text-amber-200">
            Permanent deletion is not available
        </h4>
        <p class="text-amber-700 dark:text-amber-300 text-sm mt-1">
            {deletability.reasons[0]}
        </p>
        <p class="text-amber-600 dark:text-amber-400 text-xs mt-2">
            You can archive this app instead. Archived apps are hidden from
            your dashboard but all data is preserved as required by law.
        </p>
    </div>
{/if}

Filtering Archived Apps

Archived apps are excluded from all standard queries:

python# Default query excludes archived apps
async def get_user_apps(user_id: str, include_archived: bool = False):
    query = select(App).where(App.user_id == user_id)

    if not include_archived:
        query = query.where(App.archived_at.is_(None))

    query = query.order_by(App.created_at.desc())
    result = await db.scalars(query)
    return result.all()

The dashboard shows active apps by default. An "Archived" tab or toggle lets the developer view and potentially restore archived apps.

Transaction Immutability

Transaction records are immutable by design, enforced at multiple levels:

python# Admin view: transactions are read-only
class TransactionAdmin(ModelView, model=Transaction):
    can_create = False
    can_edit = False
    can_delete = False

# API: no update or delete endpoints for transactions
# The router simply does not define PATCH or DELETE routes for /transactions/{id}

# Database: no cascade delete from apps
class Transaction(Base):
    __tablename__ = "transactions"
    app_id = Column(String, ForeignKey("apps.id", ondelete="RESTRICT"), nullable=False)
    # RESTRICT prevents deleting an app that has transactions

The ondelete="RESTRICT" foreign key constraint is the database-level enforcement. Even if a bug in the application code tries to delete an app with transactions, PostgreSQL will refuse.

Compliance Warnings in Delete Confirmation

When a developer attempts to delete an app that has live transactions, the UI does not just block the action. It explains the legal basis:

typescriptconst complianceText = `This application has processed ${deletability.live_transactions} ` +
    `live transaction(s). Under OHADA Uniform Act on Accounting (Article 24), ` +
    `financial records and supporting documents must be retained for a minimum ` +
    `of 10 years from the date of the last entry. ` +
    `This application and its transaction history cannot be permanently deleted.`;

This level of transparency matters. Developers need to understand that the restriction is not arbitrary -- it is a legal requirement in the 17 OHADA member states where many of 0fee.dev's merchants operate.

The Broader Compliance Picture

OHADA's retention rules are not the only financial regulation that affects a payment platform. But they are the most directly impactful for data lifecycle management. Here is how they fit into the broader picture:

RegulationRequirement0fee.dev Implementation
OHADA Article 2410-year document retentionSoft delete, no hard delete for apps with transactions
OHADA Article 19Chronological recordingTransactions are immutable, append-only
BCEAO regulationsKYC for payment servicesFeature planned for Phase 13 of admin expansion
GDPR (for EU users)Right to erasurePersonal data can be anonymized; transaction records preserved with anonymized references

The GDPR intersection is particularly interesting. A European user can request erasure of their personal data, but the transaction records must remain for OHADA compliance. The solution is anonymization: replace the user's name and email with anonymized identifiers while keeping the financial data intact.

What We Learned

Compliance should shape the data model from day one. We were fortunate to implement OHADA compliance relatively early (Session 054). Retrofitting deletion prevention into a system that already supports hard deletes is much harder than building it in from the start.

Developers accept restrictions when you explain the legal basis. Nobody likes being told they cannot delete their own data. But when the UI explains that it is a legal requirement in 17 countries, developers understand and accept archiving as the alternative.

Soft delete is not just a nice pattern -- it is a legal requirement. In many jurisdictions, the ability to permanently destroy financial records is not a feature. It is a liability. Building soft delete as the default, with hard delete as the exception, aligns with regulatory expectations.

The archived_at timestamp is more useful than a boolean is_archived flag. Knowing when an app was archived helps with auditing. Combined with archived_by and archive_reason, it creates a complete audit trail for the archiving action itself.

Financial compliance is not glamorous work. But for a payment platform operating in Francophone Africa, it is non-negotiable. OHADA's 10-year retention rules are clear, enforceable, and affect every data lifecycle decision. Building compliance into the platform's DNA -- not as an afterthought -- is what separates a production-grade fintech from a prototype.


This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles