Rutas de pagos

Overview

services/api/src/modules/payments/payments.routes.js define el router HTTP del módulo de pagos de la API. Expone lectura de órdenes, creación de checkouts de campaña y de plan, apertura del Billing Portal y cancelación de suscripciones. Todas las rutas usan requireAuth, y el usuario activo siempre sale de req.auth.userId fileciteturn0file9turn0file8.

El archivo conecta tres capas: modelos de persistencia, servicios de negocio y proveedor Stripe. PaymentOrder y Campaign sostienen el estado local, payments.service.js crea las órdenes pendientes, y stripe.provider.js resuelve el checkout y las operaciones de suscripción. El router también usa User para el flujo de planes y env para construir la URL de retorno del portal de facturación fileciteturn0file9turn0file8.

[!IMPORTANT] Este módulo opera sobre el usuario autenticado. No acepta userId arbitrario en el cuerpo de las solicitudes.

How it works

flowchart TB
    Client[Cliente autenticado] --> Auth[requireAuth]
    Auth --> ListRoute[GET payments]
    Auth --> DetailRoute[GET payment id]
    Auth --> CheckoutRoute[POST checkout]
    Auth --> PlanCheckoutRoute[POST plan checkout]
    Auth --> PortalRoute[POST billing portal]
    Auth --> CancelRoute[POST cancel subscription]

    ListRoute --> PaymentOrderModel[PaymentOrder]
    DetailRoute --> PaymentOrderModel
    CheckoutRoute --> CampaignModel[Campaign]
    CheckoutRoute --> CreateCampaignOrder[createPaymentOrder]
    CheckoutRoute --> StripeCheckout[createCheckout]
    CheckoutRoute --> PaymentOrderModel

    PlanCheckoutRoute --> UserModel[User]
    PlanCheckoutRoute --> CreatePlanOrder[createPlanPaymentOrder]
    PlanCheckoutRoute --> GetPlanPriceId[getPlanPriceId]
    PlanCheckoutRoute --> ChangePlan[changeSubscriptionPlan]
    PlanCheckoutRoute --> StripeCheckout
    PlanCheckoutRoute --> PaymentOrderModel

    PortalRoute --> GetOrCreateCustomer[getOrCreateCustomer]
    PortalRoute --> BillingPortal[createBillingPortalSession]

    CancelRoute --> CancelSubscription[cancelSubscriptionAtPeriodEnd]
    CancelRoute --> UserModel

El flujo siempre empieza con autenticación. Las rutas de lectura consultan PaymentOrder filtrando por el usuario actual. Las rutas de checkout validan primero la entidad asociada, crean una orden pendiente y después delegan la sesión de pago a Stripe. En el caso del plan, el código distingue entre cambio de suscripción existente y creación de una nueva sesión de checkout.

[!NOTE] POST /billing-portal construye una URL de retorno con portal_return=1. Ese parámetro permite al frontend detectar el regreso del portal y refrescar la vista.

Key components

{
    "title": "logStripeProviderError",
    "content": "Funci\u00f3n interna que registra errores de Stripe con un mensaje diferenciado entre mala configuraci\u00f3n y error real de API.",
    "type": "function",
    "filePath": "services/api/src/modules/payments/payments.routes.js",
    "badges": [
        "public"
    ]
}
{
    "title": "PLAN_CONFIG",
    "content": "Mapa local de planes oficiales. Define el nombre visible y el importe base de cada plan que usa el flujo de upgrade.",
    "type": "enum",
    "filePath": "services/api/src/modules/payments/payments.routes.js",
    "badges": [
        "read-only"
    ]
}
{
    "title": "requireAuth",
    "content": "Middleware importado que protege todas las rutas y deja disponible `req.auth.userId` para filtrar datos del usuario.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/auth.middleware.js",
    "badges": [
        "public"
    ]
}
{
    "title": "createPaymentOrder",
    "content": "Funci\u00f3n de `payments.service.js` que crea una orden pendiente para el pago de una campa\u00f1a.",
    "type": "function",
    "filePath": "services/api/src/modules/payments/payments.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "createPlanPaymentOrder",
    "content": "Funci\u00f3n de `payments.service.js` que crea una orden pendiente para un cambio de plan.",
    "type": "function",
    "filePath": "services/api/src/modules/payments/payments.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "createCheckout",
    "content": "Funci\u00f3n importada como `stripeCheckout` que crea la sesi\u00f3n de Stripe Checkout y devuelve URL e identificador externo.",
    "type": "function",
    "filePath": "services/api/src/modules/payments/providers/stripe.provider.js",
    "badges": [
        "public"
    ]
}

