services/api/src/modules/payments/payments.webhooks.js

Overview

Este archivo define el router de webhooks de pagos para Stripe. Su responsabilidad es recibir eventos firmados, validar su autenticidad, evitar reprocesos duplicados y sincronizar el estado interno de PaymentOrder con el estado real de Stripe.

El router forma parte del módulo de pagos y trabaja junto a services/api/src/modules/payments/providers/stripe.provider.js, services/api/src/modules/payments/payments.service.js, services/api/src/models/PaymentOrder.js y services/api/src/models/StripeWebhookEvent.js. En el arranque de la API, esta ruta se monta antes de express.json() para conservar el body crudo que Stripe necesita para verificar la firma .

[!IMPORTANT] Esta ruta depende del payload crudo y del header stripe-signature. Si el cuerpo ya fue transformado por un parser JSON, la verificación de firma deja de ser válida fileciteturn0file17turn0file3.

How it works

flowchart TD
    Req[Stripe webhook request] --> Router[payments webhooks router]
    Router --> Sig{stripe signature header}
    Sig -->|missing| R400[400 response]
    Sig -->|present| Verify[constructWebhookEvent]
    Verify --> DupCheck[StripeWebhookEvent exists]
    DupCheck -->|already processed| R200Dup[200 duplicate response]
    DupCheck -->|new event| Type{event type}

    Type -->|checkout session completed| Completed[Load PaymentOrder and confirm payment]
    Type -->|checkout session expired| Expired[Mark PaymentOrder cancelled]
    Type -->|customer subscription events| SubEvent[Resolve current subscription]
    Type -->|invoice payment events| Invoice[Reload subscription and sync]

    SubEvent --> Retrieve[retrieveSubscription]
    Retrieve -->|resource missing and deleted| Snapshot[event data object]
    Retrieve --> Sync[syncSubscriptionFromStripe]
    Snapshot --> Sync
    Invoice --> RetrieveSub[retrieveSubscription]
    RetrieveSub --> Sync
    Completed --> ConfirmCampaign[confirmPaymentAndActivateCampaign]
    Completed --> ConfirmPlan[confirmPaymentAndUpgradePlan]
    Expired --> Cancel[PaymentOrder status cancelled]
    Sync --> S200[200 response]
    ConfirmCampaign --> S200
    ConfirmPlan --> S200
    Cancel --> S200

El flujo arranca leyendo stripe-signature. Si falta, el router responde 400. Si existe, llama a constructWebhookEvent(req.body, signature) para validar la firma y construir el evento de Stripe.

Después aplica idempotencia con StripeWebhookEvent.exists({ eventId: event.id }). Si el evento ya se procesó, responde 200 con duplicate: true y no repite efectos. Si es nuevo, decide la rama según event.type.

Flujo para checkout.session.completed

  • Lee session.metadata.paymentOrderId.
  • Si no existe, responde 200 sin continuar.
  • Busca la orden en PaymentOrder.
  • Si el tipo es plan, recupera la suscripción real cuando existe session.subscription.
  • Si no es plan, confirma la orden como campaña.

Flujo para checkout.session.expired

  • Lee paymentOrderId desde session.metadata.
  • Si existe, marca la orden como cancelled.
  • No ejecuta confirmación ni activación.

Flujo para suscripciones

  • Para customer.subscription.created, updated y deleted, el código no usa directamente el snapshot del webhook.
  • Primero llama a retrieveSubscription para leer el estado vivo en Stripe.
  • Después sincroniza ese estado con syncSubscriptionFromStripe.

Flujo para facturas

  • Para invoice.payment_succeeded y invoice.payment_failed, el archivo lee invoice.subscription.
  • Si existe, recupera la suscripción real con retrieveSubscription.
  • Luego reutiliza syncSubscriptionFromStripe para converger al mismo estado interno.

Key components

