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 { 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 { 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 { 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 { 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 { 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 { 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 { const client = await getOrgClient(tenantId); const stream = await client.invoices.downloadPdf(facturapiId); return streamToBuffer(stream); } export async function downloadXml(tenantId: string, facturapiId: string): Promise { const client = await getOrgClient(tenantId); const stream = await client.invoices.downloadXml(facturapiId); return streamToBuffer(stream); } export async function downloadZip(tenantId: string, facturapiId: string): Promise { const client = await getOrgClient(tenantId); const stream = await client.invoices.downloadZip(facturapiId); return streamToBuffer(stream); } function streamToBuffer(stream: any): Promise { 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 { 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 { 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 }; }