Ir al contenido

ADR-010 · Mercado Pago Açaí topup público

CampoValor
StatusAccepted (requiere review jguerrero + 1 backend senior antes de Accepted)
Date2026-06-05
Authorstech-lead agent + jguerrero
Supersedes
Superseded by

Açaí es el crédito interno de uso de DJ (ADR data-dividends). Cada screening, signature_pdf, face_step_up, mfa_otp_sms debita un monto Açaí del balance del tenant. Hasta hoy el balance se acreditaba solo manualmente por un admin (POST /v1/acai/credit), lo que es inaceptable para escala — cualquier tenant nuevo (cashpaya o futuros) tiene que poder autoservicio.

Estado del código actual (Wave A+ stub ya merged):

  • platform/backend/go-platform-api/internal/mercadopago/client.go — cliente con interfaz Client (CreatePreference + VerifyWebhook), NoopClient para dev sin credenciales reales.
  • platform/backend/go-platform-api/internal/http/topup_handler.go — endpoints POST /v1/acai/topup/checkout (detrás de X-API-Key del tenant, ver WireTenant línea 39) y POST /v1/acai/topup/webhook (público, autenticado por HMAC, ver WirePublic línea 47).
  • AcaiBaseRateUSD = 0.015 (línea 23) — precio público por Açaí, sin descuento por volumen aún.
  • Migration acai_topup_intents aún pendiente (Wave A+ task F1).

Decisión jguerrero #2: “DJ expone compra de créditos Açaí vía MP desde la web pública → endpoint /v1/acai/topup”. El stub existe pero faltan las decisiones de seguridad/operacionales que este ADR fija.