{
    "title": "isResourceMissingError",
    "content": "Detecta el caso oficial de Stripe en el que un recurso no se puede recuperar. Solo devuelve verdadero cuando el error tiene code resource_missing y statusCode 404.",
    "type": "function",
    "filePath": "services/api/src/modules/payments/payments.webhooks.js",
    "badges": []
}
{
    "title": "resolveCurrentSubscriptionForEvent",
    "content": "Resuelve la Subscription actual para eventos de suscripci\u00f3n. Intenta leer la suscripci\u00f3n viva y solo usa el snapshot del webhook en customer.subscription.deleted cuando Stripe ya no permite recuperar el recurso.",
    "type": "function",
    "filePath": "services/api/src/modules/payments/payments.webhooks.js",
    "badges": []
}

Este archivo no exporta servicios de dominio. Su trabajo es coordinar validación, deduplicación y despacho hacia funciones ya existentes del módulo de pagos.

Data & fields

Los datos que consume este router pertenecen al contrato de webhooks de Stripe. El archivo usa campos concretos del header, del evento y de los objetos anidados que Stripe envía en cada caso.

[
    {
        "name": "stripe-signature",
        "type": "string",
        "required": true,
        "description": "Header firmado que Stripe envía para verificar el webhook."
    },
    {
        "name": "req.body",
        "type": "Buffer",
        "required": true,
        "description": "Cuerpo crudo del webhook. Se pasa a constructWebhookEvent sin parseo previo."
    },
    {
        "name": "event.id",
        "type": "string",
        "required": true,
        "description": "Identificador público del evento. Se usa para idempotencia en StripeWebhookEvent."
    },
    {
        "name": "event.type",
        "type": "string",
        "required": true,
        "description": "Tipo del evento de Stripe. Determina qué rama de procesamiento se ejecuta."
    },
    {
        "name": "event.data.object",
        "type": "object",
        "required": true,
        "description": "Snapshot firmado del objeto asociado al evento."
    },
    {
        "name": "event.data.object.id",
        "type": "string",
        "required": true,
        "description": "Identificador del recurso principal del evento. En suscripciones, es el subscription id."
    },
    {
        "name": "session.metadata.paymentOrderId",
        "type": "string",
        "description": "Identificador interno de la PaymentOrder asociado al checkout."
    },
    {
        "name": "session.subscription",
        "type": "string",
        "description": "ID de la suscripción creada por Stripe cuando el checkout produce una suscripción real."
    },
    {
        "name": "invoice.subscription",
        "type": "string",
        "description": "ID de la suscripción asociada a la factura. Si no existe, el webhook se ignora."
    }
]

Campos de persistencia usados por el router

  • PaymentOrder.status [badge:read-only] string — se consulta y se actualiza indirectamente para detectar órdenes pagadas o canceladas.
  • StripeWebhookEvent.eventId [badge:read-only] string — almacena el identificador del evento ya procesado.
  • StripeWebhookEvent.type [badge:read-only] string — guarda el tipo del evento recibido.
  • PaymentOrder.type [badge:read-only] string — distingue entre órdenes de plan y de campaña.
  • PaymentOrder.campaignId [badge:read-only] string — se usa cuando el evento termina activando una campaña.
  • PaymentOrder.planTarget [badge:read-only] string — se usa cuando el evento termina confirmando un plan.

Behaviors

  • isResourceMissingError(error) — devuelve true solo cuando Stripe marca el error como resource_missing y 404.
  • resolveCurrentSubscriptionForEvent(event) — intenta recuperar la suscripción en vivo y usa el snapshot solo en la cancelación definitiva.
  • router.post("/stripe", ...) — procesa el webhook de Stripe y responde con 200, 400 o 500 según el resultado.

