Files
HoruxDespachosNuevo/apps/api/src/services/facturapi.service.ts

899 lines
30 KiB
TypeScript

import Facturapi from 'facturapi';
import { env } from '../config/env.js';
import { prisma } from '../config/database.js';
import * as mpService from './payment/mercadopago.service.js';
import { getTenantOwnerEmail } from '../utils/memberships.js';
import { encryptString, decryptToString } from './sat/sat-crypto.service.js';
/**
* Cliente Facturapi con User Key (nivel cuenta, para gestión de organizaciones).
*/
function getUserClient(): Facturapi {
if (!env.FACTURAPI_USER_KEY) {
throw new Error('FACTURAPI_USER_KEY no configurada');
}
return new Facturapi(env.FACTURAPI_USER_KEY);
}
/**
* Genera una Live Secret Key vía PUT idempotente. Devuelve la existente
* si la org ya tiene una; crea nueva si no.
*/
async function generateLiveKey(orgId: string): Promise<string> {
const userKey = env.FACTURAPI_USER_KEY!;
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${userKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!res.ok) {
const errBody = await res.text().catch(() => '');
throw new Error(`Facturapi PUT /apikeys/live falló (${res.status}): ${errBody}`);
}
const key = (await res.text()).replace(/"/g, '').trim();
if (!key.startsWith('sk_live_')) {
throw new Error(`Respuesta inesperada de Facturapi (no es sk_live_*): ${key.slice(0, 10)}...`);
}
return key;
}
/**
* Cliente Facturapi con la Live Secret Key de la organización del tenant.
* Cache cifrada en `Tenant.facturapiOrgKeyEnc/Iv/Tag` (AES-256-GCM con
* derivación FIEL_ENCRYPTION_KEY). Si no hay cache, genera vía PUT y persiste.
*/
async function getOrgClient(tenantId: string): Promise<Facturapi> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: {
facturapiOrgId: true,
facturapiOrgKeyEnc: true,
facturapiOrgKeyIv: true,
facturapiOrgKeyTag: true,
},
});
if (!tenant?.facturapiOrgId) {
throw new Error('Tenant no tiene organización Facturapi configurada');
}
// 1. Reutilizar Live Secret Key cacheada (descifrar de BD).
if (tenant.facturapiOrgKeyEnc && tenant.facturapiOrgKeyIv && tenant.facturapiOrgKeyTag) {
const apiKey = decryptToString(tenant.facturapiOrgKeyEnc, tenant.facturapiOrgKeyIv, tenant.facturapiOrgKeyTag);
return new Facturapi(apiKey);
}
// 2. Generar Live Secret Key vía PUT y persistir cifrada (lazy fallback
// para tenants legacy creados antes del refactor live).
const apiKey = await generateLiveKey(tenant.facturapiOrgId);
const { encrypted, iv, tag } = encryptString(apiKey);
await prisma.tenant.update({
where: { id: tenantId },
data: {
facturapiOrgKeyEnc: encrypted,
facturapiOrgKeyIv: iv,
facturapiOrgKeyTag: tag,
},
});
return new Facturapi(apiKey);
}
// ============================================
// Organizaciones
// ============================================
export async function createOrganization(tenantId: string): Promise<{ orgId: string }> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true, rfc: true, facturapiOrgId: true },
});
if (!tenant) throw new Error('Tenant no encontrado');
if (tenant.facturapiOrgId) throw new Error('Tenant ya tiene organización Facturapi');
const client = getUserClient();
const org = await client.organizations.create({ name: tenant.nombre });
// Eager: generar Live Secret Key inmediatamente y persistirla cifrada
// para que la org quede lista para emitir desde el primer momento sin un
// PUT extra al primer emit.
const apiKey = await generateLiveKey(org.id);
const { encrypted, iv, tag } = encryptString(apiKey);
await prisma.tenant.update({
where: { id: tenantId },
data: {
facturapiOrgId: org.id,
facturapiOrgKeyEnc: encrypted,
facturapiOrgKeyIv: iv,
facturapiOrgKeyTag: tag,
},
});
return { orgId: org.id };
}
export async function getOrganizationStatus(tenantId: string): Promise<{
configured: boolean;
orgId?: string;
legalName?: string;
hasCsd?: boolean;
}> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { facturapiOrgId: true },
});
if (!tenant?.facturapiOrgId) {
return { configured: false };
}
try {
const client = getUserClient();
const org = await client.organizations.retrieve(tenant.facturapiOrgId);
return {
configured: true,
orgId: org.id,
legalName: org.legal?.name || undefined,
hasCsd: !!org.certificate?.has_certificate,
};
} catch {
return { configured: false };
}
}
// ============================================
// CSD (Certificado de Sello Digital)
// ============================================
export async function uploadCsd(
tenantId: string,
cerFile: string, // base64
keyFile: string, // base64
password: string
): Promise<{ success: boolean; message: string }> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { facturapiOrgId: true },
});
if (!tenant?.facturapiOrgId) {
throw new Error('Primero debe crearse la organización en Facturapi');
}
const client = getUserClient();
try {
await client.organizations.uploadCertificate(
tenant.facturapiOrgId,
Buffer.from(cerFile, 'base64'),
Buffer.from(keyFile, 'base64'),
password,
);
return { success: true, message: 'CSD subido correctamente' };
} catch (error: any) {
return { success: false, message: error.message || 'Error al subir CSD' };
}
}
// ============================================
// Clientes
// ============================================
export interface FacturapiCustomerData {
legalName: string;
taxId: string; // RFC o Tax ID extranjero
taxSystem?: string; // clave régimen fiscal (no aplica para extranjeros)
email?: string;
zip: string;
country?: string; // ISO 3166 alpha-3 (solo extranjeros, ej: USA, SWE)
}
export async function createOrUpdateCustomer(
tenantId: string,
data: FacturapiCustomerData
): Promise<string> {
const client = await getOrgClient(tenantId);
// Buscar si ya existe por búsqueda de texto
let existingId: string | null = null;
try {
const existing = await client.customers.list({ search: data.taxId });
if (existing.data && existing.data.length > 0) {
const match = existing.data.find((c: any) => c.tax_id === data.taxId);
if (match) existingId = match.id;
}
} catch { /* no existing */ }
const isForiegn = !!data.country && data.country !== 'MEX';
if (existingId) {
const updateData: any = {
legal_name: data.legalName,
email: data.email,
address: { zip: data.zip, ...(isForiegn ? { country: data.country } : {}) },
};
if (!isForiegn && data.taxSystem) updateData.tax_system = data.taxSystem;
await client.customers.update(existingId, updateData);
return existingId;
}
const createData: any = {
legal_name: data.legalName,
email: data.email,
address: { zip: data.zip, ...(isForiegn ? { country: data.country } : {}) },
};
if (isForiegn) {
createData.tax_id = data.taxId; // Tax ID extranjero (NumRegIdTrib)
} else {
createData.tax_id = data.taxId; // RFC mexicano
if (data.taxSystem) createData.tax_system = data.taxSystem;
}
const customer = await client.customers.create(createData);
return customer.id;
}
// ============================================
// Facturas (Emisión)
// ============================================
export interface FacturapiLineItem {
description: string;
productKey: string; // ClaveProdServ SAT
unitKey?: string; // ClaveUnidad SAT
unitName?: string;
quantity: number;
price: number;
taxIncluded?: boolean;
taxes?: Array<{
type: string; // 'IVA', 'ISR', 'IEPS'
rate: number; // 0.16, 0.10, etc.
factor?: string; // 'Tasa', 'Cuota', 'Exento'
withholding?: boolean; // true = retención, false/undefined = traslado
}>;
}
export interface FacturapiInvoiceData {
// Receptor
customer: FacturapiCustomerData;
// Conceptos
items: FacturapiLineItem[];
// Campos CFDI
use: string; // UsoCFDI: G01, G03, etc.
paymentForm: string; // FormaPago: 01, 03, 28, etc.
paymentMethod?: string; // MetodoPago: PUE, PPD
currency?: string; // MXN, USD
exchangeRate?: number;
// Opcionales
series?: string;
folioNumber?: number;
conditions?: string;
/**
* Documentos CFDI relacionados. Estructura SAT 4.0: una entrada por tipo
* de relación, agrupando N UUIDs. Facturapi en modo Live valida la
* estructura estricta — el formato {uuid, relationship} suelto es rechazado.
*/
relatedDocuments?: Array<{ relationship: string; uuids: string[] }>;
/**
* Régimen fiscal del emisor (override del default de la organización).
* Requerido cuando el contribuyente tiene múltiples régimenes y Facturapi
* necesita saber cuál usar para esta factura específica. Se envía como
* `issuer.tax_system` a Facturapi.
*/
issuerTaxSystem?: string;
}
export async function createInvoice(
tenantId: string,
data: FacturapiInvoiceData
): Promise<any> {
const client = await getOrgClient(tenantId);
// Crear/actualizar cliente en Facturapi
const customerId = await createOrUpdateCustomer(tenantId, data.customer);
const tipo = (data as any).type || 'I';
const invoiceData: any = { customer: customerId };
// Tipo de comprobante
if (tipo !== 'I') invoiceData.type = tipo;
// Items (solo para I, E, T — P no lleva conceptos)
if (data.items?.length) {
invoiceData.items = data.items.map(item => ({
quantity: item.quantity,
product: {
description: item.description,
product_key: item.productKey,
unit_key: item.unitKey || 'E48',
unit_name: item.unitName || 'Servicio',
price: item.price,
tax_included: item.taxIncluded ?? true,
taxes: item.taxes?.map(t => ({
type: t.type,
rate: t.rate,
factor: t.factor || 'Tasa',
...(t.withholding ? { withholding: true } : {}),
})) || [{ type: 'IVA', rate: 0.16 }],
},
}));
}
// Campos del comprobante (no aplican para tipo P)
if (tipo === 'I' || tipo === 'E') {
invoiceData.use = data.use || 'G01';
invoiceData.payment_form = data.paymentForm || '99';
invoiceData.payment_method = data.paymentMethod || 'PUE';
invoiceData.currency = data.currency || 'MXN';
if (data.exchangeRate && data.currency !== 'MXN') {
invoiceData.exchange = data.exchangeRate;
}
if (data.conditions) invoiceData.conditions = data.conditions;
}
if (data.series) invoiceData.series = data.series;
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
// Documentos relacionados (Ingreso / Egreso / Pago / Traslado).
if (data.relatedDocuments?.length) {
invoiceData.related_documents = data.relatedDocuments.map(r => ({
relationship: r.relationship,
documents: r.uuids,
}));
}
// Complemento de pago (tipo P)
if ((data as any).complements?.length) {
invoiceData.complements = (data as any).complements;
}
// Factura global
if ((data as any).global) {
invoiceData.global = (data as any).global;
}
// El régimen fiscal del emisor lo toma Facturapi del `legal.tax_system` de
// la organización — NO acepta override per-invoice via campo `issuer` (la
// API rechaza con "issuer is not allowed"). Si se pasa `issuerTaxSystem`,
// debe actualizarse el `legal` de la org ANTES de crear el invoice. Para
// el path tenant-level no lo hacemos (la org comparte régimen único); solo
// el path contribuyente (contribuyente-facturapi.service.ts) implementa
// el sync legal porque cada contribuyente tiene sus propios regímenes.
const invoice = await client.invoices.create(invoiceData);
return invoice;
}
// ============================================
// Cancelación
// ============================================
// ============================================
// Personalización (logo, color)
// ============================================
export async function uploadLogo(tenantId: string, logoBase64: string): Promise<{ success: boolean; message: string }> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { facturapiOrgId: true },
});
if (!tenant?.facturapiOrgId) throw new Error('Organización no configurada');
const userClient = getUserClient();
try {
const buffer = Buffer.from(logoBase64, 'base64');
await userClient.organizations.uploadLogo(tenant.facturapiOrgId, buffer);
return { success: true, message: 'Logo subido correctamente' };
} catch (error: any) {
return { success: false, message: error.message || 'Error al subir logo' };
}
}
export async function updateColor(tenantId: string, color: string): Promise<{ success: boolean; message: string }> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { facturapiOrgId: true },
});
if (!tenant?.facturapiOrgId) throw new Error('Organización no configurada');
const userClient = getUserClient();
try {
await userClient.organizations.updateCustomization(tenant.facturapiOrgId, { color: color.replace('#', '') });
return { success: true, message: 'Color actualizado' };
} catch (error: any) {
return { success: false, message: error.message || 'Error al actualizar color' };
}
}
export async function getCustomization(tenantId: string): Promise<{ logoUrl?: string; color?: string } | null> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { facturapiOrgId: true },
});
if (!tenant?.facturapiOrgId) return null;
const userClient = getUserClient();
try {
const org = await userClient.organizations.retrieve(tenant.facturapiOrgId);
return {
logoUrl: org.customization?.has_logo ? (org.logo_url ?? undefined) : undefined,
color: org.customization?.color || undefined,
};
} catch { return null; }
}
export async function sendInvoiceByEmail(
tenantId: string,
facturapiId: string,
email: string
): Promise<void> {
const client = await getOrgClient(tenantId);
await client.invoices.sendByEmail(facturapiId, { email });
}
export async function cancelInvoice(
tenantId: string,
facturapiId: string,
motive: '01' | '02' | '03' | '04' = '02',
substitution?: string
): Promise<any> {
const client = await getOrgClient(tenantId);
const cancelData: any = { motive };
if (motive === '01' && substitution) {
cancelData.substitution = substitution;
}
return client.invoices.cancel(facturapiId, cancelData);
}
// ============================================
// Descargas
// ============================================
export async function downloadPdf(tenantId: string, facturapiId: string): Promise<Buffer> {
const client = await getOrgClient(tenantId);
const stream = await client.invoices.downloadPdf(facturapiId);
return streamToBuffer(stream);
}
export async function downloadXml(tenantId: string, facturapiId: string): Promise<Buffer> {
const client = await getOrgClient(tenantId);
const stream = await client.invoices.downloadXml(facturapiId);
return streamToBuffer(stream);
}
export async function downloadZip(tenantId: string, facturapiId: string): Promise<Buffer> {
const client = await getOrgClient(tenantId);
const stream = await client.invoices.downloadZip(facturapiId);
return streamToBuffer(stream);
}
function streamToBuffer(stream: any): Promise<Buffer> {
return new Promise((resolve, reject) => {
if (Buffer.isBuffer(stream)) return resolve(stream);
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
// ============================================
// Timbres
// ============================================
export interface TimbreStatus {
configured: boolean;
// Campos flat — backward compat con UI existente que lee `limite/usados/disponibles`
// al top level. Representan SOLO el pool mensual (no suman los paquetes).
tipo?: string;
limite?: number;
usados?: number;
disponibles?: number;
periodoFin?: string;
// Shape nuevo (fase B): separa mensual vs adicionales para la UI detallada
mensual?: {
tipo: string;
limite: number;
usados: number;
disponibles: number;
periodoFin: string;
};
adicionales?: {
total: number; // suma de cantidades originales de paquetes vigentes
usados: number; // suma de usados
disponibles: number; // total - usados
paquetes: Array<{
id: number;
cantidad: number;
usados: number;
disponibles: number;
adquiridoEn: string;
expiraEn: string;
}>;
};
/** suma total disponible (mensual + adicionales vigentes). */
totalDisponibles: number;
}
export async function getTimbreStatus(tenantId: string): Promise<TimbreStatus> {
const now = new Date();
const [suscripcion, paquetes] = await Promise.all([
prisma.timbreSuscripcion.findUnique({ where: { tenantId } }),
prisma.timbrePaquete.findMany({
where: { tenantId, expiraEn: { gt: now } },
orderBy: { expiraEn: 'asc' },
}),
]);
if (!suscripcion && paquetes.length === 0) {
return { configured: false, totalDisponibles: 0 };
}
const mensualVigente = suscripcion && now <= suscripcion.periodoFin;
const mensual = mensualVigente
? {
tipo: suscripcion.tipo,
limite: suscripcion.timbresLimite,
usados: suscripcion.timbresUsados,
disponibles: Math.max(0, suscripcion.timbresLimite - suscripcion.timbresUsados),
periodoFin: suscripcion.periodoFin.toISOString().split('T')[0],
}
: undefined;
const paquetesDetail = paquetes.map(p => ({
id: p.id,
cantidad: p.cantidad,
usados: p.usados,
disponibles: Math.max(0, p.cantidad - p.usados),
adquiridoEn: p.adquiridoEn.toISOString(),
expiraEn: p.expiraEn.toISOString(),
}));
const adicionales = paquetesDetail.length > 0
? {
total: paquetesDetail.reduce((s, p) => s + p.cantidad, 0),
usados: paquetesDetail.reduce((s, p) => s + p.usados, 0),
disponibles: paquetesDetail.reduce((s, p) => s + p.disponibles, 0),
paquetes: paquetesDetail,
}
: undefined;
return {
configured: true,
// backward-compat flat: refleja el mensual si existe, sino deja undefined
tipo: mensual?.tipo,
limite: mensual?.limite,
usados: mensual?.usados,
disponibles: mensual?.disponibles,
periodoFin: mensual?.periodoFin,
// nuevo shape nested
mensual,
adicionales,
totalDisponibles: (mensual?.disponibles || 0) + (adicionales?.disponibles || 0),
};
}
/**
* Consume 1 timbre respetando las reglas del feature:
* 1) Intenta contra TimbreSuscripcion si está en periodo vigente y queda cupo.
* 2) Si mensual agotado/vencido, consume del TimbrePaquete con menor expiraEn
* (FIFO para no desperdiciar los próximos a vencer).
* 3) Si no hay nada disponible, lanza error.
*
* La transacción protege contra race conditions en emisiones concurrentes.
*/
export async function consumeTimbre(tenantId: string): Promise<{ source: 'mensual' | 'paquete'; paqueteId?: number }> {
return await prisma.$transaction(async (tx) => {
const now = new Date();
// 1) Intenta mensual
const suscripcion = await tx.timbreSuscripcion.findUnique({ where: { tenantId } });
if (suscripcion && now <= suscripcion.periodoFin && suscripcion.timbresUsados < suscripcion.timbresLimite) {
await tx.timbreSuscripcion.update({
where: { tenantId },
data: { timbresUsados: { increment: 1 } },
});
return { source: 'mensual' as const };
}
// 2) Fallback a paquetes adicionales FIFO por expiraEn
const paquete = await tx.timbrePaquete.findFirst({
where: {
tenantId,
expiraEn: { gt: now },
usados: { lt: prisma.timbrePaquete.fields.cantidad },
},
orderBy: { expiraEn: 'asc' },
});
if (!paquete) {
// Diferencia los mensajes de error para que el frontend sepa qué ofrecer
if (!suscripcion) {
throw new Error('No hay suscripción de timbres configurada');
}
if (now > suscripcion.periodoFin) {
throw new Error('La suscripción de timbres ha expirado. Compra timbres adicionales o renueva tu plan.');
}
throw new Error('Se agotaron los timbres del plan mensual y no tienes paquetes adicionales. Compra un paquete para continuar.');
}
await tx.timbrePaquete.update({
where: { id: paquete.id },
data: { usados: { increment: 1 } },
});
return { source: 'paquete' as const, paqueteId: paquete.id };
});
}
/**
* Revierte un consumo previo de timbre. Idempotente por fuente:
* - mensual → decrementa timbresUsados (no baja de 0 gracias al guard)
* - paquete → decrementa usados del paquete específico (mismo guard)
*
* Se invoca cuando la emisión en Facturapi falla después de haber consumido
* (SAT nunca selló → el timbre no debe cobrarse).
*/
export async function refundTimbre(
tenantId: string,
consumed: { source: 'mensual' | 'paquete'; paqueteId?: number },
): Promise<void> {
await prisma.$transaction(async (tx) => {
if (consumed.source === 'mensual') {
const sub = await tx.timbreSuscripcion.findUnique({ where: { tenantId } });
if (sub && sub.timbresUsados > 0) {
await tx.timbreSuscripcion.update({
where: { tenantId },
data: { timbresUsados: { decrement: 1 } },
});
}
return;
}
if (consumed.source === 'paquete' && consumed.paqueteId != null) {
const pkg = await tx.timbrePaquete.findUnique({ where: { id: consumed.paqueteId } });
if (pkg && pkg.usados > 0) {
await tx.timbrePaquete.update({
where: { id: consumed.paqueteId },
data: { usados: { decrement: 1 } },
});
}
}
});
}
/**
* Reset mensual de TimbreSuscripcion: para cada tenant cuyo periodoFin ya pasó,
* resetea `timbresUsados=0` y avanza la ventana un mes (tipo='mensual') o un año
* (tipo='anual'). Usado por cron diario. Idempotente: si no hay vencidas, no-op.
*
* Los paquetes adicionales NO se tocan aquí — su vigencia es 1 año fijo desde
* la compra y el filtro `expiraEn > now` los excluye automáticamente cuando
* caducan.
*/
export async function resetExpiredMonthlyTimbres(): Promise<{ reset: number }> {
const now = new Date();
const vencidas = await prisma.timbreSuscripcion.findMany({
where: { periodoFin: { lt: now } },
});
let count = 0;
for (const s of vencidas) {
const nextInicio = new Date(s.periodoFin);
nextInicio.setDate(nextInicio.getDate() + 1);
const nextFin = new Date(nextInicio);
if (s.tipo === 'anual') {
nextFin.setFullYear(nextFin.getFullYear() + 1);
nextFin.setDate(nextFin.getDate() - 1);
} else {
nextFin.setMonth(nextFin.getMonth() + 1);
nextFin.setDate(nextFin.getDate() - 1);
}
await prisma.timbreSuscripcion.update({
where: { id: s.id },
data: {
timbresUsados: 0,
periodoInicio: nextInicio,
periodoFin: nextFin,
},
});
count++;
console.log(`[Timbres] Reset mensual tenant ${s.tenantId}: nuevo periodo ${nextInicio.toISOString().split('T')[0]}${nextFin.toISOString().split('T')[0]}`);
}
return { reset: count };
}
// ============================================
// Paquetes adicionales: catálogo + compra + activación
// ============================================
/** Lista los paquetes activos del catálogo, ordenados por cantidad ASC. */
export async function listPaquetesCatalogo() {
const rows = await prisma.timbrePaqueteCatalogo.findMany({
where: { active: true },
orderBy: { cantidad: 'asc' },
});
return rows.map(r => ({
id: r.id,
cantidad: r.cantidad,
precio: Number(r.precio),
}));
}
/**
* Lista todos los paquetes del catálogo (incluyendo inactivos). Para admin
* global que edita precios — necesita ver los dados de baja también.
*/
export async function listAllPaquetesCatalogo() {
const rows = await prisma.timbrePaqueteCatalogo.findMany({
orderBy: { cantidad: 'asc' },
});
return rows.map(r => ({
id: r.id,
cantidad: r.cantidad,
precio: Number(r.precio),
active: r.active,
updatedAt: r.updatedAt.toISOString(),
}));
}
/**
* Actualiza precio y/o estado activo de un paquete del catálogo. Solo admin
* global. Los cambios NO afectan paquetes ya vendidos (TimbrePaquete guarda
* snapshot del precio al momento de compra).
*/
export async function updatePaqueteCatalogo(params: {
id: number;
precio?: number;
active?: boolean;
}) {
const data: { precio?: number; active?: boolean } = {};
if (params.precio !== undefined) {
if (params.precio <= 0) throw new Error('El precio debe ser mayor a 0');
data.precio = params.precio;
}
if (params.active !== undefined) data.active = params.active;
if (Object.keys(data).length === 0) {
throw new Error('Nada que actualizar');
}
const updated = await prisma.timbrePaqueteCatalogo.update({
where: { id: params.id },
data,
});
return {
id: updated.id,
cantidad: updated.cantidad,
precio: Number(updated.precio),
active: updated.active,
updatedAt: updated.updatedAt.toISOString(),
};
}
/**
* Inicia la compra de un paquete. Crea un Payment con status=pending y una
* MP Preference (checkout one-shot). Retorna la URL a la que redirigir al user.
*
* Flujo post-pago:
* 1. User paga en MP
* 2. MP dispara webhook `payment.approved` con external_reference = `timbres-pack:{paymentId}`
* 3. `applyApprovedTimbrePack` (fase webhook) crea el TimbrePaquete y emite factura
*/
export async function iniciarCompraPaquete(params: {
tenantId: string;
catalogoId: number;
callerEmail: string; // email del user que inicia la compra (caller)
}): Promise<{ paymentId: string; checkoutUrl: string }> {
const paquete = await prisma.timbrePaqueteCatalogo.findUnique({
where: { id: params.catalogoId },
});
if (!paquete || !paquete.active) {
throw new Error('Paquete no disponible');
}
// Email de pago: preferencia al owner del tenant (para continuidad del flujo
// normal de facturación). Si no hay owner activo (caso edge: tenant sin
// membership owner por ahora), caemos al email del caller para no bloquear.
const ownerEmail = await getTenantOwnerEmail(params.tenantId);
const payerEmail = ownerEmail || params.callerEmail;
if (!payerEmail) {
throw new Error('No se pudo determinar un email para el cobro');
}
// Payment pre-creado como pending. El webhook lo aprueba. Guardamos cantidad
// en un campo que podamos recuperar — usamos paymentMethod como placeholder
// para carry-over del cantidad mientras no haya un campo metadata dedicado.
// Mejor: el paqueteId del catálogo es único y persistente, no hace falta
// guardar cantidad aparte.
const payment = await prisma.payment.create({
data: {
tenantId: params.tenantId,
amount: paquete.precio,
status: 'pending',
kind: 'timbres_pack',
paymentMethod: `catalogo:${paquete.id}`, // marker para recuperar cantidad en webhook
},
});
const { preferenceId, checkoutUrl } = await mpService.createTimbrePackPreference({
paymentId: payment.id,
tenantId: params.tenantId,
cantidad: paquete.cantidad,
amount: Number(paquete.precio),
payerEmail,
});
// Guardamos el preferenceId por si lo necesitamos para debugging o cancel
await prisma.payment.update({
where: { id: payment.id },
data: { mpPaymentId: preferenceId }, // temporal hasta que llegue el paymentId real
});
return { paymentId: payment.id, checkoutUrl };
}
/**
* Activa el paquete una vez que MP confirmó el pago. Idempotente: si ya hay un
* TimbrePaquete para este paymentId, no-op.
*
* Llamado desde el webhook cuando external_reference = timbres-pack:{paymentId}
* Y status=approved.
*/
export async function activarPaqueteTrasPago(paymentId: string): Promise<{ created: boolean; paqueteId?: number }> {
const existing = await prisma.timbrePaquete.findUnique({
where: { paymentId },
});
if (existing) {
console.log(`[Timbres] Paquete ya activado para payment ${paymentId} (idempotente)`);
return { created: false, paqueteId: existing.id };
}
const payment = await prisma.payment.findUnique({ where: { id: paymentId } });
if (!payment) throw new Error(`Payment ${paymentId} no encontrado`);
if (payment.kind !== 'timbres_pack') {
throw new Error(`Payment ${paymentId} no es de tipo timbres_pack`);
}
// Recupera cantidad del marker paymentMethod (catalogo:ID)
const match = /^catalogo:(\d+)$/.exec(payment.paymentMethod || '');
if (!match) {
throw new Error(`Payment ${paymentId} sin marker de catálogo válido`);
}
const catalogoId = Number(match[1]);
const catalogo = await prisma.timbrePaqueteCatalogo.findUnique({ where: { id: catalogoId } });
if (!catalogo) {
throw new Error(`Catálogo ${catalogoId} referenciado por Payment ${paymentId} ya no existe`);
}
const adquiridoEn = new Date();
const expiraEn = new Date(adquiridoEn);
expiraEn.setFullYear(expiraEn.getFullYear() + 1);
const paquete = await prisma.timbrePaquete.create({
data: {
tenantId: payment.tenantId,
paymentId: payment.id,
cantidad: catalogo.cantidad,
precio: payment.amount, // precio pagado (historial, snapshot del momento)
adquiridoEn,
expiraEn,
},
});
console.log(`[Timbres] Activado paquete ${paquete.id} (${catalogo.cantidad} timbres) para tenant ${payment.tenantId}, expira ${expiraEn.toISOString().split('T')[0]}`);
return { created: true, paqueteId: paquete.id };
}