Initial commit - Horux Despachos NL
This commit is contained in:
898
apps/api/src/services/facturapi.service.ts
Normal file
898
apps/api/src/services/facturapi.service.ts
Normal file
@@ -0,0 +1,898 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user