# ET-MGN-015-003: API de Métodos de Pago **Módulo:** MGN-015 - Billing y Suscripciones **RF Relacionado:** [RF-MGN-015-003](../../../requerimientos-funcionales/mgn-015/RF-MGN-015-003-metodos-pago.md) **Tipo:** Backend API **Estado:** Especificado **Fecha:** 2025-11-24 ## Descripción Técnica API REST para la gestión de métodos de pago de los tenants. Implementa tokenización de tarjetas mediante Stripe para cumplimiento PCI-DSS. Nunca se almacenan datos sensibles de tarjetas en nuestra base de datos. ## Endpoints ### GET /api/v1/billing/payment-methods Lista los métodos de pago del tenant. **Autorización:** `tenant_owner` **Response 200:** ```json { "success": true, "data": [ { "id": "uuid", "type": "card", "brand": "visa", "last_four": "4242", "expires_month": 12, "expires_year": 2025, "is_default": true, "is_expired": false, "created_at": "2024-01-01T00:00:00Z" }, { "id": "uuid", "type": "card", "brand": "mastercard", "last_four": "8888", "expires_month": 6, "expires_year": 2024, "is_default": false, "is_expired": true, "created_at": "2023-06-15T00:00:00Z" } ] } ``` ### POST /api/v1/billing/payment-methods/setup-intent Crea un SetupIntent de Stripe para agregar nueva tarjeta. **Autorización:** `tenant_owner` **Response 200:** ```json { "success": true, "data": { "client_secret": "seti_xxx_secret_yyy", "publishable_key": "pk_live_xxx" } } ``` ### POST /api/v1/billing/payment-methods Confirma y guarda un método de pago después del setup en frontend. **Autorización:** `tenant_owner` **Request Body:** ```json { "payment_method_id": "pm_xxx", "set_as_default": true } ``` **Response 201:** ```json { "success": true, "data": { "id": "uuid", "type": "card", "brand": "visa", "last_four": "4242", "expires_month": 12, "expires_year": 2025, "is_default": true }, "message": "Método de pago agregado exitosamente" } ``` **Response 400:** ```json { "success": false, "error": "La tarjeta fue rechazada", "details": { "decline_code": "insufficient_funds" } } ``` ### PATCH /api/v1/billing/payment-methods/:id/default Establece un método de pago como principal. **Autorización:** `tenant_owner` **Response 200:** ```json { "success": true, "data": { "id": "uuid", "is_default": true }, "message": "Método de pago establecido como principal" } ``` ### DELETE /api/v1/billing/payment-methods/:id Elimina un método de pago. **Autorización:** `tenant_owner` **Response 200:** ```json { "success": true, "message": "Método de pago eliminado" } ``` **Response 400:** ```json { "success": false, "error": "No puedes eliminar el único método de pago con una suscripción activa" } ``` ## Integración con Stripe ### Configuración ```typescript // config/stripe.ts import Stripe from 'stripe'; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16', typescript: true }); ``` ### Service Implementation ```typescript // payment-methods.service.ts import { stripe } from '../../config/stripe.js'; export class PaymentMethodsService { async findAll(tenantId: string) { return db.query(` SELECT id, type, brand, last_four, EXTRACT(MONTH FROM expires_at)::int as expires_month, EXTRACT(YEAR FROM expires_at)::int as expires_year, is_default, expires_at < NOW() as is_expired, created_at FROM billing.payment_methods WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY is_default DESC, created_at DESC `, [tenantId]); } async createSetupIntent(tenantId: string) { // Obtener o crear customer en Stripe const tenant = await db.queryOne( 'SELECT stripe_customer_id FROM auth.tenants WHERE id = $1', [tenantId] ); let customerId = tenant.stripe_customer_id; if (!customerId) { const tenantInfo = await db.queryOne( 'SELECT name FROM auth.tenants WHERE id = $1', [tenantId] ); const customer = await stripe.customers.create({ name: tenantInfo.name, metadata: { tenant_id: tenantId } }); customerId = customer.id; await db.query( 'UPDATE auth.tenants SET stripe_customer_id = $1 WHERE id = $2', [customerId, tenantId] ); } // Crear SetupIntent const setupIntent = await stripe.setupIntents.create({ customer: customerId, payment_method_types: ['card'], metadata: { tenant_id: tenantId } }); return { client_secret: setupIntent.client_secret, publishable_key: process.env.STRIPE_PUBLISHABLE_KEY }; } async create(tenantId: string, data: CreatePaymentMethodDto) { // Obtener detalles del PaymentMethod de Stripe const pm = await stripe.paymentMethods.retrieve(data.payment_method_id); if (pm.type !== 'card' || !pm.card) { throw new BadRequestError('Solo se soportan tarjetas de crédito/débito'); } // Adjuntar al customer si no está const tenant = await db.queryOne( 'SELECT stripe_customer_id FROM auth.tenants WHERE id = $1', [tenantId] ); if (pm.customer !== tenant.stripe_customer_id) { await stripe.paymentMethods.attach(pm.id, { customer: tenant.stripe_customer_id }); } // Verificar si es el primer método (será default) const existingCount = await db.queryOne( 'SELECT COUNT(*) FROM billing.payment_methods WHERE tenant_id = $1 AND deleted_at IS NULL', [tenantId] ); const isFirst = parseInt(existingCount.count) === 0; const setAsDefault = isFirst || data.set_as_default; // Si es default, quitar default de los demás if (setAsDefault) { await db.query( 'UPDATE billing.payment_methods SET is_default = false WHERE tenant_id = $1', [tenantId] ); } // Guardar en nuestra BD (solo datos no sensibles) const expires = new Date(pm.card.exp_year, pm.card.exp_month - 1); const saved = await db.queryOne(` INSERT INTO billing.payment_methods ( tenant_id, type, provider, provider_token, brand, last_four, expires_at, is_default ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, type, brand, last_four, EXTRACT(MONTH FROM expires_at)::int as expires_month, EXTRACT(YEAR FROM expires_at)::int as expires_year, is_default `, [ tenantId, 'card', 'stripe', pm.id, // Token de Stripe, no datos de tarjeta pm.card.brand, pm.card.last4, expires, setAsDefault ]); return saved; } async setDefault(tenantId: string, id: string) { // Verificar que existe y pertenece al tenant const pm = await db.queryOne(` SELECT id FROM billing.payment_methods WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL `, [id, tenantId]); if (!pm) { throw new NotFoundError('Método de pago no encontrado'); } // Quitar default de todos await db.query( 'UPDATE billing.payment_methods SET is_default = false WHERE tenant_id = $1', [tenantId] ); // Establecer nuevo default return db.queryOne(` UPDATE billing.payment_methods SET is_default = true, updated_at = NOW() WHERE id = $1 RETURNING id, is_default `, [id]); } async delete(tenantId: string, id: string) { const pm = await db.queryOne(` SELECT id, is_default, provider_token FROM billing.payment_methods WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL `, [id, tenantId]); if (!pm) { throw new NotFoundError('Método de pago no encontrado'); } // Verificar que no sea el único con suscripción activa if (pm.is_default) { const subscription = await db.queryOne(` SELECT id FROM billing.subscriptions WHERE tenant_id = $1 AND status IN ('active', 'trialing') `, [tenantId]); const otherMethods = await db.queryOne(` SELECT COUNT(*) FROM billing.payment_methods WHERE tenant_id = $1 AND id != $2 AND deleted_at IS NULL `, [tenantId, id]); if (subscription && parseInt(otherMethods.count) === 0) { throw new BadRequestError( 'No puedes eliminar el único método de pago con una suscripción activa' ); } } // Detach de Stripe try { await stripe.paymentMethods.detach(pm.provider_token); } catch (error) { logger.warn('Failed to detach payment method from Stripe', { error }); } // Soft delete await db.query( 'UPDATE billing.payment_methods SET deleted_at = NOW() WHERE id = $1', [id] ); } // Para uso interno - cobrar a un método de pago async chargePaymentMethod(tenantId: string, options: { amount: number; currency?: string; description: string; invoice_id?: string; payment_method_id?: string; }) { // Obtener método de pago (específico o default) let pm; if (options.payment_method_id) { pm = await db.queryOne(` SELECT provider_token FROM billing.payment_methods WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL `, [options.payment_method_id, tenantId]); } else { pm = await db.queryOne(` SELECT provider_token FROM billing.payment_methods WHERE tenant_id = $1 AND is_default = true AND deleted_at IS NULL `, [tenantId]); } if (!pm) { throw new BadRequestError('No hay método de pago disponible'); } const tenant = await db.queryOne( 'SELECT stripe_customer_id FROM auth.tenants WHERE id = $1', [tenantId] ); // Crear PaymentIntent y confirmar try { const paymentIntent = await stripe.paymentIntents.create({ amount: Math.round(options.amount * 100), // Stripe usa centavos currency: options.currency || 'mxn', customer: tenant.stripe_customer_id, payment_method: pm.provider_token, confirm: true, description: options.description, metadata: { tenant_id: tenantId, invoice_id: options.invoice_id } }); return { success: paymentIntent.status === 'succeeded', transaction_id: paymentIntent.id, status: paymentIntent.status }; } catch (error: any) { return { success: false, error: error.message, decline_code: error.decline_code }; } } } ``` ## Webhook Handler ```typescript // webhooks/stripe.webhook.ts import { stripe } from '../../config/stripe.js'; export async function handleStripeWebhook(req: Request, res: Response) { const sig = req.headers['stripe-signature']; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret); } catch (error) { return res.status(400).send('Invalid signature'); } switch (event.type) { case 'payment_method.updated': await handlePaymentMethodUpdated(event.data.object); break; case 'payment_method.card_automatically_updated': await handleCardAutoUpdated(event.data.object); break; case 'customer.source.expiring': await handleCardExpiring(event.data.object); break; } res.json({ received: true }); } async function handleCardAutoUpdated(pm: Stripe.PaymentMethod) { // Stripe actualizó automáticamente los datos de la tarjeta if (pm.card) { await db.query(` UPDATE billing.payment_methods SET last_four = $1, expires_at = $2, brand = $3, updated_at = NOW() WHERE provider_token = $4 `, [ pm.card.last4, new Date(pm.card.exp_year, pm.card.exp_month - 1), pm.card.brand, pm.id ]); } } async function handleCardExpiring(source: any) { // Notificar al tenant owner const pm = await db.queryOne(` SELECT pm.*, t.id as tenant_id FROM billing.payment_methods pm JOIN auth.tenants t ON pm.tenant_id = t.id WHERE pm.provider_token = $1 `, [source.id]); if (pm) { await notificationService.send({ tenant_id: pm.tenant_id, type: 'payment_method_expiring', data: { last_four: pm.last_four, expires_at: pm.expires_at } }); } } ``` ## Frontend Integration (Ejemplo) ```typescript // En el frontend, usar Stripe.js const stripe = Stripe('pk_live_xxx'); const elements = stripe.elements(); // Crear elemento de tarjeta const cardElement = elements.create('card'); cardElement.mount('#card-element'); // Al submit del formulario async function handleSubmit() { // 1. Obtener SetupIntent del backend const { client_secret } = await api.post('/billing/payment-methods/setup-intent'); // 2. Confirmar setup con Stripe.js const { setupIntent, error } = await stripe.confirmCardSetup(client_secret, { payment_method: { card: cardElement } }); if (error) { showError(error.message); return; } // 3. Guardar en backend await api.post('/billing/payment-methods', { payment_method_id: setupIntent.payment_method, set_as_default: true }); } ``` ## Validaciones Zod ```typescript export const createPaymentMethodSchema = z.object({ payment_method_id: z.string().startsWith('pm_'), set_as_default: z.boolean().default(false) }); ``` ## Notas de Seguridad 1. **Nunca almacenar:** PAN completo, CVV, datos de banda magnética 2. **Solo almacenar:** Token de Stripe, últimos 4 dígitos, fecha vencimiento, marca 3. **PCI-DSS SAQ-A:** El frontend envía datos directo a Stripe, nunca pasan por nuestro servidor 4. **Webhook verification:** Siempre verificar firma de webhooks de Stripe 5. **HTTPS:** Todas las comunicaciones deben ser sobre HTTPS