Saltar al contenido
ApiPay Hub · Docs

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 ext
Descargar el .drawio editable

Contenedores 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 ext
Descargar el .drawio editable

Arquitectura 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 nota
Descargar el .drawio editable

Flujo 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 webhook
Descargar el .drawio editable

Checkout 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 cobro
Descargar el .drawio editable

Inscripció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 error
Descargar el .drawio editable

Outbox 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 nota
Descargar el .drawio editable

Aislamiento 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 danger
Descargar el .drawio editable

Entrega 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 envelope
Descargar el .drawio editable

Polí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 term
Descargar el .drawio editable

Có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-&lt;7&gt;"]
        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 pub

Promoció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-&lt;7&gt;<br/>Construye los --set image.digest.&lt;clave&gt;=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 seguro

Migraciones 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-&lt;env&gt;.yaml"]
        sets["--set image.digest.&lt;clave&gt;=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 off

Topologí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 nota

Release 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&lt;X.Y.Z&gt;-&lt;lenguaje&gt;<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 bloqueo

Có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.