Cashpaya, como primer tenant (ADR-009), necesita comprar Açaí para que su backend pueda consumir DJ. Sin este flow autoservicio, cada tenant nuevo requiere intervención de jguerrero para acreditar Açaí — no escala a 5-10 tenants (decisión jguerrero #4).

Dos endpoints, con auth distinta cada uno:

1.a · POST /v1/acai/topup/checkout — tenant-auth

Sección titulada «1.a · POST /v1/acai/topup/checkout — tenant-auth»
  • Auth: X-API-Key: dj_live_<...> del tenant que compra.
  • Scope requerido: acai:write.
  • Tenant context: sí, viene del middleware tenancy.Middleware.
  • Body: {"units": <int>}. Mínimo 1000 Açaí (≈ USD 15) — ya enforced en topup_handler.go:73.
  • Response 200: {preference_id, init_point, units, amount_usd}.
  • Side effect: crea row en acai_topup_intents con state pending
    • emite NATS acai.topup.created (para observabilidad).
  • Idempotency: cliente puede mandar header Idempotency-Key opcional; si lo manda, el mismo key en ventana 24h retorna el mismo preference_id sin crear duplicado.

1.b · POST /v1/acai/topup/webhook — público

Sección titulada «1.b · POST /v1/acai/topup/webhook — público»
  • Auth: NO lleva X-API-Key. MP llama server-to-server desde su infra y no conoce credenciales del tenant.
  • Verificación: HMAC SHA-256 del payload contra MP_WEBHOOK_SECRET, header x-signature + x-request-id como MP define. Implementado en mercadopago.Client.VerifyWebhook.
  • Replay protection: window 5 minutos sobre timestamp del header (ts en el x-signature de MP). Reject HTTP 401 si más viejo.
  • Idempotency duro: mp_payment_id (el data.id del webhook) tiene constraint UNIQUE en acai_topup_intents. Si MP reenvía el mismo webhook (lo hace bajo reintentos), el INSERT falla con conflict y el handler retorna 200 sin re-acreditar. Es la única protección segura contra doble crédito.
  • Tenant resolution: external_reference viene del checkout con formato "<tenant_uuid>:<units>" (ver topup_handler.go:79). Webhook parsea, valida tenant existe + activo, y acredita.
  • Side effect: actualiza row a approved/rejected/expired, acredita balance Açaí del tenant si approved, emite NATS acai.credited con {tenant_id, units, mp_payment_id, ts}.
CREATE TABLE platform.acai_topup_intents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES platform.tenants(id),
units bigint NOT NULL CHECK (units >= 1000),
amount_usd numeric(12,4) NOT NULL,
state text NOT NULL CHECK (state IN ('pending','approved','rejected','expired')) DEFAULT 'pending',
mp_preference_id text NOT NULL,
mp_payment_id text UNIQUE, -- NULL hasta que webhook confirma
external_reference text NOT NULL,
idempotency_key text,
created_at timestamptz NOT NULL DEFAULT now(),
approved_at timestamptz,
rejected_at timestamptz,
expires_at timestamptz NOT NULL DEFAULT now() + interval '24 hours',
raw_webhook jsonb -- payload del webhook (audit)
);
CREATE UNIQUE INDEX idx_acai_topup_idempotency
ON platform.acai_topup_intents(tenant_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE INDEX idx_acai_topup_tenant_created
ON platform.acai_topup_intents(tenant_id, created_at DESC);

RLS estándar: deny_all + helper SECURITY DEFINER acai_topup_credit(...) para el path del webhook (que no tiene tenant context al entrar). El helper hace el upsert atómico de balance + intent + outbox event.

3. Pricing tiers — hardcoded MVP, DB después

Sección titulada «3. Pricing tiers — hardcoded MVP, DB después»

MVP: tiers en frontend Astro (/comprar-creditos) hardcoded:

TierUnitsUSD
Starter1,00015
Growth10,000150
Scale100,0001,500
Enterprise≥ 500,000ver consulta sales

Backend valida solo units >= 1000 y amount_usd = units * AcaiBaseRateUSD (cálculo determinístico). NO valida que la combinación esté en un tier conocido — el frontend es UI guidance, el backend acepta cualquier units >= 1000.

Backlog post-MVP: tabla platform.acai_plans con tiers + descuentos por volumen + plans enterprise con contrato custom. No bloqueante para Wave A+.

MP custodia toda la card data. El init_point redirige al checkout de MP (mercadopago.com.co/…), el browser del usuario interactúa con MP directamente, MP cobra, MP notifica via webhook. El backend bjungle nunca recibe PAN, CVV, expiry.

Esto coloca a bjungle en PCI-DSS scope “SAQ A” (merchant que externaliza completamente el procesamiento de tarjetas a un PSP certificado). Documentación operacional pendiente, pero la decisión arquitectónica está fijada: nunca aceptar form de tarjeta en bjungle hosted.

bjungle factura al tenant (cashpaya, etc.) por el monto USD del topup, vía factura electrónica POS DIAN compliant. La factura no va por el marketplace de MP — MP nos paga a nosotros (account merchant bjungle), nosotros emitimos factura al tenant separadamente.

Esto significa:

  • MP merchant account: nombre fiscal Bjungle SAS, IVA aplicable.
  • Factura electrónica al tenant: integración con proveedor DIAN (Carvajal, Facture, Soluciones Alegra o similar) — fuera de scope de este ADR, va a Wave E o post-MVP. Por ahora factura manual.
  • Açaí no es moneda: el tenant compra “créditos de uso” prepagados, no convierte fiat → cripto → fiat. Esto es importante para evitar triggers PSAV/SARLAFT sobre bjungle.

MP soporta refund API. Pero hay una complicación: si el tenant ya consumió Açaí del balance (debit irreversible al hacer un screening o signature), el refund de fiat no recupera el Açaí gastado.

Política MVP:

  • Refund completo solo si el tenant no ha gastado ningún Açaí del topup específico (balance ≥ units topup).
  • Refund parcial: rechazado en MVP. Comunicar al tenant que negocie con jguerrero manualmente.
  • Disputa MP (chargeback): si MP nos notifica chargeback, bajamos el balance Açaí del tenant en units y suspendemos sus operaciones hasta resolución. Endpoint admin manual por ahora.

Implementación en Wave E o cuando aparezca el primer caso real.

El header x-signature de MP lleva ts=<unix_seconds>,v1=<hmac>. El handler:

  1. Parsea ts. Si abs(now - ts) > 300s (5 min), reject 401.
  2. Recomputa HMAC sobre id={data.id};request-id={x-request-id};ts={ts} con MP_WEBHOOK_SECRET. Si no coincide → reject 401.
  3. Solo entonces busca el mp_payment_id y procesa.

Esto evita que un atacante que capture un webhook viejo (ej. en un proxy mal configurado) lo replee horas después y dispare doble crédito. El UNIQUE constraint sobre mp_payment_id es la segunda línea de defensa, pero la primera (timestamp) reduce la superficie en órdenes de magnitud.

Página pública en website/digital-jungle-site/src/pages/comprar-creditos/:

  • index.astro — landing con los 4 tiers + form de selección
  • exito.astro — return URL MP si payment OK
  • error.astro — return URL MP si falla

La página requiere que el tenant esté logueado en el portal de bjungle para tener su API key disponible (o que el form de checkout reciba la key via SSO con bmonkey). MVP simple: el usuario hace login en el portal, abre /comprar-creditos, el portal pasa la key vía session storage al frontend Astro, frontend Astro llama POST /v1/acai/topup/checkout.

Anti-bot: Cloudflare Turnstile en el form (PR5 en procurement). Sin esto, un atacante puede generar miles de preference ids basura — no acredita Açaí pero satura DB. Turnstile siteKey en env PUBLIC_TURNSTILE_SITEKEY.

  • Autoservicio para tenants: cashpaya y cualquier tenant futuro recargan sin involucrar a jguerrero. Elimina cuello de botella humano.
  • Idempotency duro vía DB constraint: doble crédito imposible aun bajo reintentos agresivos de MP. La constraint es ruda, las pruebas pasan trivialmente.
  • PCI scope mínimo (SAQ A): bjungle no tiene obligación de certificarse contra el estándar completo. Reduce costo recurrente $30-50k USD anuales en audit.
  • Açaí como crédito prepagado evita PSAV: no somos cambiador, no somos PSAV, no entramos a SuperFinanciera por este flow.
  • Replay defense capa doble: timestamp + UNIQUE constraint. Una falla, la otra atrapa.
  • Compatibilidad multi-país futura: MP opera en AR/MX/BR/CO/CL/PE. El día que un tenant pague en MXN/BRL, el flow no cambia — solo el currency del checkout.
  • Dependencia operacional dura de MP: si MP cae, no hay topups. Mitigación: Wompi como alternativa CO (ver alternatives) en backlog, pero no en MVP. Aceptable porque MP uptime histórico es ~99.9%.
  • Comisión MP: ~3.99% + IVA sobre transacciones. En USD 1500 Enterprise eso son ~USD 70 que bjungle absorbe (no lo cobra al tenant). Pricing impactado.
  • Pricing rígido en frontend: si jguerrero quiere cambiar de USD 0.015/Açaí a 0.018, requiere deploy del frontend Astro (AcaiBaseRateUSD también en backend). Aceptable para MVP, migrar a DB cuando los planes enterprise lo requieran.
  • Anti-bot crítico: sin Turnstile, un script kiddie puede llenar acai_topup_intents con basura. La tabla crecería sin acreditar nada, pero saturaría el índice y los logs. Turnstile NO es opcional para prod.
  • Refunds limitados: caso real “tenant equivocó el monto” o “compró por error” requiere intervención manual jguerrero hasta Wave E. UX subóptimo.
  • MP usa pesos COP, no USD: el tenant ve precio en COP en MP. Backend guarda amount_usd para historial uniforme. La conversión COP↔USD la hace MP usando el TRM del día. Si TRM se mueve mucho, hay desfases de centavos que se reconcilian post-factura.
  • El endpoint dev-simulate (topup_handler.go:48) queda activo SOLO cuando MP_ENV != "prod". Documentar en runbook.
AlternativaPor qué se descartó
StripeHoy 2026 sigue sin soportar COP nativo bien — requiere convertir a USD via Stripe Atlas o similar. Para un producto colombiano vendido a fintechs colombianas es fricción innecesaria. Si DJ se expande a US, reevaluar.
Wompi (alternativa CO real)Wompi (Bancolombia) tiene buena calidad, tasa similar a MP, y es 100% CO-native. Razones para preferir MP: (1) cashpaya ya tiene cuenta MP operativa para su marketplace, reusa procurement, (2) MP cubre mercado regional (LATAM) más allá de CO, (3) jguerrero ya hizo el stub MP en código. Wompi queda como segunda opción y se evalúa si MP da problemas serios de approval o tasa.
PayUTasa más alta, UX checkout más débil, no aporta nada sobre MP en CO.
Billing directo (factura mensual)Aceptable a mediano plazo cuando el tenant promedio gasta >USD 500/mes y la operación humana de cobrar manualmente vale la pena. Hoy, con 1 tenant y planeando 5-10, autoservicio MP es mucho más barato operacionalmente. Documentar como opción en planes enterprise.
Crypto on-ramp (Açaí ERC-20 + MetaMask checkout)Cerrada por ADR-008 blockchain strategy: NO tokenizar Açaí hoy. Reabrir en 12-18 meses si aparece demanda.
Webhook sin HMAC, confiar en IP allowlist MPMP no publica lista estable de IPs y rota. IP allowlist sería frágil. HMAC es el estándar de la industria, ya implementado.
Polling MP API en vez de webhookDoubles latency, doubles costo. Webhook es el patrón nativo MP. Polling como fallback de DR si algún día falla el webhook por > 1 hora — backlog, no MVP.

Files que se van a tocar (Wave A+):

  • platform/backend/db-platform/migrations/00XX_acai_topup_intents.up.sql — nueva
  • platform/backend/go-platform-api/internal/repo/acai_topup_repo.go — nuevo
  • platform/backend/go-platform-api/internal/service/acai_topup_service.go — nuevo (helper SECURITY DEFINER caller)
  • platform/backend/go-platform-api/internal/http/topup_handler.go — extender los stubs existentes (idempotency, replay defense, NATS emit)
  • platform/backend/go-platform-api/internal/mercadopago/http_client.go — implementación HTTP real (existe Noop pero falta real)
  • website/digital-jungle-site/src/pages/comprar-creditos/index.astro — nuevo
  • website/digital-jungle-site/src/pages/comprar-creditos/exito.astro — nuevo
  • website/digital-jungle-site/src/pages/comprar-creditos/error.astro — nuevo

Patrones a respetar:

  • RLS deny_all + SECURITY DEFINER para el helper que el webhook llama sin tenant context — ver 0005_wallet_global.up.sql como referencia.
  • Outbox pattern para emitir acai.credited atómicamente con el crédito del balance — usar events_outbox global ya existente.
  • Audit log: cada webhook procesado deja entrada en audit_log con actor='mercadopago', event_type='acai.topup.credited', metadata={mp_payment_id, units, amount_usd}.

Tests obligatorios:

  • Happy path: checkout → MP sandbox → webhook → balance updated. Wave A+ F8.
  • Idempotency: dispatch del mismo webhook 2 veces → balance se acredita 1 sola vez. Constraint UNIQUE atrapa el segundo.
  • Replay window: webhook con ts > 5 min → 401. Falsificar el timestamp y verificar que el HMAC se valida pero el ts rechaza.
  • HMAC mismatch: payload con firma incorrecta → 401.
  • Tenant inactivo: si el tenant fue disabled entre checkout y webhook, el webhook rechaza el crédito y deja la intent en rejected con razón explícita.
  • External reference malformado: webhook con external_reference que no parsea → 400 + audit log entry.

Env vars requeridas (SSM params QA + prod):

  • MP_ACCESS_TOKEN — token de la app MP (production o sandbox)
  • MP_WEBHOOK_SECRET — secret HMAC compartido (configurar en MP dashboard)
  • MP_PUBLIC_KEY — public key MP (opcional, para Checkout API embedido futuro)
  • MP_ENVsandbox | production
  • PUBLIC_TURNSTILE_SITEKEY — Cloudflare Turnstile public key
  • TURNSTILE_SECRET — Cloudflare Turnstile secret (validación server-side)

Riesgos a vigilar:

  • MP cambia el formato del header x-signature: ya pasó una vez en 2024. Mitigación: tests de fixture con webhooks reales de MP sandbox
    • alerta CloudWatch si la tasa de 401 crece > 1%.
  • Tenant compra → tenant deletado antes del webhook: rare race, el helper SECURITY DEFINER debe FK-check al tenant y rechazar limpiamente si no existe — sin panic, sin doble write.
  • AcaiBaseRateUSD desfase frontend/backend: si actualizo el rate en backend pero olvido el frontend (o viceversa), el amount_usd enviado a MP no coincide con lo que cobra. Mitigación: tests E2E que validen amount_usd == units * RATE con tolerancia 0.
  • PCI-DSS SAQ A: bjungle no ve card data. Confirmar con auditor que el flow externalizado a MP califica para SAQ A simplificado. Documento de compliance bjungle pendiente.
  • DIAN factura electrónica: bjungle emite factura POS al tenant por el USD comprado. Integración DIAN proveedor (Carvajal/Facture) es procurement no-técnico — owner jguerrero, no bloquea este ADR.
  • IVA Colombia (19%): aplicable sobre el monto USD facturado al tenant. MP no incluye IVA en su comisión (lo factura aparte). bjungle debe facturar al tenant amount_usd + IVA. Decisión: el AcaiBaseRateUSD = 0.015 es pre-IVA. El frontend /comprar-creditos muestra precio
    • IVA breakdown.
  • Açaí no es activo virtual: créditos prepagados de uso de un servicio digital específico. No entra en la definición SuperFinanciera de cripto-activo ni de PSAV. Si en algún futuro se vuelve transferible cross-tenant, reabrir este ADR (ver ADR-008).

Open questions — Resoluciones (2026-06-05)

Sección titulada «Open questions — Resoluciones (2026-06-05)»

Decididas en review de ADR (Jonhjar Guerrero, 2026-06-05):

  1. Cuenta merchant MP: ✅ Bjungle SAS. Habilita facturación corporativa + IVA correcto. Procurement no-técnico — jguerrero completa el alta MP con NIT Bjungle SAS antes de Wave A+ task F10.

  2. Topup desde mobile webview: ✅ Browser redirect. Cuando un admin cashpaya hace topup desde la app móvil, abrimos el checkout MP en el browser nativo del SO (no webview). Evita issues de 3DS dentro de webviews (problema conocido MP 2024). Implementación frontend: target="_blank" o intent android.intent.action.VIEW / iOS UIApplication.shared.open().

  3. Pricing tiers Enterprise (resuelve también OQ 9.3 de ADR-009): ✅ Escala con descuento por volumen aplicado en backend:

    Units por mes (suma de topups)Descuento
    < 10,0000% (AcaiBaseRateUSD = 0.015)
    10,000 – 99,99910% (effective 0.0135)
    100,000 – 499,99920% (effective 0.012)
    ≥ 500,000custom (negociado con sales)

    Implementación MVP: el cálculo del amount_usd en topup_handler.go considera la suma de topups aprobados del mes corriente del tenant para aplicar el tier — query simple sobre acai_topup_intents con state='approved' AND approved_at >= date_trunc('month', now()). Frontend muestra el tier vigente del tenant en /comprar-creditos.

  4. Webhook idempotency window: ✅ Perpetua. El UNIQUE constraint en mp_payment_id protege siempre, no hace falta TTL. Si MP envía un retry a las 25h o más, el handler detecta mp_payment_id ya visto y retorna 200 sin re-acreditar. Si la intent estaba en expired, se re-activa a approved con audit entry {event: "delayed_webhook", delay_hours: N}.

  5. Refund post-Açaí-gastado: ✅ Política dura para MVP.

    • Refund completo solo si tenant.acai_balance >= topup.units al momento de request. Endpoint admin manual.
    • Refund parcial / pro-rata: diferido a Wave E o cuando aparezca el primer caso real.
    • Chargeback MP: dispara tenant.suspend() automático + Açaí debit de los units del chargeback. Resolución manual de jguerrero.