El modelo de datos original de 0fee.dev almacenaba un unico amount y una unica currency por transaccion. Esto funciona cuando la moneda del pagador coincide con la del receptor. Se desmorona en el momento en que alguien en Estados Unidos paga a un comerciante en Costa de Marfil.
Un cliente paga $10 USD. El comerciante recibe 6.200 XOF. ¿Que monto almacenas? ¿Que moneda? Si almacenas $10 USD, el panel del comerciante muestra el monto incorrecto en su moneda local. Si almacenas 6.200 XOF, el recibo del cliente muestra el monto incorrecto.
La respuesta es: almacenas ambos.
El problema con un solo campo de moneda
El esquema original:
pythonclass Transaction(Base):
amount = Column(Float) # ¿Pero que monto?
currency = Column(String(3)) # ¿Pero que moneda?Esto creo una cascada de ambiguedades:
| Escenario | `amount` | `currency` | Problema |
|---|---|---|---|
| Cliente paga $10, comerciante recibe $10 | 10.00 | USD | Sin problema (misma moneda) |
| Cliente paga $10, comerciante recibe 6.200 XOF | ¿10.00? ¿6200? | ¿USD? ¿XOF? | ¿Cual almacenamos? |
| Comision calculada al 0,99% | ¿0,099? ¿61,38? | ¿USD? ¿XOF? | La moneda de la comision es ambigua |
| Reembolso emitido | ??? | ??? | ¿Que monto reembolsar? |
Cada sistema posterior -- el panel de control, facturas, SDKs, recibos, analiticas -- tenia que adivinar que moneda representaba el campo amount. Algunos asumian origen, algunos asumian destino, y algunos asumian que eran iguales. Esto llevo a errores sutiles y dificiles de rastrear.
Las cuatro nuevas columnas
El documento BIG-CURRENCY-UPDATE-PLAN.md detallo la solucion: cuatro nuevas columnas que hacen el flujo de monedas explicito:
pythonclass Transaction(Base):
# Origen: lo que el cliente paga
source_amount = Column(Float, nullable=False)
source_currency = Column(String(3), nullable=False)
# Destino: lo que el comerciante recibe
destination_amount = Column(Float, nullable=True)
destination_currency = Column(String(3), nullable=True)
# Campos heredados (mantenidos para compatibilidad durante la migracion)
amount = Column(Float, nullable=True) # Obsoleto
currency = Column(String(3), nullable=True) # ObsoletoAhora una transaccion entre monedas es inequivoca:
pythontransaction = Transaction(
source_amount=10.00,
source_currency="USD",
destination_amount=6200.00,
destination_currency="XOF",
)Los campos destination_amount y destination_currency son anulables porque las transacciones en la misma moneda no los necesitan -- cuando origen y destino son iguales, destination_<em> es nulo y el sistema trata source_</em> como los valores canonicos.
Las nueve fases de implementacion
La actualizacion de moneda no podia hacerse en un solo commit. Era un cambio de API incompatible que afectaba 13 archivos en el backend, frontend y SDKs. Lo planificamos en nueve fases:
| Fase | Descripcion | Archivos afectados |
|---|---|---|
| 1 | Agregar nuevas columnas a la base de datos | models/transaction.py, script de migracion |
| 2 | Actualizar logica de creacion de transacciones | services/payment.py |
| 3 | Actualizar adaptadores de proveedores para reportar ambas monedas | providers/*.py |
| 4 | Actualizar esquemas de respuesta API | schemas/transaction.py |
| 5 | Actualizar visualizacion del panel de control | frontend: TransactionList, TransactionDetail |
| 6 | Actualizar generacion de facturas | services/invoice.py |
| 7 | Actualizar calculo de comisiones | services/billing.py |
| 8 | Actualizar SDKs | Los 8 paquetes SDK |
| 9 | Obsolescer y eliminar columnas heredadas | Limpieza final |
Fase 1: Migracion de base de datos
sql-- Migracion: agregar columnas de moneda
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);
-- Rellenar desde columnas heredadas
UPDATE transactions
SET source_amount = amount,
source_currency = currency
WHERE source_amount IS NULL;El relleno asume que todas las transacciones existentes eran de la misma moneda (origen = destino), lo cual era cierto en el momento de la migracion.
Fase 2: Creacion de transacciones
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",
# Campos heredados (mantenidos durante la transicion)
amount=data.amount,
currency=data.currency,
)
# Enrutar al proveedor
provider = await route_payment(app, data)
# Si el proveedor soporta conversion de moneda, obtener destino
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 transactionFase 3: Actualizaciones de adaptadores de proveedores
Cada adaptador de proveedor necesitaba reportar la conversion de moneda que ocurrio:
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 maneja la conversion internamente
)
return PaymentResult(
provider_id=intent.id,
status=map_stripe_status(intent.status),
source_amount=transaction.source_amount,
source_currency=transaction.source_currency,
# Conversion de Stripe (si aplica)
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,
)Fase 4: Esquema de respuesta API
python# schemas/transaction.py
class TransactionResponse(BaseModel):
id: str
status: str
# Nuevos campos de moneda
source_amount: float
source_currency: str
destination_amount: float | None = None
destination_currency: str | None = None
# Heredados (obsoletos, seran eliminados en 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, # Obsoleto
"currency": "USD", # Obsoleto
}
}Ambos campos heredados y nuevos se devuelven durante el periodo de transicion. Los campos heredados seran eliminados en una version futura de la API.
El cambio incompatible de la API
Este fue el primer cambio incompatible de API de 0fee.dev. Lo manejamos con un enfoque de obsolescencia primero:
python# Advertencia de obsolescencia en cabeceras de respuesta
@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 {}
)Las cabeceras Deprecation y Sunset siguen el RFC 8594, dando a los usuarios de SDK una senal legible por maquina de que los campos amount/currency estan obsoletos y seran eliminados despues del 1 de junio de 2026.
Los 13 archivos afectados
| Archivo | Cambios |
|---|---|
models/transaction.py | 4 columnas agregadas |
services/payment.py | Logica de creacion actualizada |
services/billing.py | Calculo de comisiones usa source_amount |
services/invoice.py | Factura muestra ambas monedas |
schemas/transaction.py | Esquema de respuesta actualizado |
providers/stripe_adapter.py | Reporta moneda de destino |
providers/paypal_adapter.py | Reporta moneda de destino |
providers/hub2_adapter.py | Reporta moneda de destino |
providers/pawapay_adapter.py | Reporta moneda de destino |
providers/test_adapter.py | Soporta conversion simulada |
routes/transactions.py | Endpoints de lista/detalle actualizados |
routes/webhooks.py | Payload de webhook incluye ambas monedas |
frontend/TransactionDetail.tsx | Muestra origen y destino |
Correcciones de la sesion 032
La implementacion inicial en la sesion 032 revelo varios problemas que requirieron seguimiento:
Seguimiento de tasa de cambio. La primera version almacenaba montos de origen y destino pero no la tasa de cambio utilizada. Agregamos una columna exchange_rate:
pythonclass Transaction(Base):
exchange_rate = Column(Float, nullable=True) # ej., 620.0 para USD->XOFAmbiguedad en la moneda de comision. Cuando la comision es 0,99% de la transaccion, ¿cual es la base -- origen o destino? Estandarizamos con el monto de origen como base de comision:
python# La comision siempre se calcula sobre el monto de origen
fee_amount = transaction.source_amount * 0.0099
fee_currency = transaction.source_currencyVisualizacion en el panel de control. El panel necesitaba mostrar ambas monedas inteligentemente:
typescript// Frontend: visualizacion de monto de transaccion
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;
}
// Ejemplos de salida:
// "$10.00 USD" (misma moneda)
// "$10.00 USD -> 6,200 XOF" (entre monedas)Los cinco escenarios de prueba
Validamos la actualizacion de moneda con cinco escenarios:
| Escenario | Origen | Destino | Esperado |
|---|---|---|---|
| Misma moneda, USD | $10 USD | nulo | source_amount=10, destination_*=nulo |
| Misma moneda, XOF | 5.000 XOF | nulo | source_amount=5000, destination_*=nulo |
| Entre monedas, USD a XOF | $10 USD | 6.200 XOF | Ambos poblados, exchange_rate=620 |
| Entre monedas, EUR a USD | 10 EUR | $10,85 USD | Ambos poblados, exchange_rate=1,085 |
| Moneda sin decimales | 1.000 JPY | $6,50 USD | Manejo correcto de JPY (sin decimales) |
La prueba de moneda sin decimales era critica. El yen japones no tiene posiciones decimales -- 1.000 JPY son mil yenes, no diez yenes. El sistema de monedas debe saber cuales monedas no tienen decimales para evitar los errores de multiplicacion/division documentados en el articulo 060.
Lo que aprendimos
Los modelos de datos con una sola moneda son una trampa. Funcionan para plataformas de pago domesticas pero fallan inmediatamente cuando entran en juego los pagos transfronterizos. Si estas construyendo un sistema de pagos, comienza con origen/destino desde el primer dia. El costo de migracion es mucho mayor que el costo de diseno inicial.
Los cambios incompatibles de API necesitan una estrategia de obsolescencia. No puedes cambiar la forma de las respuestas de transacciones sin avisar. El enfoque RFC 8594 (cabeceras Deprecation y Sunset) da a los usuarios de SDK cronogramas de migracion legibles por maquina.
Las tasas de cambio deben almacenarse con la transaccion. Las tasas cambian constantemente. Si solo almacenas los montos y necesitas la tasa despues (para calculos de reembolso, resolucion de disputas, conciliacion), debes poder derivarla. Almacenarla explicitamente elimina ambiguedad de redondeo.
La base de la comision debe ser explicita. "0,99% de la transaccion" es ambiguo cuando hay dos montos. Documenta y aplica cual monto es la base de la comision. Elegimos el origen (monto del cliente) porque es lo que el comerciante ve y espera.
Este articulo es parte de la serie "Como construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre mas de 53 proveedores en mas de 200 paises, construido por Juste A. GNIMAVO y Claude desde Abiyan sin ningun ingeniero humano. Sigue la serie para conocer la historia completa de construccion.