ADR-010 · Mercado Pago Açaí topup público
| Campo | Valor |
|---|---|
| Status | Accepted (requiere review jguerrero + 1 backend senior antes de Accepted) |
| Date | 2026-06-05 |
| Authors | tech-lead agent + jguerrero |
| Supersedes | — |
| Superseded by | — |
Context
Sección titulada «Context»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 interfazClient(CreatePreference + VerifyWebhook),NoopClientpara dev sin credenciales reales.platform/backend/go-platform-api/internal/http/topup_handler.go— endpointsPOST /v1/acai/topup/checkout(detrás de X-API-Key del tenant, verWireTenantlínea 39) yPOST /v1/acai/topup/webhook(público, autenticado por HMAC, verWirePubliclínea 47).AcaiBaseRateUSD = 0.015(línea 23) — precio público por Açaí, sin descuento por volumen aún.- Migration
acai_topup_intentsaú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).
Decision
Sección titulada «Decision»1. Topología de endpoints
Sección titulada «1. Topología de endpoints»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 entopup_handler.go:73. - Response 200:
{preference_id, init_point, units, amount_usd}. - Side effect: crea row en
acai_topup_intentscon statepending- emite NATS
acai.topup.created(para observabilidad).
- emite NATS
- Idempotency: cliente puede mandar header
Idempotency-Keyopcional; 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, headerx-signature+x-request-idcomo MP define. Implementado enmercadopago.Client.VerifyWebhook. - Replay protection: window 5 minutos sobre timestamp del header
(
tsen elx-signaturede MP). Reject HTTP 401 si más viejo. - Idempotency duro:
mp_payment_id(eldata.iddel webhook) tiene constraintUNIQUEenacai_topup_intents. Si MP reenvía el mismo webhook (lo hace bajo reintentos), elINSERTfalla con conflict y el handler retorna 200 sin re-acreditar. Es la única protección segura contra doble crédito. - Tenant resolution:
external_referenceviene del checkout con formato"<tenant_uuid>:<units>"(vertopup_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 siapproved, emite NATSacai.creditedcon{tenant_id, units, mp_payment_id, ts}.
2. Migration acai_topup_intents
Sección titulada «2. Migration acai_topup_intents»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:
| Tier | Units | USD |
|---|---|---|
| Starter | 1,000 | 15 |
| Growth | 10,000 | 150 |
| Scale | 100,000 | 1,500 |
| Enterprise | ≥ 500,000 | ver 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+.
4. PCI scope: bjungle NO ve card data
Sección titulada «4. PCI scope: bjungle NO ve card data»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.
5. DIAN / facturación electrónica
Sección titulada «5. DIAN / facturación electrónica»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.
6. Refunds — backlog post-MVP
Sección titulada «6. Refunds — backlog post-MVP»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
unitsy suspendemos sus operaciones hasta resolución. Endpoint admin manual por ahora.
Implementación en Wave E o cuando aparezca el primer caso real.
7. Replay attack defense — window 5 min
Sección titulada «7. Replay attack defense — window 5 min»El header x-signature de MP lleva ts=<unix_seconds>,v1=<hmac>. El
handler:
- Parsea
ts. Siabs(now - ts) > 300s(5 min), reject 401. - Recomputa HMAC sobre
id={data.id};request-id={x-request-id};ts={ts}conMP_WEBHOOK_SECRET. Si no coincide → reject 401. - Solo entonces busca el
mp_payment_idy 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.
8. Frontend Astro /comprar-creditos
Sección titulada «8. Frontend Astro /comprar-creditos»Página pública en website/digital-jungle-site/src/pages/comprar-creditos/:
index.astro— landing con los 4 tiers + form de selecciónexito.astro— return URL MP si payment OKerror.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.
Consequences
Sección titulada «Consequences»Positivas
Sección titulada «Positivas»- 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
currencydel checkout.
Negativas / trade-offs
Sección titulada «Negativas / trade-offs»- 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
(
AcaiBaseRateUSDtambié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_intentscon 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.
Neutrales
Sección titulada «Neutrales»- MP usa pesos COP, no USD: el tenant ve precio en COP en MP. Backend
guarda
amount_usdpara 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 cuandoMP_ENV != "prod". Documentar en runbook.
Alternatives considered
Sección titulada «Alternatives considered»| Alternativa | Por qué se descartó |
|---|---|
| Stripe | Hoy 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. |
| PayU | Tasa 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 MP | MP 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 webhook | Doubles 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. |
Implementation notes
Sección titulada «Implementation notes»Files que se van a tocar (Wave A+):
platform/backend/db-platform/migrations/00XX_acai_topup_intents.up.sql— nuevaplatform/backend/go-platform-api/internal/repo/acai_topup_repo.go— nuevoplatform/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— nuevowebsite/digital-jungle-site/src/pages/comprar-creditos/exito.astro— nuevowebsite/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.sqlcomo referencia. - Outbox pattern para emitir
acai.creditedatómicamente con el crédito del balance — usarevents_outboxglobal ya existente. - Audit log: cada webhook procesado deja entrada en
audit_logconactor='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
disabledentre checkout y webhook, el webhook rechaza el crédito y deja la intent enrejectedcon razón explícita. - External reference malformado: webhook con
external_referenceque 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_ENV—sandbox|productionPUBLIC_TURNSTILE_SITEKEY— Cloudflare Turnstile public keyTURNSTILE_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.
AcaiBaseRateUSDdesfase frontend/backend: si actualizo el rate en backend pero olvido el frontend (o viceversa), elamount_usdenviado a MP no coincide con lo que cobra. Mitigación: tests E2E que validenamount_usd == units * RATEcon tolerancia 0.
Compliance / regulatory considerations
Sección titulada «Compliance / regulatory considerations»- 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: elAcaiBaseRateUSD = 0.015es pre-IVA. El frontend/comprar-creditosmuestra 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):
-
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.
-
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 intentandroid.intent.action.VIEW/ iOSUIApplication.shared.open(). -
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,000 0% (AcaiBaseRateUSD = 0.015) 10,000 – 99,999 10% (effective 0.0135) 100,000 – 499,999 20% (effective 0.012) ≥ 500,000 custom (negociado con sales) Implementación MVP: el cálculo del
amount_usdentopup_handler.goconsidera la suma de topups aprobados del mes corriente del tenant para aplicar el tier — query simple sobreacai_topup_intentsconstate='approved' AND approved_at >= date_trunc('month', now()). Frontend muestra el tier vigente del tenant en/comprar-creditos. -
Webhook idempotency window: ✅ Perpetua. El UNIQUE constraint en
mp_payment_idprotege siempre, no hace falta TTL. Si MP envía un retry a las 25h o más, el handler detectamp_payment_idya visto y retorna 200 sin re-acreditar. Si la intent estaba enexpired, se re-activa aapprovedcon audit entry{event: "delayed_webhook", delay_hours: N}. -
Refund post-Açaí-gastado: ✅ Política dura para MVP.
- Refund completo solo si
tenant.acai_balance >= topup.unitsal 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 losunitsdel chargeback. Resolución manual de jguerrero.
- Refund completo solo si
Cross-references
Sección titulada «Cross-references»- Gap analysis sección “Wave A+ — Mercado Pago Açaí topup”:
integracion-cashpaya-gap-analysis.md - Backlog tasks F1..F10:
cashpaya-integration-backlog.md - ADR-009 Cashpaya como primer cliente
- ADR-008 Blockchain strategy — por qué Açaí no se tokeniza hoy
- ADR-004 API key hardening — scope
acai:writerequerido para checkout - Data dividends 20/7/5 — modelo Açaí del que este flow es el on-ramp
- Suscripción y pagos — contexto histórico del billing model