Data & fields

  • page [badge:optional] number — Página solicitada en GET /api/payments. Si falta, usa 1.
  • limit [badge:optional] number — Tamaño de página en GET /api/payments. El código limita el valor entre 1 y 50.
  • status [badge:optional] string — Filtro opcional en GET /api/payments para acotar por estado de la orden.
  • id [badge:required] string — Identificador de la orden en GET /api/payments/:id. Debe ser un ObjectId válido.
  • campaignId [badge:required] string — Identificador de campaña requerido por POST /checkout.
  • planTarget [badge:required] string — Plan de destino requerido por POST /plan-checkout. El código acepta professional y creator.
  • stripeCustomerId [badge:optional] string — Identificador del cliente Stripe guardado en User. Es obligatorio para abrir el Billing Portal.
  • stripeSubscriptionId [badge:optional] string — Identificador de la suscripción Stripe usada para cambiar o cancelar el plan.
  • subscriptionStatus [badge:optional] string — Estado de suscripción almacenado en User. El flujo lo usa para decidir si existe una suscripción activa.
  • currentPeriodEnd [badge:optional] Date — Fecha de fin del periodo actual, actualizada tras cambio o cancelación de suscripción.
  • cancelAtPeriodEnd [badge:optional] boolean — Marca local que refleja si la suscripción terminará al final del periodo.
  • amount [badge:required] number — Importe de la orden. En campañas proviene de campaign.budget.total; en planes proviene de PLAN_CONFIG.
  • currency [badge:required] string — Moneda de la orden. Si la campaña no define una, usa MXN.
  • provider [badge:required] string — Proveedor de pago usado por la orden. En este archivo se envía stripe.
  • checkoutUrl [badge:optional] string — URL devuelta por Stripe para redirigir al usuario.
  • externalId [badge:optional] string — Identificador externo de la sesión o suscripción retornado por Stripe.

El checkout de campaña lee solo estos subcampos de Campaign:

  • name — Se usa para construir el nombre del producto en Stripe.
  • status — Controla si la campaña puede activarse.
  • budget.total — Define el importe a cobrar.
  • budget.currency — Define la moneda de la orden.

El checkout y la cancelación de plan leen estos campos de User:

  • plan — Plan actual del usuario.
  • stripeCustomerId — Cliente Stripe asociado.
  • stripeSubscriptionId — Suscripción Stripe actual.
  • subscriptionStatus — Estado de la suscripción.
  • currentPeriodEnd — Final del periodo vigente.
  • cancelAtPeriodEnd — Marca de cancelación pendiente.

Behaviors

  • GET / — Lista las órdenes del usuario autenticado, ordenadas por createdAt descendente y con paginación.
  • GET /:id — Devuelve una orden concreta del usuario autenticado si el ObjectId es válido.
  • POST /checkout — Crea una PaymentOrder para una campaña y abre Stripe Checkout.
  • POST /plan-checkout — Inicia el upgrade de plan. Si existe suscripción activa, cambia el precio sobre la misma suscripción.
  • POST /billing-portal — Crea una sesión del Billing Portal para gestionar plan, facturación y cancelación.
  • POST /cancel-subscription — Cancela el plan al final del periodo o baja el plan localmente si no existe una suscripción Stripe real.
  • logStripeProviderError(context, error) — Registra errores de Stripe con un mensaje útil para diagnóstico.
  • PLAN_CONFIG — Centraliza el nombre e importe de los planes disponibles.

API

GET /api/payments

{
    "title": "Listar \u00f3rdenes de pago",
    "description": "Devuelve las \u00f3rdenes de pago del usuario autenticado con paginaci\u00f3n y filtro por estado.",
    "method": "GET",
    "baseUrl": "<ServiceBaseUrl>/api/payments",
    "endpoint": "/",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [
        {
            "key": "page",
            "value": "N\u00famero de p\u00e1gina",
            "required": false
        },
        {
            "key": "limit",
            "value": "Cantidad m\u00e1xima de elementos por p\u00e1gina",
            "required": false
        },
        {
            "key": "status",
            "value": "Estado de la orden",
            "required": false
        }
    ],
    "pathParams": [],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Listado obtenido",
            "body": "{\n  \"success\": true,\n  \"count\": 1,\n  \"total\": 12,\n  \"page\": 1,\n  \"pages\": 12,\n  \"data\": [\n    {\n      \"_id\": \"66a1b2c3d4e5f67890123456\",\n      \"status\": \"pending\",\n      \"provider\": \"stripe\",\n      \"amount\": 299,\n      \"currency\": \"MXN\",\n      \"checkoutUrl\": \"https://checkout.stripe.example/session\",\n      \"campaignId\": {\n        \"name\": \"Campa\u00f1a mayo\",\n        \"status\": \"draft\"\n      },\n      \"createdAt\": \"2026-01-10T12:00:00.000Z\"\n    }\n  ]\n}"
        },
        "500": {
            "description": "Error interno",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudieron obtener los pagos.\"\n}"
        }
    }
}

