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 fileciteturn0file9turn0file8.
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 fileciteturn0file9turn0file8.
[!IMPORTANT] Este módulo opera sobre el usuario autenticado. No acepta
userIdarbitrario 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-portalconstruye una URL de retorno conportal_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 enGET /api/payments. Si falta, usa1.limit[badge:optional]number— Tamaño de página enGET /api/payments. El código limita el valor entre1y50.status[badge:optional]string— Filtro opcional enGET /api/paymentspara acotar por estado de la orden.id[badge:required]string— Identificador de la orden enGET /api/payments/:id. Debe ser unObjectIdválido.campaignId[badge:required]string— Identificador de campaña requerido porPOST /checkout.planTarget[badge:required]string— Plan de destino requerido porPOST /plan-checkout. El código aceptaprofessionalycreator.stripeCustomerId[badge:optional]string— Identificador del cliente Stripe guardado enUser. 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 enUser. 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 decampaign.budget.total; en planes proviene dePLAN_CONFIG.currency[badge:required]string— Moneda de la orden. Si la campaña no define una, usaMXN.provider[badge:required]string— Proveedor de pago usado por la orden. En este archivo se envíastripe.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 porcreatedAtdescendente y con paginación.GET /:id— Devuelve una orden concreta del usuario autenticado si elObjectIdes válido.POST /checkout— Crea unaPaymentOrderpara 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íacampaignId. El router valida propiedad, estado y presupuesto. Luego crea unaPaymentOrder, abre Stripe Checkout y persisteexternalIdycheckoutUrl.Checkout de plan— El usuario envíaplanTarget. 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 sustripeCustomerId. 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/:idrechaza cualquier identificador que no sea unObjectIdválido.
[!WARNING]
POST /checkoutbloquea campañasactive,cancelledocompleted, 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
cancelledantes de responder502.
[!IMPORTANT]
POST /plan-checkoutusa 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 a10.
[!NOTE]
POST /cancel-subscriptionpuede responder conimmediate: truecuando el usuario tiene un plan local sin suscripción Stripe asociada.
[!NOTE]
PLAN_CONFIGdefineprofessionalcon299ycreatorcon1499. Es la fuente local usada por el flujo de upgrade.
Related
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 fileciteturn0file5turn0file8turn0file9.
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 fileciteturn0file9turn0file8.