Back to 0fee
0fee

The Big Currency Update: Source and Destination Currencies

How we redesigned 0fee.dev's currency model with source and destination currencies across 13 files. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 8 min 0fee
currencyapi-designbreaking-changedatabasepayments

The original 0fee.dev data model stored a single amount and a single currency per transaction. This works when the payer's currency matches the receiver's currency. It falls apart the moment someone in the United States pays a merchant in Ivory Coast.

A customer pays $10 USD. The merchant receives 6,200 XOF. Which amount do you store? Which currency? If you store $10 USD, the merchant's dashboard shows the wrong amount in their local currency. If you store 6,200 XOF, the customer's receipt shows the wrong amount.

The answer is: you store both.

The Problem With One Currency Field

The original schema:

pythonclass Transaction(Base):
    amount = Column(Float)       # But which amount?
    currency = Column(String(3)) # But which currency?

This created a cascade of ambiguities:

Scenario`amount``currency`Problem
Customer pays $10, merchant receives $1010.00USDNo problem (same currency)
Customer pays $10, merchant receives 6,200 XOF10.00? 6200?USD? XOF?Which one do we store?
Fee calculated at 0.99%0.099? 61.38?USD? XOF?Fee currency is ambiguous
Refund issued??????Which amount to refund?

Every downstream system -- the dashboard, invoices, SDKs, receipts, analytics -- had to guess which currency the amount field represented. Some assumed source, some assumed destination, and some assumed they were the same. This led to bugs that were subtle and difficult to trace.

The Four New Columns

The BIG-CURRENCY-UPDATE-PLAN.md document outlined the solution: four new columns that make the currency flow explicit:

pythonclass Transaction(Base):
    # Source: what the customer pays
    source_amount = Column(Float, nullable=False)
    source_currency = Column(String(3), nullable=False)

    # Destination: what the merchant receives
    destination_amount = Column(Float, nullable=True)
    destination_currency = Column(String(3), nullable=True)

    # Legacy fields (kept for backward compatibility during migration)
    amount = Column(Float, nullable=True)       # Deprecated
    currency = Column(String(3), nullable=True)  # Deprecated

Now a cross-currency transaction is unambiguous:

pythontransaction = Transaction(
    source_amount=10.00,
    source_currency="USD",
    destination_amount=6200.00,
    destination_currency="XOF",
)

The destination_amount and destination_currency are nullable because same-currency transactions do not need them -- when source and destination are the same, destination_<em> is null and the system treats source_</em> as the canonical values.

The Nine Implementation Phases

The currency update could not be done in a single commit. It was a breaking API change that affected 13 files across the backend, frontend, and SDKs. We planned it in nine phases:

PhaseDescriptionFiles Affected
1Add new columns to databasemodels/transaction.py, migration script
2Update transaction creation logicservices/payment.py
3Update provider adapters to report both currenciesproviders/*.py
4Update API response schemasschemas/transaction.py
5Update dashboard displayfrontend: TransactionList, TransactionDetail
6Update invoice generationservices/invoice.py
7Update fee calculationservices/billing.py
8Update SDKsAll 8 SDK packages
9Deprecate and remove legacy columnsFinal cleanup

Phase 1: Database Migration

sql-- Migration: add currency columns
ALTER TABLE transactions ADD COLUMN source_amount FLOAT;
ALTER TABLE transactions ADD COLUMN source_currency VARCHAR(3);
ALTER TABLE transactions ADD COLUMN destination_amount FLOAT;
ALTER TABLE transactions ADD COLUMN destination_currency VARCHAR(3);

-- Backfill from legacy columns
UPDATE transactions
SET source_amount = amount,
    source_currency = currency
WHERE source_amount IS NULL;

The backfill assumes all existing transactions were same-currency (source = destination), which was true at the time of the migration.

Phase 2: Transaction Creation

python# services/payment.py
async def create_payment(data: PaymentCreate, app: App) -> Transaction:
    transaction = Transaction(
        id=generate_transaction_id(),
        app_id=app.id,
        user_id=app.user_id,
        source_amount=data.amount,
        source_currency=data.currency,
        reference=data.reference,
        status="pending",
        # Legacy fields (kept during transition)
        amount=data.amount,
        currency=data.currency,
    )

    # Route to provider
    provider = await route_payment(app, data)

    # If provider supports currency conversion, get destination
    if provider.supports_conversion:
        conversion = await provider.get_conversion(
            amount=data.amount,
            from_currency=data.currency,
            to_currency=app.settlement_currency,
        )
        transaction.destination_amount = conversion.amount
        transaction.destination_currency = conversion.currency

    db.add(transaction)
    await db.commit()
    return transaction

Phase 3: Provider Adapter Updates

Each provider adapter needed to report the currency conversion that occurred:

python# providers/stripe_adapter.py
class StripeAdapter(BaseProvider):
    async def process_payment(self, transaction: Transaction, credentials: dict) -> PaymentResult:
        intent = await stripe.PaymentIntent.create(
            amount=to_smallest_unit(transaction.source_amount, transaction.source_currency),
            currency=transaction.source_currency.lower(),
            # Stripe handles conversion internally
        )

        return PaymentResult(
            provider_id=intent.id,
            status=map_stripe_status(intent.status),
            source_amount=transaction.source_amount,
            source_currency=transaction.source_currency,
            # Stripe's conversion (if applicable)
            destination_amount=from_smallest_unit(
                intent.amount_received, intent.currency
            ) if intent.amount_received else None,
            destination_currency=intent.currency.upper() if intent.currency != transaction.source_currency.lower() else None,
        )

Phase 4: API Response Schema

python# schemas/transaction.py
class TransactionResponse(BaseModel):
    id: str
    status: str

    # New currency fields
    source_amount: float
    source_currency: str
    destination_amount: float | None = None
    destination_currency: str | None = None

    # Legacy (deprecated, will be removed in v2)
    amount: float | None = None
    currency: str | None = None

    created_at: datetime

    class Config:
        json_schema_extra = {
            "example": {
                "id": "tx_abc123",
                "status": "completed",
                "source_amount": 10.00,
                "source_currency": "USD",
                "destination_amount": 6200.00,
                "destination_currency": "XOF",
                "amount": 10.00,  # Deprecated
                "currency": "USD",  # Deprecated
            }
        }

Both legacy and new fields are returned during the transition period. The legacy fields will be removed in a future API version.

The Breaking API Change

This was 0fee.dev's first breaking API change. We handled it with a deprecation-first approach:

python# Deprecation warning in response headers
@router.get("/transactions/{id}")
async def get_transaction(id: str):
    transaction = await get_transaction_or_404(id)
    response = TransactionResponse.from_orm(transaction)

    return JSONResponse(
        content=response.dict(),
        headers={
            "Deprecation": "true",
            "Sunset": "2026-06-01",
            "Link": '<https://docs.0fee.dev/migration/currency-update>; rel="deprecation"',
        } if response.amount is not None else {}
    )

The Deprecation and Sunset headers follow RFC 8594, giving SDK users a machine-readable signal that the amount/currency fields are deprecated and will be removed after June 1, 2026.

The 13 Files Affected

FileChanges
models/transaction.pyAdded 4 columns
services/payment.pyUpdated creation logic
services/billing.pyFee calculation uses source_amount
services/invoice.pyInvoice shows both currencies
schemas/transaction.pyUpdated response schema
providers/stripe_adapter.pyReports destination currency
providers/paypal_adapter.pyReports destination currency
providers/hub2_adapter.pyReports destination currency
providers/pawapay_adapter.pyReports destination currency
providers/test_adapter.pySupports mock conversion
routes/transactions.pyUpdated list/detail endpoints
routes/webhooks.pyWebhook payload includes both currencies
frontend/TransactionDetail.tsxDisplays source and destination

Session 032 Fixes

The initial implementation in Session 032 revealed several issues that required follow-up:

Exchange rate tracking. The first version stored source and destination amounts but not the exchange rate used. We added an exchange_rate column:

pythonclass Transaction(Base):
    exchange_rate = Column(Float, nullable=True)  # e.g., 620.0 for USD->XOF

Fee currency ambiguity. When the fee is 0.99% of the transaction, which amount is the basis -- source or destination? We standardized on source amount as the fee basis:

python# Fee is always calculated on source amount
fee_amount = transaction.source_amount * 0.0099
fee_currency = transaction.source_currency

Dashboard display. The dashboard needed to show both currencies intelligently:

typescript// Frontend: transaction amount display
function formatTransactionAmount(tx: Transaction): string {
    const source = `${formatCurrency(tx.source_amount, tx.source_currency)}`;

    if (tx.destination_currency && tx.destination_currency !== tx.source_currency) {
        const dest = `${formatCurrency(tx.destination_amount, tx.destination_currency)}`;
        return `${source} -> ${dest}`;
    }

    return source;
}

// Example outputs:
// "$10.00 USD"                    (same currency)
// "$10.00 USD -> 6,200 XOF"      (cross-currency)

The Five Test Scenarios

We validated the currency update with five scenarios:

ScenarioSourceDestinationExpected
Same currency, USD$10 USDnullsource_amount=10, destination_*=null
Same currency, XOF5,000 XOFnullsource_amount=5000, destination_*=null
Cross-currency, USD to XOF$10 USD6,200 XOFBoth populated, exchange_rate=620
Cross-currency, EUR to USD10 EUR$10.85 USDBoth populated, exchange_rate=1.085
Zero-decimal currency1,000 JPY$6.50 USDCorrect handling of JPY (no decimals)

The zero-decimal currency test was critical. Japanese Yen has no decimal places -- 1,000 JPY is one thousand yen, not ten yen. The currency system must know which currencies are zero-decimal to avoid the multiplication/division errors documented in article 060.

What We Learned

Single-currency data models are a trap. They work for domestic payment platforms but break immediately when cross-border payments enter the picture. If you are building a payment system, start with source/destination from day one. The migration cost is far higher than the initial design cost.

Breaking API changes need a deprecation strategy. You cannot change the shape of transaction responses without warning. The RFC 8594 approach (Deprecation and Sunset headers) gives SDK users machine-readable migration timelines.

Exchange rates must be stored with the transaction. Rates change constantly. If you store only the amounts and need the rate later (for refund calculations, dispute resolution, reconciliation), you must be able to derive it. Storing it explicitly eliminates rounding ambiguity.

Fee basis must be explicit. "0.99% of the transaction" is ambiguous when there are two amounts. Document and enforce which amount is the fee basis. We chose source (customer's amount) because that is what the merchant sees and expects.


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