Comportamiento por evento

  • checkout.session.completed

    • Busca paymentOrderId en metadata.
    • Si falta, responde 200 y no hace nada más.
    • Consulta PaymentOrder para decidir si confirma un plan o una campaña.
    • Si existe session.subscription, recupera la suscripción real antes de confirmar el plan.
  • checkout.session.expired

    • Cancela la orden asociada.
    • No llama a la capa de confirmación.
  • customer.subscription.created

    • Recupera la suscripción viva.
    • Sincroniza el estado local.
  • customer.subscription.updated

    • Recupera la suscripción viva.
    • Sincroniza el estado local.
  • customer.subscription.deleted

    • Recupera la suscripción viva.
    • Solo usa el snapshot si Stripe ya no permite leer el recurso.
  • invoice.payment_succeeded

    • Recupera la suscripción asociada a la factura.
    • Sincroniza el estado local.
  • invoice.payment_failed

    • Recupera la suscripción asociada a la factura.
    • Sincroniza el estado local.

API

POST /api/payments/webhooks/stripe

{
    "title": "Stripe webhook",
    "description": "Procesa eventos firmados de Stripe y sincroniza PaymentOrder y suscripciones. Requiere body crudo y el header stripe-signature.",
    "method": "POST",
    "baseUrl": "<ServiceBaseUrl>/api/payments/webhooks",
    "endpoint": "/stripe",
    "headers": [
        {
            "key": "stripe-signature",
            "value": "<firma>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Evento recibido, ignorado por duplicado, o procesado sin error.",
            "body": "{\n  \"received\": true\n}"
        },
        "400": {
            "description": "Falta el header de firma o la firma no es v\u00e1lida.",
            "body": "{\n  \"error\": \"Falta stripe-signature.\"\n}"
        },
        "500": {
            "description": "Fall\u00f3 el procesamiento interno del evento.",
            "body": "{\n  \"error\": \"Error interno al procesar el evento.\"\n}"
        }
    }
}

El endpoint no acepta JSON parseado como contrato propio. El router espera el body crudo que llega desde la capa montada en services/api/src/app.js, donde la ruta de webhooks se registra antes de express.json() .

Gotchas & edge cases

[!WARNING] Si falta stripe-signature, el router responde 400 de inmediato. No intenta procesar el body.

[!WARNING] constructWebhookEvent depende del cuerpo crudo. Si el body ya fue consumido por un parser JSON, la validación puede fallar fileciteturn0file17turn0file3.

[!WARNING] customer.subscription.created y customer.subscription.updated no usan el snapshot firmado como fuente principal. Siempre intentan leer el estado real en Stripe.

[!IMPORTANT] El fallback de resolveCurrentSubscriptionForEvent solo aplica a customer.subscription.deleted cuando Stripe responde resource_missing con 404. No se usa para errores de red, permisos o autenticación.

[!NOTE] El archivo usa StripeWebhookEvent.exists para evitar reprocesar eventos ya confirmados. Si dos requests compiten, el E11000 al crear el registro no se considera error real.

[!NOTE] Cuando checkout.session.completed no trae metadata.paymentOrderId, el router responde 200 y termina sin efectos secundarios.

[!IMPORTANT] Los eventos de factura solo se procesan si invoice.subscription existe. Un cargo único sin suscripción queda fuera de esta lógica.

Este archivo conecta con componentes muy concretos del módulo de pagos. La relación está limitada a los elementos que aparecen en el código o en el contexto verificado.

  • services/api/src/modules/payments/providers/stripe.provider.js — expone constructWebhookEvent y retrieveSubscription.
  • services/api/src/modules/payments/payments.service.js — expone confirmPaymentAndActivateCampaign, confirmPaymentAndUpgradePlan y syncSubscriptionFromStripe.
  • services/api/src/models/PaymentOrder.js — permite leer el tipo de orden y cancelarla en checkout.session.expired.
  • services/api/src/models/StripeWebhookEvent.js — registra la idempotencia por event.id.
  • services/api/src/app.js — monta este router antes de express.json() para mantener el body crudo de Stripe .

[!NOTE] En el contexto del módulo de pagos, este archivo representa la frontera de entrada para confirmaciones externas de Stripe. La confirmación de negocio ocurre después, en payments.service.js y en los modelos persistidos asociados fileciteturn0file3turn0file6.