14 KiB
ET-MGN-015-003: API de Métodos de Pago
Módulo: MGN-015 - Billing y Suscripciones RF Relacionado: RF-MGN-015-003 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:
{
"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:
{
"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:
{
"payment_method_id": "pm_xxx",
"set_as_default": true
}
Response 201:
{
"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:
{
"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:
{
"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:
{
"success": true,
"message": "Método de pago eliminado"
}
Response 400:
{
"success": false,
"error": "No puedes eliminar el único método de pago con una suscripción activa"
}
Integración con Stripe
Configuración
// 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
// 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
// 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)
// 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
export const createPaymentMethodSchema = z.object({
payment_method_id: z.string().startsWith('pm_'),
set_as_default: z.boolean().default(false)
});
Notas de Seguridad
- Nunca almacenar: PAN completo, CVV, datos de banda magnética
- Solo almacenar: Token de Stripe, últimos 4 dígitos, fecha vencimiento, marca
- PCI-DSS SAQ-A: El frontend envía datos directo a Stripe, nunca pasan por nuestro servidor
- Webhook verification: Siempre verificar firma de webhooks de Stripe
- HTTPS: Todas las comunicaciones deben ser sobre HTTPS