Initial commit - Horux Despachos NL
This commit is contained in:
351
apps/api/src/services/payment/invoicing.service.ts
Normal file
351
apps/api/src/services/payment/invoicing.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user