erp-core/docs/04-modelado/especificaciones-tecnicas/backend/mgn-015/ET-MGN-015-003-api-metodos-pago.md

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

  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