/** * Auto-facturación de pagos de suscripción. * * Cada vez que MercadoPago confirma un pago (webhook `payment.approved`), este * servicio emite automáticamente un CFDI al público en general vía Facturapi, * usando la organización de Horux 360 como emisor. * * Reglas: * - El **primer pago** aprobado de cada tenant NO se factura automáticamente — * el admin lo hace manualmente para verificar/capturar los datos fiscales del * cliente. Los pagos subsecuentes sí van auto a público en general. * - Trials (amount=0) no se facturan. * - Idempotente: si `Payment.facturapiInvoiceId` ya existe, skip. * - Si Facturapi falla (API down, CSD inválido), se logea el error pero NO se * tira el webhook — `facturapiInvoiceId` queda null y el admin puede re-emitir * manualmente después. Esto evita que MP reintente el webhook y que se * dupliquen registros de Payment. * * Emisor: Horux 360 (RFC HTS240708LJA, RESICO PM, régimen 626, sin retenciones). * Receptor: PUBLICO EN GENERAL (XAXX010101000, régimen 616). * Concepto: clave prod/serv 81112502 (Servicios de alojamiento de aplicaciones). */ import { prisma } from '../../config/database.js'; import * as facturapiService from '../facturapi.service.js'; import { GLOBAL_ADMIN_RFC } from '@horux/shared'; import { auditLog } from '../../utils/audit.js'; import { getTenantOwnerEmail } from '../../utils/memberships.js'; // Constantes de facturación — ajustar aquí si cambia la convención const CONCEPT_PRODUCT_KEY = '81112502'; // Servicios de alojamiento de aplicaciones const CONCEPT_UNIT_KEY = 'E48'; // Unidad de servicio const CONCEPT_UNIT_NAME = 'Servicio'; // Fallback público en general — se usa cuando el tenant pagador no tiene // suficientes datos fiscales (sin CSF cargada, sin domicilio, etc.). const FALLBACK_TAX_ID = 'XAXX010101000'; const FALLBACK_LEGAL_NAME = 'PUBLICO EN GENERAL'; const FALLBACK_TAX_SYSTEM = '616'; // Sin obligaciones fiscales const FALLBACK_USE_CFDI = 'S01'; // Sin efectos fiscales // Default cuando facturamos con datos reales del cliente — gastos en general. // Fase 2 hará esto configurable por tenant. const DEFAULT_USE_CFDI = 'G03'; const IVA_RATE = 0.16; // Mapeo MP payment_method → SAT forma_pago. Conservador: por default TEF (03). const FORMA_PAGO_POR_METHOD: Record = { credit_card: '04', // Tarjeta de crédito debit_card: '28', // Tarjeta de débito account_money: '03', // Transferencia (MP wallet) bank_transfer: '03', }; const PLAN_LABELS: Record = { trial: 'Trial', custom: 'Custom', mi_empresa: 'Mi Empresa', mi_empresa_plus: 'Mi Empresa Plus', business_control: 'Business Control', business_cloud: 'Enterprise', }; /** * Cuenta si este tenant ya tuvo un pago aprobado antes del actual. * Si no hay ninguno, es el primer pago → devolvemos true (skip auto-emit). */ async function isFirstApprovedPayment( tenantId: string, excludePaymentId: string, ): Promise { const count = await prisma.payment.count({ where: { tenantId, status: 'approved', id: { not: excludePaymentId }, }, }); return count === 0; } /** * Busca el tenant emisor (Horux 360) con su organización Facturapi configurada. * Si falta, lanza error — el admin global tiene que crear la organización primero. */ async function getEmitterTenant() { const tenant = await prisma.tenant.findUnique({ where: { rfc: GLOBAL_ADMIN_RFC }, select: { id: true, nombre: true, rfc: true, codigoPostal: true, facturapiOrgId: true, }, }); if (!tenant) { throw new Error(`Tenant emisor (RFC ${GLOBAL_ADMIN_RFC}) no existe — corre pnpm bootstrap:admin-global`); } if (!tenant.facturapiOrgId) { throw new Error(`Tenant emisor no tiene organización Facturapi — configúrala en /configuracion`); } if (!tenant.codigoPostal) { throw new Error(`Tenant emisor no tiene código postal — configúralo en /configuracion/domicilio-fiscal`); } return tenant; } /** * Datos fiscales del receptor para la factura. `null` si no hay datos suficientes * (RFC + razón social + CP + régimen) — el caller cae a público en general. */ interface CustomerData { legalName: string; taxId: string; taxSystem: string; email: string; zip: string; } /** * Resuelve los datos fiscales del receptor desde el tenant que paga. * Requiere CSF sincronizada (régimen) + domicilio fiscal (CP). * * Heurística cuando hay múltiples regímenes activos: usa el más antiguo * (primer regímen agregado al tenant). Fase 2 lo hará configurable. * * Retorna `null` si falta cualquier dato requerido — el caller debe caer * a público en general en ese caso. */ async function getCustomerFromTenant(payerTenantId: string): Promise { const tenant = await prisma.tenant.findUnique({ where: { id: payerTenantId }, select: { nombre: true, rfc: true, codigoPostal: true, factPreferencia: true, factRegimenPreferido: true, }, }); if (!tenant) return null; // Si el cliente eligió "público en general" explícitamente, respetar. if (tenant.factPreferencia === 'publico_general') return null; if (!tenant.rfc || !tenant.nombre || !tenant.codigoPostal) return null; // Régimen fiscal: si el tenant configuró uno preferido, usar ese (validar // que sigue activo). Si no, heurística "primer activo por createdAt". let regimenClave: string | null = null; if (tenant.factRegimenPreferido) { const activo = await prisma.tenantRegimenActivo.findFirst({ where: { tenantId: payerTenantId, regimen: { clave: tenant.factRegimenPreferido }, }, include: { regimen: true }, }); if (activo) regimenClave = activo.regimen.clave; } if (!regimenClave) { const regimenActivo = await prisma.tenantRegimenActivo.findFirst({ where: { tenantId: payerTenantId }, include: { regimen: true }, orderBy: { createdAt: 'asc' }, }); if (!regimenActivo) return null; regimenClave = regimenActivo.regimen.clave; } const email = await getTenantOwnerEmail(payerTenantId); return { legalName: tenant.nombre.toUpperCase(), taxId: tenant.rfc.toUpperCase(), taxSystem: regimenClave, email: email || '', zip: tenant.codigoPostal, }; } /** * Construye el payload para Facturapi. Acepta customer real (datos del cliente) * o fallback a público en general si `customer` es null. */ function buildInvoicePayload(params: { amount: number; description: string; // Texto del concepto — varía por kind (subscription vs timbres) emitterCp: string; paymentMethod: string | null; customer: CustomerData | null; usoCfdi: string; // Resuelto por el caller según preferencia del tenant }) { const description = params.description; // Normaliza método de pago MP → código SAT. Default 03 (TEF) si no mapea. const normalizedMethod = params.paymentMethod?.toLowerCase().replace(/^proration-/, '') || ''; const formaPago = FORMA_PAGO_POR_METHOD[normalizedMethod] || '03'; const useCustomerData = params.customer !== null; const customerPayload = useCustomerData ? { legalName: params.customer!.legalName, taxId: params.customer!.taxId, taxSystem: params.customer!.taxSystem, email: params.customer!.email, zip: params.customer!.zip, } : { legalName: FALLBACK_LEGAL_NAME, taxId: FALLBACK_TAX_ID, taxSystem: FALLBACK_TAX_SYSTEM, email: '', zip: params.emitterCp, }; return { customer: customerPayload as any, items: [ { description, productKey: CONCEPT_PRODUCT_KEY, unitKey: CONCEPT_UNIT_KEY, unitName: CONCEPT_UNIT_NAME, quantity: 1, price: params.amount, // Ya incluye IVA taxIncluded: true, // Facturapi desagrega subtotal + IVA 16% taxes: [ { type: 'IVA', rate: IVA_RATE, factor: 'Tasa' }, // RESICO PM → sin retenciones ], }, ], use: params.usoCfdi, paymentForm: formaPago, paymentMethod: 'PUE', currency: 'MXN', } as facturapiService.FacturapiInvoiceData; } /** * Entry point. Se llama desde el webhook de MP cuando un pago se confirma. * Todas las validaciones son fail-soft: loggear y retornar silenciosamente. */ export async function emitInvoiceIfApplicable(paymentId: string): Promise { try { const payment = await prisma.payment.findUnique({ where: { id: paymentId }, include: { subscription: true }, }); if (!payment) { console.warn(`[Invoicing] Payment ${paymentId} no existe`); return; } // Gate 1: ya facturado (idempotencia) if (payment.facturapiInvoiceId) { console.log(`[Invoicing] Payment ${paymentId} ya facturado (${payment.facturapiInvoiceId}), skip`); return; } // Gate 2: status if (payment.status !== 'approved') { console.log(`[Invoicing] Payment ${paymentId} status=${payment.status}, skip (sólo approved se factura)`); return; } // Gate 3: amount const amount = Number(payment.amount); if (!(amount > 0)) { console.log(`[Invoicing] Payment ${paymentId} amount=${amount}, skip (trial o cero)`); return; } // Gate 4: primer pago del tenant → manual if (await isFirstApprovedPayment(payment.tenantId, payment.id)) { console.log(`[Invoicing] Payment ${paymentId} es el PRIMER pago aprobado del tenant ${payment.tenantId}, skip (factura manual)`); return; } // Gate 5: emisor configurado const emitter = await getEmitterTenant(); // Construir payload. El concepto varía por tipo de pago: // - subscription: "Suscripción {plan} {freq} a Horux 360" // - timbres_pack: "{cantidad} timbres adicionales — Horux 360" let description: string; let auditMetadata: Record; if (payment.kind === 'timbres_pack') { // Recupera cantidad del paquete — vinculado 1:1 con Payment const paquete = await prisma.timbrePaquete.findUnique({ where: { paymentId: payment.id }, }); const cantidad = paquete?.cantidad ?? 0; description = `${cantidad.toLocaleString('es-MX')} timbres adicionales — Horux Despachos`; auditMetadata = { cantidad, amount, kind: 'timbres_pack' }; } else { const plan = payment.subscription?.plan || 'trial'; const frequency = payment.subscription?.frequency || 'monthly'; const descFrecuencia = frequency === 'annual' ? 'anual' : 'mensual'; description = `Suscripción ${PLAN_LABELS[plan] || plan} ${descFrecuencia} a Horux Despachos`; auditMetadata = { amount, plan, frequency, kind: 'subscription' }; } // Resuelve customer real si el tenant pagador tiene CSF + domicilio + // preferencia 'mis_datos'. Si no, null → buildInvoicePayload cae a público // en general como fallback seguro. const customer = await getCustomerFromTenant(payment.tenantId); if (!customer) { console.log(`[Invoicing] Tenant ${payment.tenantId} sin datos fiscales completos o preferencia=publico_general. Facturando a Público en General.`); } // Lee uso CFDI preferido del tenant (default G03 ya cargado en BD via default). const tenantPref = await prisma.tenant.findUnique({ where: { id: payment.tenantId }, select: { factUsoCfdi: true }, }); const usoCfdi = customer ? (tenantPref?.factUsoCfdi || DEFAULT_USE_CFDI) : FALLBACK_USE_CFDI; const payload = buildInvoicePayload({ amount, description, emitterCp: emitter.codigoPostal!, paymentMethod: payment.paymentMethod, customer, usoCfdi, }); console.log(`[Invoicing] Emitiendo factura para Payment ${paymentId} (tenant ${payment.tenantId}, $${amount}, receptor=${customer?.taxId || FALLBACK_TAX_ID})`); const invoice = await facturapiService.createInvoice(emitter.id, payload); await prisma.payment.update({ where: { id: payment.id }, data: { facturapiInvoiceId: invoice.id }, }); auditLog({ tenantId: payment.tenantId, action: 'invoice.emitted_auto', entityType: 'Payment', entityId: payment.id, metadata: { facturapiInvoiceId: invoice.id, ...auditMetadata, }, }); console.log(`[Invoicing] Factura ${invoice.id} emitida y vinculada a Payment ${paymentId}`); } catch (error: any) { // Fail-soft: log y retorno silencioso. El admin puede re-emitir manualmente. console.error(`[Invoicing] Error emitiendo factura para Payment ${paymentId}:`, error.message || error); } }