Arquitectura
Ocho diagramas que explican cómo funciona ApiPay Hub por dentro, en el orden en que conviene leerlos: primero el contexto y el despliegue, después la estructura del servicio de pagos, y por último los cuatro mecanismos que determinan cómo se comporta tu integración —el flujo con redirección, el outbox transaccional, el aislamiento entre comercios y la entrega de webhooks— más la política de reintentos de los SDKs.
Cada diagrama existe en dos formatos que son el mismo diagrama: el que ves aquí, renderizado con Mermaid, y un archivo .drawio editable en diagrams.net que puedes descargar desde el enlace de cada figura. Si algo cambia, se actualizan los dos.
El sistema que integras
Qué hace la plataforma, cómo viaja un pago y qué garantías te da. Estos ocho existen también como .drawio editable.
Contexto del sistema
Empieza por aquí si es tu primer día con ApiPay. El diagrama fija las tres relaciones que definen el producto: tu backend habla con api.apipay.io de servidor a servidor con una clave secreta y nunca desde el navegador; el comprador solo interactúa con el iframe de checkout.apipay.io; y el hub te devuelve el desenlace por webhook firmado. La línea punteada que va del comprador directo a las pasarelas es la que decide el alcance de cumplimiento: los datos de tarjeta viajan de su navegador a la pasarela sin pasar por ningún componente nuestro, y por eso ApiPay califica para PCI DSS SAQ A. Si un diagrama de tu integración dibuja el PAN entrando a ApiPay, ese diagrama está mal.
Contexto del sistema
Quién habla con ApiPay Hub: el backend del comercio, el comprador en el navegador y las pasarelas de pago.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart TB
subgraph actores["Personas y sistemas del comercio"]
buyer["Comprador<br/>navegador"]
merchant["Comercio<br/>frontend propio + backend propio"]
operator["Operador del hub<br/>PLATFORM_ADMIN · TENANT_ADMIN · TENANT_ANALYST"]
end
subgraph hub["ApiPay Hub · los cuatro dominios"]
api["api.apipay.io<br/>payment-api · REST /v1<br/>auth: header X-API-Key con sk_"]
admin["admin.apipay.io<br/>backoffice + admin-api<br/>auth: JWT de Azure AD B2C"]
checkout["checkout.apipay.io<br/>checkout-widget en iframe<br/>auth: pk_ + X-Client-Secret"]
docs["docs.apipay.io<br/>docs-portal · estático, sin datos de tenants"]
end
subgraph pasarelas["Pasarelas externas"]
webpay["Transbank Webpay Plus<br/>vía Servipag"]
mercadopago["MercadoPago"]
end
b2c["Azure AD B2C<br/>OIDC · authorization code + PKCE"]
merchant -->|"crea, confirma y consulta intents"| api
merchant -->|"lee guías, referencia y recetas"| docs
buyer -->|"navega el checkout del comercio"| merchant
buyer -->|"paga dentro del iframe del widget"| checkout
operator -->|"configura pasarelas y revisa entregas"| admin
operator -->|"inicia sesión"| b2c
admin -->|"valida el JWT contra JWKS"| b2c
checkout -->|"GET y confirm del intent propio"| api
api -->|"init y commit de la transacción"| webpay
api -->|"pagos y refunds"| mercadopago
mercadopago -->|"webhooks firmados entrantes"| api
api -->|"webhooks salientes firmados con ApiPay-Signature<br/>(los entrega webhook-dispatcher)"| merchant
buyer -.->|"PAN y CVV solo aquí: hosted fields o redirección de la pasarela<br/>fuera del alcance PCI del hub"| pasarelas
classDef actor fill:#FFF8E1,stroke:#F9A825,color:#1A1A1A
classDef hubnode fill:#E3F2FD,stroke:#1565C0,color:#1A1A1A
classDef ext fill:#F3E5F5,stroke:#6A1B9A,color:#1A1A1A
class buyer,merchant,operator actor
class api,admin,checkout,docs hubnode
class webpay,mercadopago,b2c extContenedores de la plataforma
La vista de despliegue: qué proceso corre dónde y con qué protocolo habla. Importa por dos razones prácticas. La primera es que payment-api y admin-api son dos fronteras de seguridad distintas, con superficies de autenticación distintas (clave de API contra JWT de Azure AD B2C): tu integración server-to-server nunca toca admin.apipay.io. La segunda es que webhook-dispatcher es un worker sin HTTP público, así que los picos de entrega o un endpoint tuyo que responde lento jamás compiten por threads con el camino de cobro. El widget y este portal son bundles estáticos servidos por NGINX: no tienen backend ni acceso a datos de ningún tenant.
Contenedores de la plataforma
payment-api, admin-api, webhook-dispatcher, backoffice, checkout-widget y el portal, con PostgreSQL, Redis y Kafka.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart LR
subgraph clientes["Clientes"]
merchantsrv["Backend del comercio"]
buyerbrowser["Navegador del comprador"]
devbrowser["Navegador del desarrollador"]
opbrowser["Navegador del operador"]
end
subgraph cluster["Clúster AKS · namespace apipay-prod"]
ingress["NGINX Ingress + cert-manager<br/>TLS 1.3 en el borde"]
paymentapi["payment-api<br/>Spring Boot 4 · Java 25 · puerto 8080"]
adminapi["admin-api<br/>Spring Boot 4 · Java 25 · puerto 8081"]
backoffice["backoffice<br/>Next.js 15 App Router · puerto 3000"]
widget["checkout-widget<br/>estático de Vite 7 · NGINX 8080"]
docsportal["docs-portal<br/>estático de Next.js 15 · NGINX 8080"]
dispatcher["webhook-dispatcher<br/>worker Kafka sin HTTP público<br/>management 8090 solo interno"]
end
subgraph azure["Servicios administrados"]
postgres[("PostgreSQL 17<br/>schema apipay · RLS")]
redis[("Redis 7.4<br/>idempotencia · rate limit · sesión del widget")]
kafka[["Kafka / Event Hubs · 3 topics<br/>apipay.payment-events.v1<br/>apipay.outbound-webhooks.v1<br/>apipay.audit.v1"]]
keyvault["Azure Key Vault<br/>credenciales de pasarela · KEK de firmas"]
end
subgraph gateways["Pasarelas"]
webpay["Webpay Plus vía Servipag"]
mercadopago["MercadoPago"]
sandbox["gateway-sandbox<br/>módulo in-process de payment-api<br/>solo livemode=false"]
end
merchantsrv -->|"HTTPS api.apipay.io"| ingress
buyerbrowser -->|"HTTPS checkout.apipay.io"| ingress
devbrowser -->|"HTTPS docs.apipay.io"| ingress
opbrowser -->|"HTTPS admin.apipay.io"| ingress
gateways -->|"webhooks entrantes<br/>POST /v1/gateways/{gatewayId}/webhooks"| ingress
ingress -->|"HTTP interno"| paymentapi
ingress -->|"HTTP interno"| adminapi
ingress -->|"HTTP interno"| backoffice
ingress -->|"HTTP interno"| widget
ingress -->|"HTTP interno"| docsportal
paymentapi -->|"SQL/TLS · rol apipay_app + SET LOCAL app.tenant_id"| postgres
paymentapi -->|"SQL/TLS · rol apipay_worker: relay del outbox"| postgres
adminapi -->|"SQL/TLS · apipay_app y apipay_worker"| postgres
dispatcher -->|"SQL/TLS · rol apipay_worker: deliveries y secretos cifrados"| postgres
paymentapi -->|"RESP/TLS"| redis
adminapi -->|"RESP/TLS: caché de configuración del tenant"| redis
paymentapi -->|"publica lo drenado de outbox_events"| kafka
adminapi -->|"publica su auditoría con su propio relay"| kafka
kafka -->|"consume apipay.outbound-webhooks.v1<br/>grupo webhook-dispatcher"| dispatcher
paymentapi -.->|"Workload Identity: credenciales por tenant"| keyvault
adminapi -.->|"Workload Identity"| keyvault
dispatcher -.->|"Workload Identity: KEK de los secretos de firma"| keyvault
paymentapi -->|"HTTPS vía el SPI PaymentGateway"| webpay
paymentapi -->|"HTTPS vía el SPI PaymentGateway"| mercadopago
paymentapi -.->|"in-process, sin salida a red"| sandbox
dispatcher -->|"POST firmado al webhook_endpoint"| merchantsrv
classDef cli fill:#FFF8E1,stroke:#F9A825,color:#1A1A1A
classDef svc fill:#E3F2FD,stroke:#1565C0,color:#1A1A1A
classDef stat fill:#E0F7FA,stroke:#00838F,color:#1A1A1A
classDef data fill:#E8F5E9,stroke:#2E7D32,color:#1A1A1A
classDef ext fill:#F3E5F5,stroke:#6A1B9A,color:#1A1A1A
class merchantsrv,buyerbrowser,devbrowser,opbrowser cli
class ingress,paymentapi,adminapi,backoffice,dispatcher svc
class widget,docsportal stat
class postgres,redis,kafka,keyvault data
class webpay,mercadopago,sandbox extArquitectura hexagonal de payment-api
Cómo está organizado el servicio que atiende tus llamadas, y por qué eso te afecta. El dominio no importa Spring ni JPA, y todas las pasarelas viven detrás de un único puerto, el SPI PaymentGateway, resuelto en tiempo de ejecución por (tenant, pasarela). La consecuencia que se nota en tu integración: la forma de tu petición no cambia cuando cambias de pasarela. Añadir una pasarela nueva es una librería que implementa el SPI más una fila en el catálogo, con cero cambios en el dominio, en la aplicación ni en el contrato público. Las reglas de dependencia no son una aspiración: las verifica ArchUnit en cada build.
Arquitectura hexagonal de payment-api
Dominio en el centro, puertos de entrada y salida alrededor, y los adaptadores REST, persistencia y pasarela en el borde.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart TB
subgraph entrada["adapters.in.rest — puertos de entrada"]
secfilter["ApiKeyAuthenticationFilter<br/>libs/security · hash Argon2id de la clave"]
tenantfilter["TenantContextFilter<br/>libs/tenancy · fija app.tenant_id"]
intentctrl["PaymentIntentController<br/>/v1/payment-intents"]
refundctrl["RefundController<br/>/v1/refunds"]
webhookctrl["WebhookController<br/>/v1/gateways/{gatewayId}/webhooks"]
end
subgraph aplicacion["application — servicios de aplicación"]
intentsvc["PaymentIntentService"]
refundsvc["RefundService"]
ingestsvc["WebhookIngestionService"]
end
subgraph dominio["domain — sin Spring, sin jakarta.persistence"]
aggregate["Agregado PaymentIntent<br/>amount_minor + currency, invariantes<br/>metadata del comercio vs. reservada apipay_*"]
statemachine["Máquina de estados<br/>CREATED · REQUIRES_ACTION · PROCESSING<br/>SUCCEEDED · FAILED · EXPIRED · CANCELED"]
txmodel["Transaction / TransactionEvent<br/>PAYMENT · REFUND · CHARGEBACK"]
refundmodel["Refund"]
end
subgraph puertos["application.port.out — puertos de salida (interfaces)"]
gwport["PaymentGateway<br/>SPI de libs/gateway-spi"]
repoport["PaymentIntentRepository<br/>TransactionRepository · RefundRepository"]
outboxport["OutboxPublisher"]
idemport["IdempotencyStore"]
end
subgraph outpersist["adapters.out.persistence · adapters.out.redis"]
jpaadapters["Adapters JPA<br/>Hibernate @TenantId + RLS"]
outboximpl["OutboxJpaPublisher<br/>INSERT en outbox_events en la misma transacción"]
redisidem["RedisIdempotencyStore<br/>idem:{tenantId}:{key} · TTL 24 h"]
end
subgraph outgateway["adapters.out.gateway"]
registry["GatewayRegistry<br/>resuelve por (tenantId, gatewayId)<br/>contra tenant_gateway_configs"]
gwebpay["gateway-webpay"]
gmp["gateway-mercadopago"]
gsandbox["gateway-sandbox<br/>solo livemode=false"]
kvres["KeyVaultGatewayCredentialsResolver<br/>Key Vault + caché Caffeine TTL 300 s"]
end
reglas["Reglas verificadas con ArchUnit:<br/>apps → libs siempre · libs → apps jamás<br/>gateway-* solo depende de gateway-spi + core-domain<br/>domain no importa Spring ni jakarta.persistence"]
secfilter --> tenantfilter
tenantfilter --> intentctrl
tenantfilter --> refundctrl
tenantfilter -.->|"sin X-API-Key: la autenticidad la da la firma de la pasarela"| webhookctrl
intentctrl --> intentsvc
refundctrl --> refundsvc
webhookctrl --> ingestsvc
intentsvc --> aggregate
intentsvc --> statemachine
refundsvc --> refundmodel
refundsvc --> txmodel
ingestsvc --> statemachine
ingestsvc --> txmodel
intentsvc --> gwport
intentsvc --> repoport
intentsvc --> outboxport
intentsvc --> idemport
refundsvc --> gwport
refundsvc --> repoport
ingestsvc --> repoport
ingestsvc --> outboxport
gwport -.->|"implementado por"| registry
repoport -.->|"implementado por"| jpaadapters
outboxport -.->|"implementado por"| outboximpl
idemport -.->|"implementado por"| redisidem
registry --> gwebpay
registry --> gmp
registry --> gsandbox
registry --> kvres
classDef inbound fill:#E3F2FD,stroke:#1565C0,color:#1A1A1A
classDef app fill:#E0F7FA,stroke:#00838F,color:#1A1A1A
classDef dom fill:#FFF8E1,stroke:#F9A825,color:#1A1A1A
classDef port fill:#EDE7F6,stroke:#4527A0,color:#1A1A1A
classDef adapter fill:#E8F5E9,stroke:#2E7D32,color:#1A1A1A
classDef nota fill:#FFFDE7,stroke:#F9A825,color:#1A1A1A
class secfilter,tenantfilter,intentctrl,refundctrl,webhookctrl inbound
class intentsvc,refundsvc,ingestsvc app
class aggregate,statemachine,txmodel,refundmodel dom
class gwport,repoport,outboxport,idemport port
class jpaadapters,outboximpl,redisidem,registry,gwebpay,gmp,gsandbox,kvres adapter
class reglas notaFlujo de redirección de Webpay Plus
El flujo más sutil del producto, y el que más consultas de soporte genera. Webpay Plus no admite hosted fields: exige llevar al comprador a su formulario y volver con un token_ws. El estado REQUIRES_ACTION modela exactamente ese paréntesis, y el mismo endpoint de confirm sirve las dos fases: sin token hace el init y devuelve la redirect_url, y con token_ws hace el commit y resuelve el pago. Fíjate en los pasos 8 y 17: son dos confirm, no uno. Y fíjate en que el veredicto llega de forma síncrona en el commit, no por webhook, a diferencia de MercadoPago. Si el comprador nunca vuelve, el guard de conciliación lleva el intent a EXPIRED al vencer su TTL para que ninguno quede huérfano.
Flujo de redirección de Webpay Plus
Del intent creado a la vuelta del comprador con token_ws y el segundo confirm que liquida el cobro.
Renderizando el diagrama…
Ver la fuente mermaid
sequenceDiagram
autonumber
participant EC as Backend del comercio (sk_test_)
participant BR as Navegador del comprador
participant WG as checkout-widget (iframe)
participant PA as payment-api
participant DB as PostgreSQL 17 (RLS)
participant WP as Webpay Plus (Servipag)
participant OB as Outbox, Kafka y webhook-dispatcher
EC->>PA: POST /v1/payment-intents con X-API-Key sk_test_, Idempotency-Key y return_url
PA->>DB: SET LOCAL app.tenant_id, INSERT payment_intents (CREATED) + outbox_events en UNA transacción
Note over PA,DB: la return_url se persiste en la clave reservada apipay_return_url,<br/>que nunca aparece en el objeto metadata que ve el comercio
PA-->>EC: 201 con id pi_... y client_secret
EC-->>BR: renderiza su checkout y entrega el client_secret al widget
BR->>WG: monta el iframe de checkout.apipay.io (handshake postMessage con origin estricto y nonce)
WG->>PA: GET /v1/payment-intents/pi_... con pk_test_ y header X-Client-Secret
PA-->>WG: 200 intent CREATED con las pasarelas habilitadas del tenant
WG->>PA: POST /v1/payment-intents/pi_.../confirm — PRIMER confirm, sin token
PA->>WP: createPayment vía gateway-webpay con la return_url persistida
WP-->>PA: token_ws y URL del formulario de pago
PA->>DB: CREATED -> REQUIRES_ACTION, publica apipay_redirect_url + outbox_events
PA-->>WG: 200 con status REQUIRES_ACTION y redirect_url
WG->>BR: redirección top-level al formulario de Webpay, fuera del iframe
BR->>WP: el comprador paga con su tarjeta en el sitio de Webpay
Note over BR,WP: el PAN y el CVV no atraviesan ningún componente de ApiPay (PCI DSS SAQ A)
WP-->>BR: 302 hacia la return_url del comercio con token_ws en la query
BR->>WG: vuelve a la página de checkout con el token_ws
WG->>PA: POST /v1/payment-intents/pi_.../confirm con token_ws — SEGUNDO confirm
Note over WG,PA: el token_ws puede llegar en gateway_params o en la query string:<br/>el controlador fusiona ambos y la query gana
PA->>DB: REQUIRES_ACTION -> PROCESSING + outbox_events
PA->>WP: commit del token_ws vía gateway-webpay
alt autorización aprobada
WP-->>PA: aprobada
PA->>DB: PROCESSING -> SUCCEEDED + transaction APPROVED + outbox_events
else autorización rechazada
WP-->>PA: rechazada
PA->>DB: PROCESSING -> FAILED + transaction REJECTED + outbox_events
end
PA-->>WG: 200 con el estado final del intent
PA->>OB: el relay drena outbox_events y publica payment_intent.succeeded o .failed
OB->>EC: POST firmado con ApiPay-Signature al webhook_endpoint del comercio
Note over PA,WP: si el comprador nunca vuelve, el guard de conciliación llama a fetchStatus<br/>y lleva el intent a EXPIRED al vencer su TTL
Note over WG,PA: el token_ws es de un solo uso: ningún SDK reintenta este POST automáticamente.<br/>Webpay entrega el veredicto en el commit, no por webhookCheckout alojado («botón de pago»)
Checkout alojado («botón de pago»)
El intent nace sin gateway_id, el comprador llega a checkout_url y elige pasarela allí: la elección queda fijada recién cuando hay referencia en la pasarela.
Renderizando el diagrama…
Ver la fuente mermaid
sequenceDiagram
autonumber
participant EC as Comercio (backend sk_test_ y tienda)
participant BR as Navegador del comprador
participant CO as Checkout alojado de ApiPay (modo página)
participant PA as payment-api
participant PG as Pasarela que elige el comprador
EC->>PA: POST /v1/payment-intents SIN gateway_id, con Idempotency-Key, return_url y merchant_return_url
Note over EC,PA: crear el intent sin pasarela es lo que distingue a un hub de un frontal de una sola pasarela:<br/>PaymentIntentService.doCreate acepta gateway nulo a propósito, para que el selector tenga algo que ofrecer
PA-->>EC: 201 con pi_..., client_secret y checkout_url
Note over PA,EC: checkout_url SOLO se emite en el 201. Es el único instante en que el client_secret existe en claro,<br/>así que un GET posterior NO puede reconstruirla y omite el campo: si el comercio la pierde, crea otro intent.<br/>También se omite si el despliegue no tiene base de checkout o si el tenant no declaró clave publicable
EC-->>BR: manda al comprador a checkout_url
BR->>CO: GET /session?pk=pk_test_...#client_secret=pi_..._secret_...
Note over BR,CO: la forma exacta de checkout_url la arma PaymentIntentController.hostedCheckoutUrl.<br/>El client_secret viaja tras la almohadilla a propósito: un FRAGMENTO no llega a los logs del servidor<br/>ni a la cabecera Referer cuando el navegador salta a la pasarela.<br/>El checkout lo lee, lo guarda en sessionStorage y lo borra del historial con history.replaceState()
CO->>PA: GET /v1/payment-intents/pi_... con pk_test_ y header X-Client-Secret
PA-->>CO: 200 con checkout.payment_methods = TODAS las pasarelas habilitadas del tenant
Note over PA,CO: CheckoutSessionService.configFor recorta la lista a una sola pasarela cuando el intent YA tiene una.<br/>Sin pasarela devuelve la lista completa, que es justo lo que el selector necesita.<br/>Ojo: en modo hub gateway_id viaja null hasta que el comprador elige, aunque el contrato lo declare required
alt más de una pasarela habilitada
CO->>BR: selector de medio de pago
BR->>CO: el comprador elige la pasarela
else exactamente una
Note over CO: mapIntentToUiState va directo a gateway_flow: el selector no llega a aparecer
end
CO->>PA: POST /v1/payment-intents/pi_.../confirm con el gateway_id elegido — PRIMER confirm
PA->>PG: createPayment con la return_url que se persistió al crear el intent
PG-->>PA: referencia de la transacción y URL de su formulario de pago
PA-->>CO: 200 con status REQUIRES_ACTION y redirect_url
CO->>BR: redirección top-level al formulario de la pasarela
BR->>PG: el comprador paga con su tarjeta en el sitio de la pasarela
Note over BR,PG: el PAN y el CVV no atraviesan ningún componente de ApiPay (PCI DSS SAQ A)
PG-->>BR: vuelve a la return_url, que en checkout alojado es el propio checkout: es el único que puede cerrar el cobro
BR->>CO: retorno con el token de la pasarela y nada más
Note over BR,CO: por eso existe hostedSession: el client_secret se recupera de sessionStorage.<br/>Sin él la página de retorno no sabría qué intent cerrar y la pasarela abortaría un cobro ya hecho
CO->>PA: POST /v1/payment-intents/pi_.../confirm con el token — SEGUNDO confirm
PA->>PG: commit del token
PG-->>PA: aprobada o rechazada
PA-->>CO: 200 con el estado final del intent
CO->>BR: página de resultado, con el botón "Volver a la tienda" si se declaró merchant_return_url
BR->>EC: el comprador vuelve a la tienda por merchant_return_url
Note over EC,PA: NO preselecciones gateway_id al crear, y el motivo es contraintuitivo.<br/>resolveConfirmGateway solo deja cambiar de pasarela MIENTRAS el intent no tiene gatewayReference:<br/>si nace atado a una, cualquier fallo obliga a crear otro intent. Sin preseleccionar, un fallo anterior<br/>a la referencia todavía permite elegir otra sobre el MISMO intent
Note over PA,PG: un fallo POSTERIOR a la referencia —tarjeta rechazada en Webpay, que ya creó su transacción—<br/>sí obliga a empezar de nuevo, y eso es correcto: cambiar de pasarela con una transacción PENDING<br/>en la primera arriesga un doble cobroInscripción de un medio de pago (Oneclick)
Inscripción de un medio de pago (Oneclick)
El POST de formulario con TBK_TOKEN que abre el formulario de Transbank, el pm_ que queda inscrito y el cobro posterior sin el titular delante.
Renderizando el diagrama…
Ver la fuente mermaid
sequenceDiagram
autonumber
participant EC as Backend del comercio (sk_test_)
participant BR as Navegador del titular
participant PA as payment-api
participant TB as Transbank Oneclick
EC->>PA: RUTA 1 de 5 · POST /v1/payment-methods/inscriptions con sk_test_ e Idempotency-Key
Note over EC,PA: cuerpo: gateway_id, customer_ref, email y return_url. Aquí NO viaja ningún dato de tarjeta.<br/>La Idempotency-Key es obligatoria: sin ella un reintento abriría otra inscripción y dejaría la anterior huérfana.<br/>Los cuatro SDKs la generan si el comercio no la pasa
PA->>TB: abre la inscripción
TB-->>PA: URL del formulario y token de un solo uso
PA-->>EC: 201 ins_... en REQUIRES_ACTION, con redirect { method, url, field_name, token }
Note over PA,EC: EL ERROR MÁS FÁCIL DE COMETER: redirect NO es una URL para saltar con un GET.<br/>Hay que hacer un POST DE FORMULARIO a url, con un campo llamado field_name cuyo valor es token.<br/>En Transbank ese campo es TBK_TOKEN. Por eso el objeto lleva method y field_name y no solo la URL
EC-->>BR: formulario auto-enviado hacia la pasarela (method POST, action url, input field_name = token)
BR->>TB: POST del formulario
Note over BR,TB: el titular teclea su tarjeta en el sitio de Transbank: el PAN no atraviesa ApiPay (PCI DSS SAQ A)
TB-->>BR: vuelve a la return_url del comercio con el token
BR->>EC: retorno a la return_url
EC->>PA: RUTA 2 de 5 · POST /v1/payment-methods/inscriptions/ins_.../confirm con Idempotency-Key
PA->>TB: cierra la inscripción y recoge la credencial
TB-->>PA: tbk_user y los datos no sensibles de la tarjeta
PA-->>EC: 201 con el medio de pago pm_...
Note over PA,EC: el tbk_user NO se publica: el comercio cobra referenciando el id pm_...,<br/>y no hay ningún campo con el que reconstruir la tarjeta
EC->>PA: cobro · POST /v1/payment-intents con payment_method_id pm_... y SIN gateway_id
Note over EC,PA: la pasarela se DERIVA del medio —una tarjeta inscrita en Oneclick solo se cobra en Oneclick—,<br/>así que mandar además un gateway_id distinto responde 400 en vez de adivinar cuál gana.<br/>En Oneclick la clave reservada installments de metadata fija el número de cuotas
EC->>PA: RUTA 3 de 5 · GET /v1/payment-methods — solo los VIGENTES, paginado por cursor
EC->>PA: RUTA 4 de 5 · GET /v1/payment-methods/pm_... — lee uno, REVOCADOS INCLUIDOS
Note over EC,PA: la asimetría es deliberada: el listado oculta los revocados para no invitar a cobrar con ellos,<br/>pero la lectura por id los devuelve porque los intents históricos los referencian.<br/>El estado se lee en el campo revoked
EC->>PA: RUTA 5 de 5 · DELETE /v1/payment-methods/pm_...
PA->>TB: da de baja la credencial en la pasarela
TB-->>PA: baja confirmada
PA-->>EC: 200 con el recurso ya revocado, no un 204 vacío
Note over PA,EC: la fila se CONSERVA: borrarla perdería el rastro de con qué se cobró.<br/>La operación es idempotente, así que repetirla no es un errorOutbox transaccional
Este diagrama explica por qué el evento que recibes no puede contradecir al estado que consultas. El cambio de estado y la fila de outbox_events se escriben en la misma transacción: o se guardan los dos, o ninguno. No hay dual-write, así que no existe la ventana en la que el pago quedó cobrado pero el evento se perdió. Un relay aparte drena la tabla con FOR UPDATE SKIP LOCKED y publica en Kafka, lo que hace la entrega at-least-once: puedes recibir el mismo evt_ dos veces y por eso tienes que deduplicar. El bloque rojo documenta un defecto real y ya corregido: el relay corría sobre el rol de la aplicación, cuya policy de RLS es fail-closed sin tenant fijado, así que veía cero filas y no publicaba nada sin un solo error en el log. Se dejó dibujado a propósito, porque es el tipo de fallo que solo se ve cuando alguien lo cuenta.
Outbox transaccional
Cómo un cambio de estado y su evento se escriben en la misma transacción y luego viajan a Kafka sin perderse.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart LR
subgraph negocio["Transacción de negocio · rol apipay_app"]
svc["PaymentIntentService.confirm()"]
setlocal["SET LOCAL app.tenant_id = tenant del request"]
upd["UPDATE payment_intents SET status = ..."]
ins["INSERT INTO apipay.outbox_events<br/>published_at NULL"]
commit["COMMIT · o se guardan el estado y el evento,<br/>o no se guarda ninguno de los dos"]
end
tabla[("apipay.outbox_events<br/>id · tenant_id · topic · payload<br/>created_at · published_at")]
subgraph relay["OutboxRelay · rol apipay_worker (apipayWorkerDataSource)"]
sched["drain() @Scheduled"]
txtemplate["TransactionTemplate abre la transacción a mano<br/>(sin @Transactional: el gestor de JPA<br/>no cubre las conexiones del worker)"]
claim["SELECT ... WHERE published_at IS NULL<br/>ORDER BY created_at LIMIT n<br/>FOR UPDATE SKIP LOCKED"]
publish["KafkaTemplate.send()<br/>clave de partición = tenant_id"]
mark["UPDATE published_at en la MISMA transacción<br/>que reclamó el lote"]
end
kafka[["Kafka / Event Hubs<br/>apipay.payment-events.v1<br/>apipay.outbound-webhooks.v1<br/>apipay.audit.v1"]]
dispatcher["webhook-dispatcher<br/>consume y deduplica por evt_"]
bug["Por qué el relay NO puede ir sobre apipay_app:<br/>la policy tenant_isolation evalúa<br/>tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid<br/>Sin tenant fijado la expresión es NULL y NO califica ninguna fila:<br/>0 filas, cero eventos publicados y ni un error en el log.<br/>Bug real detectado y corregido; verificado contra PostgreSQL 17."]
ok["La policy que cruza tenants es platform_access,<br/>y es TO apipay_worker USING (true).<br/>Cruzar tenants es precisamente el cometido del relay."]
svc --> setlocal
setlocal --> upd
upd --> ins
ins --> commit
commit -->|"una sola transacción, sin dual-write"| tabla
sched --> txtemplate
txtemplate --> claim
tabla -->|"lote FIFO reclamado y bloqueado hasta el commit"| claim
claim --> publish
publish --> mark
mark -->|"una fila con published_at no se vuelve a publicar"| tabla
publish --> kafka
kafka -->|"apipay.outbound-webhooks.v1"| dispatcher
tabla -.-> bug
claim -.-> ok
classDef biz fill:#E3F2FD,stroke:#1565C0,color:#1A1A1A
classDef wrk fill:#E0F7FA,stroke:#00838F,color:#1A1A1A
classDef data fill:#E8F5E9,stroke:#2E7D32,color:#1A1A1A
classDef danger fill:#FDECEA,stroke:#C62828,color:#1A1A1A
classDef nota fill:#FFFDE7,stroke:#F9A825,color:#1A1A1A
class svc,setlocal,upd,ins,commit biz
class sched,txtemplate,claim,publish,mark wrk
class tabla,kafka,dispatcher data
class bug danger
class ok notaAislamiento por tenant con RLS
El aislamiento entre comercios no descansa en que las consultas estén bien escritas: descansa en Row-Level Security de PostgreSQL. Cada transacción de negocio fija app.tenant_id, y la policy tenant_isolation compara ese valor contra la fila. Lo importante es el comportamiento cuando falta el contexto: la expresión evalúa a NULL y no califica ninguna fila, es decir cero resultados en lugar de todos. Fail-closed, y sin lanzar error. Ningún rol de runtime tiene BYPASSRLS, ni el que usan los workers: el aislamiento no se salta, cambia de dueño mediante una policy distinta y explícita. Para ti significa que un bug de query en el hub no puede devolverte datos de otro comercio.
Aislamiento por tenant con RLS
Row Level Security de PostgreSQL con app.tenant_id por transacción: ningún rol de la aplicación tiene BYPASSRLS.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart TB
subgraph roles["Los tres roles de PostgreSQL · ninguno tiene BYPASSRLS"]
app["apipay_app<br/>payment-api y admin-api<br/>SET LOCAL app.tenant_id por transacción"]
worker["apipay_worker<br/>webhook-dispatcher, relay del outbox,<br/>ingesta de webhooks entrantes · sin app.tenant_id"]
migrator["apipay_migrator<br/>solo Flyway · no es rol de runtime"]
end
subgraph policies["Cada tabla con tenant_id: ENABLE + FORCE ROW LEVEL SECURITY"]
pol1["tenant_isolation<br/>FOR ALL TO apipay_app<br/>USING y WITH CHECK:<br/>tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid"]
pol2["platform_access<br/>FOR ALL TO apipay_worker<br/>USING (true) WITH CHECK (true)"]
pol3["migrator_maintenance<br/>FOR ALL TO apipay_migrator<br/>USING (true) WITH CHECK (true)"]
end
force["FORCE ROW LEVEL SECURITY somete también al dueño de las tablas:<br/>de ahí que migrator_maintenance tenga que existir para que Flyway<br/>pueda ejecutar seeds y backfills. No debilita el aislamiento."]
subgraph casos["Dos tablas con policy propia"]
tenants["apipay.tenants<br/>tenant_self FOR SELECT TO apipay_app:<br/>la fila ES el tenant, así que compara id, no tenant_id"]
inbound["apipay.inbound_webhook_events<br/>tenant_read FOR SELECT TO apipay_app:<br/>la escribe el worker antes de conocer el tenant"]
end
subgraph verificado["Comportamiento verificado contra PostgreSQL 17"]
v1["apipay_app SIN app.tenant_id<br/>0 filas · fail-closed"]
v2["apipay_app con app.tenant_id fijado<br/>solo las filas de ese tenant"]
v3["apipay_worker sin tenant<br/>todas las filas, vía platform_access"]
v4["WITH CHECK impide además<br/>insertar o mover una fila a otro tenant"]
end
failclosed["Fail-closed, no fail-open: sin contexto de tenant la expresión es NULL<br/>y la policy no califica ninguna fila. No lanza error: devuelve el conjunto vacío.<br/>Un bug de query no puede cruzar tenants; un servicio que olvida fijar el tenant no ve nada.<br/>Ése es exactamente el mecanismo que rompía el relay del outbox antes de moverlo a apipay_worker."]
app --> pol1
worker --> pol2
migrator --> pol3
policies --> force
policies --> casos
pol1 --> v1
pol1 --> v2
pol2 --> v3
pol1 --> v4
verificado --> failclosed
classDef rol fill:#E3F2FD,stroke:#1565C0,color:#1A1A1A
classDef pol fill:#EDE7F6,stroke:#4527A0,color:#1A1A1A
classDef tab fill:#E8F5E9,stroke:#2E7D32,color:#1A1A1A
classDef check fill:#E0F7FA,stroke:#00838F,color:#1A1A1A
classDef nota fill:#FFFDE7,stroke:#F9A825,color:#1A1A1A
classDef danger fill:#FDECEA,stroke:#C62828,color:#1A1A1A
class app,worker,migrator rol
class pol1,pol2,pol3 pol
class tenants,inbound tab
class v1,v2,v3,v4 check
class force nota
class failclosed dangerEntrega de webhooks salientes
El camino completo de un webhook, desde el evento en Kafka hasta el POST firmado que aterriza en tu endpoint. Tres cosas que tienes que implementar en tu extremo salen de aquí: verificar la firma ApiPay-Signature sobre el cuerpo crudo con tolerancia de 300 segundos, responder 2xx rápido (cualquier cosa que no sea 2xx entra en la escalera de reintentos de 1m, 5m, 30m, 2h y 12h) y deduplicar por el id evt_ del envelope, porque el mismo evento puede llegarte dos veces. Tras los cinco reintentos la entrega pasa a FAILED, queda registrada con su código de respuesta y su latencia por intento, y se puede reintentar a mano desde el backoffice.
Entrega de webhooks salientes
Firma ApiPay-Signature, intento inicial y los cinco reintentos (1m, 5m, 30m, 2h, 12h) antes de marcar FAILED.
Renderizando el diagrama…
Ver la fuente mermaid
sequenceDiagram
autonumber
participant K as Kafka · apipay.outbound-webhooks.v1
participant C as OutboundWebhookConsumer
participant DB as PostgreSQL · outbound_webhook_deliveries
participant RS as RetryScheduler + WebhookSigner
participant EP as webhook_endpoint del comercio
K->>C: envelope { id: evt_..., type, created, livemode, data.object }
C->>DB: ¿existe ya una delivery con event_public_id = evt_... ?
DB-->>C: no existe
C->>DB: INSERT outbound_webhook_deliveries (whd_..., PENDING), una por endpoint suscrito
C->>K: ack del offset en el afterCommit de la transacción
Note over C,K: el outbox entrega at-least-once: tras un rebalanceo el mismo evt_ puede llegar dos veces.<br/>La deduplicación por event_public_id absorbe el reenvío
RS->>DB: reclama las vencidas con SELECT ... FOR UPDATE SKIP LOCKED (lease 60 s)
Note over RS,EP: firma ApiPay-Signature: t=unix,v1=hex hmac-sha256 sobre el texto t, un punto y el cuerpo crudo.<br/>Durante una rotación viajan DOS elementos v1=, sin kid
RS->>EP: POST del envelope firmado · timeout 10 s · User-Agent ApiPay-Webhooks/1.0
alt el endpoint responde 2xx
EP-->>RS: 200 OK
RS->>DB: status DELIVERED
else 5xx, 4xx o timeout
EP-->>RS: 503 o timeout
RS->>DB: status RETRYING, attempt_count + 1, next_attempt_at = ahora + backoff
loop reintentos a 1m, 5m, 30m, 2h y 12h · máximo 5 tras el intento inicial
RS->>EP: el MISMO evt_ y el MISMO cuerpo, con firma nueva sobre el t actual
end
RS->>DB: intentos agotados: status FAILED con failed_at
end
Note over RS,DB: una delivery FAILED queda consultable y se reintenta a mano desde<br/>POST /admin/v1/webhook-deliveries/{id}/retry
Note over RS,EP: del lado del comercio: verificar la firma con tolerancia de 300 s,<br/>responder 2xx rápido y deduplicar por el id evt_ del envelopePolítica de reintentos de los SDKs
La decisión menos obvia de todo el diseño de los SDKs, dibujada para que no haya dudas. Un GET se reintenta ante 429 y 5xx con backoff exponencial y jitter completo. Un POST del que se recibió respuesta —o del que no consta si llegó— no se reintenta nunca, en ningún lenguaje, con ningún estado. Reintentar automáticamente un POST de cobro es exactamente cómo se duplican los cargos, y el SDK no puede saber si el cargo se ejecutó. Lo que hace en su lugar es generar una Idempotency-Key (UUID v4) y exponértela en el resultado: tú la persistes y reintentas con esa misma clave, que es el único reintento seguro. Reintentar con una clave nueva es un cargo nuevo.
Política de reintentos de los SDKs
Por qué un GET se reintenta y un POST del que hubo respuesta no: el reintento del cobro es del comercio, con su Idempotency-Key.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart TB
start(["El SDK va a enviar una petición a api.apipay.io"])
metodo{"¿El método es GET?"}
getfallo{"¿429, 5xx, o error de red<br/>producido ANTES de enviar?"}
getintentos{"¿intentos usados < maxRetries?<br/>por defecto 2, es decir 3 intentos"}
espera["espera = jitter completo sobre<br/>min(500 ms · 2^n, 8 s) · base 500 ms, factor 2, tope 8 s"]
retryafter["Si viene Retry-After y el estado es 429 o 503,<br/>manda Retry-After, con tope de 60 s"]
reintenta["Reintenta la MISMA petición"]
postresp{"¿Se recibió respuesta,<br/>o no consta si el POST llegó?"}
nunca["NO se reintenta NUNCA,<br/>ni con 429 ni con 503 ni con timeout"]
expone["El SDK devuelve el error tipado<br/>y la Idempotency-Key (UUID v4) que generó"]
comercio["El comercio la persiste y reintenta<br/>con la MISMA Idempotency-Key"]
duplicado["Reintentar con una clave nueva = cargo duplicado"]
resultado(["Resultado, o ApiPayError con code, status,<br/>el problem completo y el requestId"])
razon["Reintentar automáticamente un POST de cobro es exactamente<br/>cómo se duplican los cargos. La asimetría es deliberada:<br/>el reintento seguro es del comercio, con la clave que el SDK le entrega."]
plataforma["Sobre los errores de red previos al envío: Python y Java los distinguen<br/>y los reintentan. TypeScript y PHP no pueden (fetch y PSR-18 no separan<br/>pre-envío de post-envío) y ante la duda no reintentan: la mitad estricta<br/>es más conservadora que el contrato, nunca menos segura."]
start --> metodo
metodo -->|"sí · GET, lectura idempotente"| getfallo
metodo -->|"no · POST de create, confirm, cancel o refund"| postresp
getfallo -->|"no · 2xx u otro 4xx"| resultado
getfallo -->|"sí"| getintentos
getintentos -->|"no · agotados"| resultado
getintentos -->|"sí"| espera
espera --> retryafter
retryafter --> reintenta
reintenta --> getfallo
postresp -->|"sí, en cualquiera de los dos casos"| nunca
nunca --> expone
expone --> resultado
expone --> comercio
comercio -.->|"nunca con una clave distinta"| duplicado
nunca -.-> razon
getfallo -.-> plataforma
classDef flujo fill:#E3F2FD,stroke:#1565C0,color:#1A1A1A
classDef decision fill:#FFF3E0,stroke:#EF6C00,color:#1A1A1A
classDef danger fill:#FDECEA,stroke:#C62828,color:#1A1A1A
classDef nota fill:#FFFDE7,stroke:#F9A825,color:#1A1A1A
classDef term fill:#E8F5E9,stroke:#2E7D32,color:#1A1A1A
class espera,retryafter,reintenta,expone,comercio flujo
class metodo,getfallo,getintentos,postresp decision
class nunca,duplicado,razon danger
class plataforma nota
class start,resultado termCómo se construye y se despliega
El pipeline, la promoción entre entornos, las migraciones sin downtime y la topología en Azure. Útil si operas o contribuyes al hub, no si solo lo integras.
Pipeline de CI
Pipeline de CI
El grafo de jobs de ci.yml: qué valida un PR, qué se publica solo en main y por qué el gate de cobertura vive fuera del camino del merge.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart TB
subgraph disparo["Disparo"]
pr["pull request hacia main"]
push["push a main"]
end
changes["changes<br/>dorny/paths-filter<br/>backend · frontend · infra · sdks · docs"]
subgraph validacion["Validacion · corre en PR y en main"]
backend["backend<br/>spotlessCheck · test (494) · integrationTest (40)<br/>JDK 25 · backend/gradlew -p backend"]
frontend["frontend<br/>build · typecheck · lint · test (377)<br/>Turborepo · pnpm 10.11.0"]
contracts["contracts<br/>Spectral · oasdiff (solo en PR)<br/>pactos de consumidor (9)"]
sdks["sdks · matriz por lenguaje<br/>TypeScript (92) · Python (165) · Java (28) · PHP"]
docs["docs<br/>next build estatico del portal"]
security["security<br/>gitleaks · Trivy fs · Trivy config"]
end
coverage["coverage<br/>jacocoTestCoverageVerification<br/>continue-on-error: true"]
deuda["Deuda conocida: el gate exige 0.85 de lineas Y de ramas<br/>y hoy falla en 10 de 13 modulos.<br/>Fuera del camino del merge a proposito: un gate<br/>siempre rojo se acaba desactivando.<br/>Registrado en Docs/RETOMAR-AQUI.md seccion 5 bis."]
subgraph publicacion["Publicacion · SOLO en push a main"]
docker["docker · matriz de 6 imagenes<br/>buildx + Trivy antes de publicar<br/>tag informativo sha-<7>"]
digests["image-digests<br/>valida ^sha256:[0-9a-f]{64}$ por imagen<br/>artifact image-digests.json + 7 outputs"]
end
ciok(["ci-ok<br/>UNICO status check obligatorio<br/>falla con failure o cancelled;<br/>tolera skipped del filtro de paths"])
deploy["deploy.yml<br/>encadenado por workflow_run"]
pr --> changes
push --> changes
changes --> backend
changes --> frontend
changes --> contracts
changes --> sdks
changes --> docs
changes --> security
changes --> coverage
backend --> ciok
frontend --> ciok
contracts --> ciok
sdks --> ciok
docs --> ciok
security --> ciok
backend -.->|"solo en main"| docker
frontend -.->|"solo en main"| docker
docker --> digests
digests --> ciok
ciok -->|"verde en main"| deploy
coverage -.->|"informa, no bloquea"| deuda
classDef gate fill:#E3F2FD,stroke:#1565C0,color:#1A1A1A
classDef nota fill:#FFF8E1,stroke:#F9A825,color:#1A1A1A
classDef pub fill:#E8F5E9,stroke:#2E7D32,color:#1A1A1A
class ciok gate
class deuda nota
class docker,digests pubPromoción de dev a producción
Promoción de dev a producción
Los digests se resuelven una sola vez y los tres entornos despliegan los mismos: un push a main durante una aprobación no puede colarse a producción.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart TB
ci(["ci.yml verde en main"])
resolve["resolve<br/>Resuelve el digest de las 7 imagenes UNA SOLA VEZ<br/>desde el ACR, a partir del tag sha-<7><br/>Construye los --set image.digest.<clave>=sha256:..."]
porque["Se resuelven una vez y NO por entorno:<br/>si cada entorno los resolviera por su cuenta, un push a main<br/>durante la ventana de aprobacion de prod haria que prod<br/>desplegara algo que nadie valido en staging."]
subgraph dev["Environment: dev — automatico"]
hdev["helm upgrade --install --atomic<br/>-f values-dev.yaml<br/>1 replica, sin HA, sin canary"]
sdev["smoke: /actuator/health por port-forward<br/>payment-api y admin-api"]
end
subgraph stg["Environment: staging — requiere aprobacion"]
hstg["helm upgrade --install --atomic<br/>-f values-staging.yaml"]
sstg["smoke"]
zap["OWASP ZAP baseline<br/>reglas en .zap/rules.tsv"]
end
subgraph prod["Environment: prod — requiere aprobacion"]
hprod["helm upgrade --install --atomic<br/>-f values-prod.yaml<br/>replicas reales + PDB"]
sprod["smoke"]
end
flyway["Job flyway-migrate<br/>hook pre-upgrade del chart<br/>corre ANTES del rollout y una sola vez"]
expand["Orden expand/contract: primero se anade lo compatible,<br/>se despliega el codigo, y solo despues se retira lo viejo.<br/>Es lo que permite desplegar sin downtime."]
rollback["--atomic: si el despliegue no converge,<br/>Helm revierte a la revision anterior solo"]
ci -->|workflow_run| resolve
resolve --> hdev
resolve -.-> porque
flyway -.->|"pre-upgrade"| hdev
flyway -.->|"pre-upgrade"| hstg
flyway -.->|"pre-upgrade"| hprod
flyway -.-> expand
hdev --> sdev
sdev -->|"aprobacion manual"| hstg
hstg --> sstg
sstg --> zap
zap -->|"aprobacion manual"| hprod
hprod --> sprod
hdev -.-> rollback
hstg -.-> rollback
hprod -.-> rollback
classDef nota fill:#FFF8E1,stroke:#F9A825,color:#1A1A1A
classDef seguro fill:#E8F5E9,stroke:#2E7D32,color:#1A1A1A
class porque,expand nota
class rollback,flyway seguroMigraciones sin downtime: expand y contract
Migraciones sin downtime: expand y contract
Por qué lo aditivo va en un despliegue y el DROP en el siguiente, y por qué Flyway es un hook del chart y no un paso del workflow.
Renderizando el diagrama…
Ver la fuente mermaid
sequenceDiagram
autonumber
participant CD as deploy.yml
participant H as Helm
participant J as Job flyway-migrate<br/>(hook pre-upgrade)
participant DB as PostgreSQL 17
participant P as Pods de payment-api
Note over CD,P: Despliegue N — fase EXPAND: solo cambios compatibles hacia atras
CD->>H: helm upgrade --install --atomic
H->>J: ejecuta el hook pre-upgrade
J->>DB: flyway migrate (rol apipay_migrator)
Note right of DB: Solo aditivo: columnas nullable,<br/>tablas e indices nuevos.<br/>El codigo VIEJO sigue funcionando<br/>contra este schema.
DB-->>J: migraciones aplicadas
J-->>H: Job completado
H->>P: rollout de la version nueva
Note right of P: maxUnavailable 0: los pods viejos<br/>siguen atendiendo hasta que los nuevos<br/>estan listos. Durante la ventana conviven<br/>codigo viejo y nuevo, y AMBOS son<br/>compatibles con el schema.
P-->>H: readiness OK
H-->>CD: release desplegado
Note over CD,P: Despliegue N+1 — fase CONTRACT: se retira lo que ya nadie usa
CD->>H: helm upgrade (siguiente release)
H->>J: hook pre-upgrade
J->>DB: flyway migrate: DROP de la columna vieja,<br/>NOT NULL, renombrados
Note right of DB: Seguro AHORA porque ninguna replica<br/>del despliegue anterior sigue viva.<br/>Hacerlo en el paso N habria roto<br/>los pods viejos en pleno rollout.
DB-->>J: aplicadas
J-->>H: OK
H->>P: rollout
P-->>CD: listo
Note over CD,DB: Por que el Job es hook del CHART y no un paso del workflow:<br/>--atomic tiene que poder revertir el release COMPLETO.<br/>Y por que payment-api es el UNICO que migra: dos servicios<br/>corriendo Flyway a la vez se pelean por el lock del schema.Qué renderiza el chart de Helm
Qué renderiza el chart de Helm
Los 42 recursos que salen de values + los digests inyectados, y la guarda que aborta el render si una imagen no viene por digest.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart LR
subgraph entrada["Lo que entra al render"]
values["values.yaml<br/>+ values-<env>.yaml"]
sets["--set image.digest.<clave>=sha256:...<br/>las 7 claves, inyectadas por deploy.yml"]
end
helper["_helpers.tpl · apipay.image<br/>index .Values.image.digest .digestKey<br/>fail si no empieza por sha256:"]
guarda["Esa guarda es el seguro del despliegue por digest:<br/>values.yaml trae 64 ceros como placeholder INVALIDO,<br/>asi que un digest sin resolver ABORTA el render<br/>en vez de desplegar una imagen equivocada."]
subgraph render["42 recursos renderizados (values-prod)"]
dep["6 Deployment<br/>payment-api · admin-api · webhook-dispatcher<br/>backoffice · checkout-widget · docs-portal"]
svc["7 Service"]
job["1 Job · flyway-migrate, hook pre-upgrade"]
ingr["1 Ingress<br/>api · admin · checkout · docs"]
np["9 NetworkPolicy · default-deny + allow explicito"]
pdb["6 PodDisruptionBudget"]
hpa["3 HorizontalPodAutoscaler"]
spc["3 SecretProviderClass · CSI hacia Key Vault"]
cm["2 ConfigMap"]
mon["2 ServiceMonitor + 1 PodMonitor"]
sa["1 ServiceAccount · Workload Identity"]
end
keda["ScaledObject del dispatcher<br/>condicional keda.enabled, apagado en F1"]
sec["securityContext en los seis:<br/>runAsNonRoot · readOnlyRootFilesystem<br/>allowPrivilegeEscalation false · drop ALL<br/>seccompProfile RuntimeDefault"]
nosecret["Ningun secreto vive en el chart:<br/>llegan por CSI Secrets Store desde Key Vault<br/>con la identidad federada del ServiceAccount."]
values --> helper
sets --> helper
helper --> dep
values --> svc
values --> job
values --> ingr
values --> np
values --> pdb
values --> hpa
values --> spc
values --> cm
values --> mon
values --> sa
helper -.-> guarda
hpa -.->|"lo sustituye si se activa"| keda
dep -.-> sec
spc -.-> nosecret
classDef nota fill:#FFF8E1,stroke:#F9A825,color:#1A1A1A
classDef off fill:#ECEFF1,stroke:#78909C,color:#1A1A1A
class guarda,sec,nosecret nota
class keda offTopología en Azure
Topología en Azure
VNet, tres node pools de AKS y todo el plano de datos por private endpoint: el único punto de entrada público es el Ingress.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart TB
users["Internet<br/>e-commerce de tenants · backoffice · compradores"]
gha["GitHub Actions<br/>OIDC federado, sin secretos estaticos"]
subgraph shared["rg-apipay-shared · compartido entre entornos"]
acr[("ACR<br/>apipayacr.azurecr.io")]
dns["Azure DNS<br/>zona apipay.io"]
end
subgraph rg["rg-apipay-prod"]
fd["Front Door + WAF<br/>opcional, recomendado en prod<br/>reglas OWASP CRS"]
subgraph vnet["VNet vnet-apipay 10.20.0.0/16"]
subgraph snaks["snet-aks 10.20.0.0/20"]
subgraph aks["AKS 1.32+ · 3 zonas de disponibilidad"]
npsys["node pool system<br/>taint CriticalAddonsOnly<br/>CoreDNS, CSI, metrics"]
npuser["node pool user<br/>payment-api · admin-api · backoffice<br/>checkout-widget · docs-portal"]
npspot["node pool spot<br/>taint scalesetpriority=spot:NoSchedule<br/>webhook-dispatcher y jobs"]
ing["NGINX Ingress + cert-manager"]
end
end
subgraph sndata["snet-data 10.20.16.0/24"]
pepg["Private Endpoint PostgreSQL"]
end
subgraph snpe["snet-private-endpoints 10.20.17.0/24"]
pekv["PE Key Vault"]
peeh["PE Event Hubs"]
pered["PE Redis"]
peacr["PE ACR"]
end
end
pg[("PostgreSQL 17 Flexible Server<br/>HA zone-redundant · PITR 35 dias<br/>SIN IP publica")]
red[("Azure Cache for Redis 7.4")]
eh[["Event Hubs Premium · endpoint Kafka<br/>payment-events.v1 · outbound-webhooks.v1 · audit.v1"]]
kv["Key Vault<br/>via CSI Secrets Store + Workload Identity"]
mon["Log Analytics · Managed Prometheus · Managed Grafana"]
end
porque["Todo el plano de datos se consume por private endpoint:<br/>ni PostgreSQL, ni Redis, ni Event Hubs, ni Key Vault tienen IP publica.<br/>El UNICO punto de entrada publico es el Ingress."]
spot["El dispatcher va en spot porque su trabajo es reintentable:<br/>consume con dedup por evt_ y el outbox garantiza at-least-once.<br/>Una interrupcion de la VM no pierde ni duplica un webhook."]
users --> fd --> ing
dns -.->|"A / CNAME api, admin, checkout, docs"| fd
gha -->|"push de imagenes"| acr
gha -->|"helm upgrade --atomic"| aks
npuser --> pepg --> pg
npuser --> pered --> red
npuser --> peeh --> eh
npspot --> peeh
npuser --> pekv --> kv
aks -->|"pull con managed identity"| peacr --> acr
aks -.->|"OTel, metricas, logs"| mon
snpe -.-> porque
npspot -.-> spot
classDef nota fill:#FFF8E1,stroke:#F9A825,color:#1A1A1A
class porque,spot notaRelease de los SDKs
Release de los SDKs
Un tag libera exactamente un SDK, y el gate de compatibilidad del contrato bloquea el release antes de publicar en cualquier registro.
Renderizando el diagrama…
Ver la fuente mermaid
flowchart TB
tag(["Tag sdk-v<X.Y.Z>-<lenguaje><br/>Un tag libera EXACTAMENTE un SDK"])
porque["SemVer independiente por SDK: un fix del cliente PHP<br/>no obliga a publicar nada en Python."]
gate["Gate de contrato · ANTES de publicar<br/>Spectral (estilo) + oasdiff (compatibilidad)<br/>Un breaking change BLOQUEA el release"]
suite["Suite completa del SDK del tag"]
node["@apipay/node -> npm<br/>pnpm con lockfile + provenance"]
py["apipay -> PyPI<br/>trusted publishing, sin token"]
java["com.apipay:apipay-java -> Maven Central<br/>backend/gradlew -p sdks/java publishToMavenCentral"]
php["apipay/apipay-php -> Packagist<br/>git subtree split + push del tag al mirror"]
notas["Notas de release<br/>baseline = release anterior DEL MISMO lenguaje"]
pend["Dos pendientes declarados, no disimulados:<br/>1. El plan verifica 'git diff --exit-code -- sdks/' tras regenerar,<br/>pero generate.sh escribe en sdks/generator/out/: ese gate pasaria<br/>SIEMPRE y no verificaria nada. No se implementa un gate falso.<br/>2. El plan usa 'npm ci' para el SDK de TypeScript y no hay<br/>package-lock.json: se usa pnpm con su lockfile."]
tag --> gate
tag -.-> porque
gate --> suite
suite -->|"tag -node"| node
suite -->|"tag -python"| py
suite -->|"tag -java"| java
suite -->|"tag -php"| php
node --> notas
py --> notas
java --> notas
php --> notas
gate -.-> pend
classDef nota fill:#FFF8E1,stroke:#F9A825,color:#1A1A1A
classDef bloqueo fill:#FFEBEE,stroke:#C62828,color:#1A1A1A
class porque,pend nota
class gate bloqueoCómo editar estos diagramas
Los fuentes viven en el monorepo: docs-portal/content/diagramas/<id>.mmd es lo que renderiza esta página, y Docs/diagramas/<id>.drawio es la versión editable, copiada a docs-portal/public/diagramas/ para que el enlace de descarga funcione con la exportación estática. Los dos formatos describen el mismo diagrama y se actualizan en el mismo cambio; Docs/diagramas/README.md explica el procedimiento y qué enseña cada uno.