GET /api/payments/:id

{
    "title": "Obtener orden de pago",
    "description": "Devuelve una orden de pago del usuario autenticado si el identificador es v\u00e1lido y la orden le pertenece.",
    "method": "GET",
    "baseUrl": "<ServiceBaseUrl>/api/payments",
    "endpoint": "/:id",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Identificador de la orden",
            "required": true
        }
    ],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Orden encontrada",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"_id\": \"66a1b2c3d4e5f67890123456\",\n    \"status\": \"paid\",\n    \"provider\": \"stripe\",\n    \"amount\": 299,\n    \"currency\": \"MXN\",\n    \"campaignId\": {\n      \"name\": \"Campa\u00f1a mayo\",\n      \"status\": \"active\",\n      \"objective\": \"visibility\"\n    }\n  }\n}"
        },
        "400": {
            "description": "ID inv\u00e1lido",
            "body": "{\n  \"success\": false,\n  \"message\": \"ID inv\u00e1lido.\"\n}"
        },
        "404": {
            "description": "Orden no encontrada",
            "body": "{\n  \"success\": false,\n  \"message\": \"Orden de pago no encontrada.\"\n}"
        },
        "500": {
            "description": "Error interno",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo obtener la orden.\"\n}"
        }
    }
}

POST /api/payments/checkout

{
    "title": "Crear checkout de campa\u00f1a",
    "description": "Crea una PaymentOrder para una campa\u00f1a del usuario autenticado y devuelve la URL de Stripe Checkout.",
    "method": "POST",
    "baseUrl": "<ServiceBaseUrl>/api/payments",
    "endpoint": "/checkout",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "json",
    "requestBody": "{\n  \"campaignId\": \"66a1b2c3d4e5f67890123456\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "201": {
            "description": "Orden creada",
            "body": "{\n  \"success\": true,\n  \"message\": \"Orden de pago creada.\",\n  \"data\": {\n    \"paymentOrderId\": \"66a1b2c3d4e5f67890123456\",\n    \"checkoutUrl\": \"https://checkout.stripe.example/session\",\n    \"provider\": \"stripe\",\n    \"amount\": 1499,\n    \"currency\": \"MXN\"\n  }\n}"
        },
        "400": {
            "description": "Datos inv\u00e1lidos",
            "body": "{\n  \"success\": false,\n  \"message\": \"campaignId inv\u00e1lido.\"\n}"
        },
        "404": {
            "description": "Campa\u00f1a no encontrada",
            "body": "{\n  \"success\": false,\n  \"message\": \"Campa\u00f1a no encontrada o sin permisos.\"\n}"
        },
        "409": {
            "description": "Estado no permitido",
            "body": "{\n  \"success\": false,\n  \"message\": \"La campa\u00f1a ya est\u00e1 activa.\"\n}"
        },
        "502": {
            "description": "Stripe no disponible",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo conectar con Stripe. Intenta de nuevo.\"\n}"
        },
        "500": {
            "description": "Error interno",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo crear la orden de pago.\"\n}"
        }
    }
}

POST /api/payments/plan-checkout

{
    "title": "Crear checkout de plan",
    "description": "Inicia el upgrade del plan del usuario autenticado. Si existe una suscripci\u00f3n Stripe activa, cambia la suscripci\u00f3n actual.",
    "method": "POST",
    "baseUrl": "<ServiceBaseUrl>/api/payments",
    "endpoint": "/plan-checkout",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "json",
    "requestBody": "{\n  \"planTarget\": \"creator\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Plan actualizado sobre una suscripci\u00f3n existente",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"changed\": true,\n    \"plan\": \"creator\"\n  }\n}"
        },
        "201": {
            "description": "Checkout creado",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"paymentOrderId\": \"66a1b2c3d4e5f67890123456\",\n    \"checkoutUrl\": \"https://checkout.stripe.example/session\",\n    \"provider\": \"stripe\",\n    \"amount\": 1499,\n    \"currency\": \"MXN\"\n  }\n}"
        },
        "400": {
            "description": "Plan inv\u00e1lido o sin Price ID",
            "body": "{\n  \"success\": false,\n  \"message\": \"planTarget inv\u00e1lido. Usa: professional, creator.\"\n}"
        },
        "404": {
            "description": "Usuario no encontrado",
            "body": "{\n  \"success\": false,\n  \"message\": \"Usuario no encontrado.\"\n}"
        },
        "409": {
            "description": "Plan ya activo",
            "body": "{\n  \"success\": false,\n  \"message\": \"Ya tienes este plan activo.\"\n}"
        },
        "502": {
            "description": "Stripe no disponible",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo conectar con Stripe. Intenta de nuevo.\"\n}"
        },
        "500": {
            "description": "Error interno",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo crear la orden de pago.\"\n}"
        }
    }
}

