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 $10 | 10.00 | USD | No problem (same currency) |
| Customer pays $10, merchant receives 6,200 XOF | 10.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) # DeprecatedNow 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:
| Phase | Description | Files Affected |
|---|---|---|
| 1 | Add new columns to database | models/transaction.py, migration script |
| 2 | Update transaction creation logic | services/payment.py |
| 3 | Update provider adapters to report both currencies | providers/*.py |
| 4 | Update API response schemas | schemas/transaction.py |
| 5 | Update dashboard display | frontend: TransactionList, TransactionDetail |
| 6 | Update invoice generation | services/invoice.py |
| 7 | Update fee calculation | services/billing.py |
| 8 | Update SDKs | All 8 SDK packages |
| 9 | Deprecate and remove legacy columns | Final 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 transactionPhase 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
| File | Changes |
|---|---|
models/transaction.py | Added 4 columns |
services/payment.py | Updated creation logic |
services/billing.py | Fee calculation uses source_amount |
services/invoice.py | Invoice shows both currencies |
schemas/transaction.py | Updated response schema |
providers/stripe_adapter.py | Reports destination currency |
providers/paypal_adapter.py | Reports destination currency |
providers/hub2_adapter.py | Reports destination currency |
providers/pawapay_adapter.py | Reports destination currency |
providers/test_adapter.py | Supports mock conversion |
routes/transactions.py | Updated list/detail endpoints |
routes/webhooks.py | Webhook payload includes both currencies |
frontend/TransactionDetail.tsx | Displays 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->XOFFee 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_currencyDashboard 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:
| Scenario | Source | Destination | Expected |
|---|---|---|---|
| Same currency, USD | $10 USD | null | source_amount=10, destination_*=null |
| Same currency, XOF | 5,000 XOF | null | source_amount=5000, destination_*=null |
| Cross-currency, USD to XOF | $10 USD | 6,200 XOF | Both populated, exchange_rate=620 |
| Cross-currency, EUR to USD | 10 EUR | $10.85 USD | Both populated, exchange_rate=1.085 |
| Zero-decimal currency | 1,000 JPY | $6.50 USD | Correct 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.