- Frontend: muestra input 'No. Cuenta Predial' en sección 'Datos del Inmueble' cuando el régimen del emisor es 606 (Arrendamiento), antes de Conceptos - Frontend: incluye cuentaPredial en payload; se resetea al cambiar contribuyente - Backend: pasa property_tax_account a nivel de cada item en Facturapi para facturapi.service.ts y contribuyente-facturapi.service.ts - Build y deploy exitosos
901 lines
30 KiB
TypeScript
901 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 ?? false,
|
|
taxes: item.taxes?.map(t => ({
|
|
type: t.type,
|
|
rate: t.rate,
|
|
factor: t.factor || 'Tasa',
|
|
...(t.withholding ? { withholding: true } : {}),
|
|
})) || [{ type: 'IVA', rate: 0.16 }],
|
|
},
|
|
...((data as any).cuentaPredial ? { property_tax_account: (data as any).cuentaPredial } : {}),
|
|
}));
|
|
}
|
|
|
|
// 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;
|
|
if ((data as any).fechaEmision) invoiceData.date = (data as any).fechaEmision;
|
|
|
|
// 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 };
|
|
}
|