POST /api/payments/billing-portal

{
    "title": "Abrir Billing Portal",
    "description": "Crea una sesi\u00f3n del Billing Portal de Stripe para que el usuario gestione su suscripci\u00f3n y facturaci\u00f3n.",
    "method": "POST",
    "baseUrl": "<ServiceBaseUrl>/api/payments",
    "endpoint": "/billing-portal",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Portal creado",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"url\": \"https://billing.stripe.example/session\"\n  }\n}"
        },
        "400": {
            "description": "Sin cliente Stripe",
            "body": "{\n  \"success\": false,\n  \"message\": \"A\u00fan no tienes un pago registrado en Stripe. Activa un plan para habilitar el portal de facturaci\u00f3n.\"\n}"
        },
        "502": {
            "description": "Stripe no disponible",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo abrir el portal de facturaci\u00f3n. Intenta de nuevo.\"\n}"
        }
    }
}

POST /api/payments/cancel-subscription

{
    "title": "Cancelar suscripci\u00f3n",
    "description": "Cancela el plan pago del usuario autenticado al final del periodo o lo baja localmente si no existe una suscripci\u00f3n Stripe real.",
    "method": "POST",
    "baseUrl": "<ServiceBaseUrl>/api/payments",
    "endpoint": "/cancel-subscription",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Cancelaci\u00f3n procesada",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"immediate\": false,\n    \"currentPeriodEnd\": \"2026-02-01T00:00:00.000Z\"\n  }\n}"
        },
        "404": {
            "description": "Usuario no encontrado",
            "body": "{\n  \"success\": false,\n  \"message\": \"Usuario no encontrado.\"\n}"
        },
        "409": {
            "description": "Ya est\u00e1 en plan Gratis",
            "body": "{\n  \"success\": false,\n  \"message\": \"Ya tienes el plan Gratis.\"\n}"
        },
        "502": {
            "description": "Stripe no disponible",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo cancelar la suscripci\u00f3n en Stripe. Intenta de nuevo.\"\n}"
        },
        "500": {
            "description": "Error interno",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo cancelar el plan.\"\n}"
        }
    }
}

Workflows

  • Checkout de campaña — El usuario autenticado envía campaignId. El router valida propiedad, estado y presupuesto. Luego crea una PaymentOrder, abre Stripe Checkout y persiste externalId y checkoutUrl.
  • Checkout de plan — El usuario envía planTarget. Si ya tiene una suscripción activa, el router actualiza esa misma suscripción. Si no, crea una nueva orden de plan y abre Stripe Checkout.
  • Gestión de suscripción — El usuario abre el Billing Portal con su stripeCustomerId. Desde ahí puede cambiar plan, cancelar o revisar facturas.
  • Cancelación de plan — Si existe una suscripción Stripe activa, la ruta la marca para terminar al final del periodo. Si no existe, baja el plan localmente de forma inmediata.

Gotchas & edge cases

[!WARNING] GET /api/payments/:id rechaza cualquier identificador que no sea un ObjectId válido.

[!WARNING] POST /checkout bloquea campañas active, cancelled o completed, y también falla si la campaña no tiene presupuesto.

[!CAUTION] Si Stripe falla durante un checkout, la orden recién creada se marca como cancelled antes de responder 502.

[!IMPORTANT] POST /plan-checkout usa dos caminos distintos. Si hay suscripción activa, modifica la suscripción existente. Si no, crea un checkout nuevo.

[!NOTE] El límite de paginación nunca supera 50. Si llega un valor fuera de rango, el código vuelve a 10.

[!NOTE] POST /cancel-subscription puede responder con immediate: true cuando el usuario tiene un plan local sin suscripción Stripe asociada.

[!NOTE] PLAN_CONFIG define professional con 299 y creator con 1499. Es la fuente local usada por el flujo de upgrade.

services/api/src/modules/payments/payments.service.js crea las órdenes que este router persiste y luego presenta al proveedor de pago. services/api/src/modules/payments/providers/stripe.provider.js resuelve el checkout, el cliente, el cambio de plan, la cancelación y el Billing Portal. services/api/src/models/PaymentOrder.js, services/api/src/models/Campaign.js y services/api/src/models/User.js aportan el estado que el router consulta y actualiza fileciteturn0file5turn0file8turn0file9.

El archivo también se apoya en services/api/src/modules/auth/auth.middleware.js para fijar la frontera de acceso, y en services/api/src/config/env.js para construir la URL de retorno del portal de facturación. Esa combinación mantiene el flujo de pagos dentro del usuario autenticado y evita operar sobre identidades ajenas fileciteturn0file9turn0file8.