Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,351 @@
/**
* 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<string, string> = {
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<string, string> = {
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<boolean> {
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<CustomerData | null> {
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<void> {
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<string, any>;
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);
}
}