Back to 0fee
0fee

La gran actualizacion de monedas: monedas de origen y destino

Como rediseñamos el modelo de monedas de 0fee.dev con monedas de origen y destino a traves de 13 archivos. Por Juste A. Gnimavo y Claude.

Thales & Claude | March 30, 2026 9 min 0fee
EN/ FR/ ES
currencyapi-designbreaking-changedatabasepayments

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 $1010.00USDSin 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)  # Obsoleto

Ahora 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:

FaseDescripcionArchivos afectados
1Agregar nuevas columnas a la base de datosmodels/transaction.py, script de migracion
2Actualizar logica de creacion de transaccionesservices/payment.py
3Actualizar adaptadores de proveedores para reportar ambas monedasproviders/*.py
4Actualizar esquemas de respuesta APIschemas/transaction.py
5Actualizar visualizacion del panel de controlfrontend: TransactionList, TransactionDetail
6Actualizar generacion de facturasservices/invoice.py
7Actualizar calculo de comisionesservices/billing.py
8Actualizar SDKsLos 8 paquetes SDK
9Obsolescer y eliminar columnas heredadasLimpieza 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 transaction

Fase 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

ArchivoCambios
models/transaction.py4 columnas agregadas
services/payment.pyLogica de creacion actualizada
services/billing.pyCalculo de comisiones usa source_amount
services/invoice.pyFactura muestra ambas monedas
schemas/transaction.pyEsquema de respuesta actualizado
providers/stripe_adapter.pyReporta moneda de destino
providers/paypal_adapter.pyReporta moneda de destino
providers/hub2_adapter.pyReporta moneda de destino
providers/pawapay_adapter.pyReporta moneda de destino
providers/test_adapter.pySoporta conversion simulada
routes/transactions.pyEndpoints de lista/detalle actualizados
routes/webhooks.pyPayload de webhook incluye ambas monedas
frontend/TransactionDetail.tsxMuestra 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->XOF

Ambiguedad 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_currency

Visualizacion 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:

EscenarioOrigenDestinoEsperado
Misma moneda, USD$10 USDnulosource_amount=10, destination_*=nulo
Misma moneda, XOF5.000 XOFnulosource_amount=5000, destination_*=nulo
Entre monedas, USD a XOF$10 USD6.200 XOFAmbos poblados, exchange_rate=620
Entre monedas, EUR a USD10 EUR$10,85 USDAmbos poblados, exchange_rate=1,085
Moneda sin decimales1.000 JPY$6,50 USDManejo 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles