feat: facturación primer pago, fixes SAT/MP, autocompletado RFCs/conceptos
Backend: - Notificación email al admin cuando llega primer pago aprobado (sin factura auto) - Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global - Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d) - Fix webhook MP: validación defensiva de x-signature header - Fix autocompleto RFCs: eliminado filtro por contribuyenteId - Fix autocompleto conceptos: eliminado filtro por contribuyenteId - SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds - SAT sync request reuse across jobs para evitar agotar cuota diaria - Typo fix MP_ACCESS_TOKEN en .env - Trial invitations system backend Frontend: - Nueva página /admin/facturas-pendientes con tabla y emisión manual - Métrica 'Facturas pendientes' en /clientes (clickable) - Navegación onboarding FIEL/CSD corregida - Sidebar themes sincronizados - Fix SAT portal migration scraper (NetIQ) - Trial invitation acceptance pages
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "subscription_addons_subscription_id_plan_addon_catalogo_id_key";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "trial_invitations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"invited_by" TEXT NOT NULL,
|
||||
"plan" TEXT NOT NULL DEFAULT 'business_control',
|
||||
"duration_days" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"token" TEXT NOT NULL,
|
||||
"email_sent_to" TEXT,
|
||||
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"accepted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "trial_invitations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "trial_invitations_token_key" ON "trial_invitations"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "trial_invitations_tenant_id_idx" ON "trial_invitations"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "trial_invitations_token_idx" ON "trial_invitations"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "trial_invitations_status_idx" ON "trial_invitations"("status");
|
||||
@@ -477,6 +477,29 @@ model TrialUsage {
|
||||
@@map("trial_usages")
|
||||
}
|
||||
|
||||
/// Invitaciones de trial enviadas por admin global a tenants específicos.
|
||||
/// Permite activar trials configurables (ej. Business Control Prueba por 60 días)
|
||||
/// con un link único que el owner del tenant puede aceptar.
|
||||
model TrialInvitation {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
invitedBy String @map("invited_by")
|
||||
plan String @default("business_control")
|
||||
durationDays Int @map("duration_days")
|
||||
status String @default("pending") // pending | accepted | expired | cancelled
|
||||
token String @unique
|
||||
emailSentTo String? @map("email_sent_to")
|
||||
sentAt DateTime @default(now()) @map("sent_at")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
acceptedAt DateTime? @map("accepted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([token])
|
||||
@@index([status])
|
||||
@@map("trial_invitations")
|
||||
}
|
||||
|
||||
/// Catálogo despacho — precios + limits editables por admin global.
|
||||
/// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`)
|
||||
/// porque están acopladas a UI/middleware y son contrato de código.
|
||||
|
||||
@@ -162,8 +162,8 @@ async function main() {
|
||||
{ plan: 'custom', nombre: 'Custom', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
|
||||
{ plan: 'mi_empresa', nombre: 'Mi Empresa', monthly: 580, firstYear: 5800, renewal: 5800, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
|
||||
{ plan: 'mi_empresa_plus', nombre: 'Mi Empresa +', monthly: 900, firstYear: 9000, renewal: 9000, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: true },
|
||||
{ plan: 'business_control', nombre: 'Business Control', monthly: null, firstYear: 25850, renewal: 25850, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true },
|
||||
{ plan: 'business_cloud', nombre: 'Enterprise', monthly: null, firstYear: 43000, renewal: 43000, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true },
|
||||
{ plan: 'business_control', nombre: 'Business Control', monthly: null, firstYear: 25850, renewal: 25850, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'MANAGED' as const, permiteServidorBackup: true, permiteSatIncremental: true },
|
||||
{ plan: 'business_cloud', nombre: 'Enterprise', monthly: null, firstYear: 43000, renewal: 43000, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'MANAGED' as const, permiteServidorBackup: true, permiteSatIncremental: true },
|
||||
];
|
||||
for (const p of DESPACHO_PLAN_CATALOGO) {
|
||||
await prisma.despachoPlanPrice.upsert({
|
||||
|
||||
@@ -38,6 +38,7 @@ import adminDashboardRoutes from './routes/admin-dashboard.routes.js';
|
||||
import adminImpersonateRoutes from './routes/admin-impersonate.routes.js';
|
||||
import adminClientesRoutes from './routes/admin-clientes.routes.js';
|
||||
import adminAddonsRoutes from './routes/admin-addons.routes.js';
|
||||
import { trialInvitationRoutes } from './routes/trial-invitations.routes.js';
|
||||
import despachoAuditRoutes from './routes/despacho-audit.routes.js';
|
||||
import metricasRoutes from './routes/metricas.routes.js';
|
||||
|
||||
@@ -105,6 +106,7 @@ app.use('/api/admin/clientes', adminClientesRoutes);
|
||||
app.use('/api/admin/addons', adminAddonsRoutes);
|
||||
app.use('/api/despacho/audit-log', despachoAuditRoutes);
|
||||
app.use('/api/metricas', metricasRoutes);
|
||||
app.use('/api/invitations/trial', trialInvitationRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorMiddleware);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { prisma } from '../config/database.js';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({ message: 'Solo el administrador global puede consultar el audit log' });
|
||||
}
|
||||
|
||||
@@ -9,10 +9,15 @@ function effectiveTenantId(req: Request): string {
|
||||
const ROLES_OWNER = new Set(['owner', 'cfo']);
|
||||
const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']);
|
||||
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
|
||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||
|
||||
function isPlatformStaff(user: Request['user']): boolean {
|
||||
return (user?.platformRoles || []).some(r => PLATFORM_SUPERSET.has(r));
|
||||
}
|
||||
|
||||
export async function getContribuyentesStats(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!ROLES_OWNER.has(req.user!.role)) {
|
||||
if (!isPlatformStaff(req.user) && !ROLES_OWNER.has(req.user!.role)) {
|
||||
throw new AppError(403, 'Solo owner puede ver estas métricas');
|
||||
}
|
||||
const tenantId = effectiveTenantId(req);
|
||||
@@ -27,7 +32,7 @@ export async function getContribuyentesStats(req: Request, res: Response, next:
|
||||
|
||||
export async function getMisAsignados(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!ROLES_ASIGNADOS.has(req.user!.role)) {
|
||||
if (!isPlatformStaff(req.user) && !ROLES_ASIGNADOS.has(req.user!.role)) {
|
||||
throw new AppError(403, 'No tienes contribuyentes asignados');
|
||||
}
|
||||
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
|
||||
@@ -47,7 +52,7 @@ export async function getMisAsignados(req: Request, res: Response, next: NextFun
|
||||
|
||||
export async function getEquipoStats(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!ROLES_SUPERVISORY.has(req.user!.role)) {
|
||||
if (!isPlatformStaff(req.user) && !ROLES_SUPERVISORY.has(req.user!.role)) {
|
||||
throw new AppError(403, 'Solo owner y supervisor pueden ver al equipo');
|
||||
}
|
||||
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
|
||||
|
||||
@@ -10,10 +10,10 @@ const signupSchema = z.object({
|
||||
regimenFiscal: z.string().optional(),
|
||||
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
|
||||
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
|
||||
plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional().default('trial'),
|
||||
// Solo aplica a mi_empresa y mi_empresa_plus (los otros pagados son
|
||||
// anuales fijos). Default annual sesga el cash-flow del negocio.
|
||||
frequency: z.enum(['monthly', 'annual']).optional().default('annual'),
|
||||
// plan y frequency ya no se escogen en el registro — todos empiezan con trial genérico.
|
||||
// Se mantienen opcionales para compatibilidad backward con clientes antiguos.
|
||||
plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional(),
|
||||
frequency: z.enum(['monthly', 'annual']).optional(),
|
||||
}),
|
||||
owner: z.object({
|
||||
nombre: z.string().min(2, 'Nombre del owner requerido'),
|
||||
@@ -42,16 +42,10 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
||||
// planes, dbMode también es MANAGED y reportar `business_cloud` daba
|
||||
// mapeo equivocado. tenant.plan es la fuente de verdad post-migración
|
||||
// 20260426073942 (que añadió mi_empresa y mi_empresa_plus al enum).
|
||||
let currentPlan: string;
|
||||
if (isTrialActive) {
|
||||
currentPlan = 'trial';
|
||||
} else {
|
||||
currentPlan = String(tenant.plan);
|
||||
}
|
||||
|
||||
// Estado de suscripción activa (si hay) — alimenta la UI con el monto
|
||||
// recurrente actual, fecha de próxima renovación y si el primer pago
|
||||
// (cuando aplica dualidad firstYear) ya fue completado.
|
||||
//
|
||||
// FIX: Si hay una subscription en trial con un plan específico (ej.
|
||||
// business_control desde una TrialInvitation), respetamos ese plan
|
||||
// para que el feature-gate y los límites funcionen correctamente.
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -61,6 +55,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
||||
},
|
||||
});
|
||||
|
||||
let currentPlan: string;
|
||||
if (subscription?.status === 'trial' && subscription.plan && subscription.plan !== 'trial') {
|
||||
currentPlan = subscription.plan;
|
||||
} else if (isTrialActive) {
|
||||
currentPlan = 'trial';
|
||||
} else {
|
||||
currentPlan = String(tenant.plan);
|
||||
}
|
||||
|
||||
// Estado de suscripción activa (si hay) — alimenta la UI con el monto
|
||||
// recurrente actual, fecha de próxima renovación y si el primer pago
|
||||
// (cuando aplica dualidad firstYear) ya fue completado.
|
||||
return res.json({
|
||||
plan: currentPlan,
|
||||
dbMode: tenant.dbMode,
|
||||
|
||||
@@ -518,8 +518,6 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
|
||||
whereType = `AND (c.type = 'EMITIDO' OR (c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'))`;
|
||||
}
|
||||
|
||||
const whereContrib = contribuyenteId ? `AND c.contribuyente_id = '${contribuyenteId}'` : '';
|
||||
|
||||
let whereSearch = '';
|
||||
const params: any[] = [];
|
||||
if (q.length >= 2) {
|
||||
@@ -548,7 +546,6 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
|
||||
JOIN cfdis c ON cc.cfdi_id = c.id
|
||||
WHERE c.status NOT IN ('Cancelado', '0')
|
||||
${whereType}
|
||||
${whereContrib}
|
||||
${whereSearch}
|
||||
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
|
||||
LIMIT 30
|
||||
@@ -664,40 +661,20 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
||||
});
|
||||
const tenantRfc = tenant?.rfc || '';
|
||||
|
||||
// En multi-RFC con contribuyente activo, filtrar a contrapartes con las
|
||||
// que ese contribuyente ha tenido CFDIs (emisor o receptor). Sin
|
||||
// contribuyenteId, retornar el catálogo completo (compat con flujos
|
||||
// legacy / admin global sin contribuyente seleccionado).
|
||||
let rows;
|
||||
if (contribuyenteId) {
|
||||
({ rows } = await pool.query(`
|
||||
SELECT DISTINCT r.id, r.rfc,
|
||||
r.razon_social as "razonSocial",
|
||||
r.regimen_fiscal as "regimenFiscal",
|
||||
r.codigo_postal as "codigoPostal"
|
||||
FROM rfcs r
|
||||
WHERE r.rfc != $1
|
||||
AND (r.rfc ILIKE $2 OR r.razon_social ILIKE $2)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM cfdis c
|
||||
WHERE c.contribuyente_id = $3
|
||||
AND (c.rfc_emisor_id = r.id OR c.rfc_receptor_id = r.id)
|
||||
)
|
||||
ORDER BY r.razon_social
|
||||
LIMIT 10
|
||||
`, [tenantRfc, `%${q}%`, contribuyenteId]));
|
||||
} else {
|
||||
({ rows } = await pool.query(`
|
||||
SELECT id, rfc, razon_social as "razonSocial",
|
||||
regimen_fiscal as "regimenFiscal",
|
||||
codigo_postal as "codigoPostal"
|
||||
FROM rfcs
|
||||
WHERE rfc != $1
|
||||
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
|
||||
ORDER BY razon_social
|
||||
LIMIT 10
|
||||
`, [tenantRfc, `%${q}%`]));
|
||||
}
|
||||
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo
|
||||
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo
|
||||
// contrario no se podría facturar a un cliente nuevo que nunca haya
|
||||
// aparecido en un CFDI previo.
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, rfc, razon_social as "razonSocial",
|
||||
regimen_fiscal as "regimenFiscal",
|
||||
codigo_postal as "codigoPostal"
|
||||
FROM rfcs
|
||||
WHERE rfc != $1
|
||||
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
|
||||
ORDER BY razon_social
|
||||
LIMIT 10
|
||||
`, [tenantRfc, `%${q}%`]);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) { next(error); }
|
||||
@@ -787,3 +764,123 @@ export async function comprarPaquete(req: Request, res: Response, next: NextFunc
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin global: pagos de suscripción sin factura ──
|
||||
|
||||
export async function getPagosSinFactura(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) {
|
||||
return res.status(403).json({ message: 'Solo admin global puede consultar pagos sin factura' });
|
||||
}
|
||||
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: {
|
||||
status: 'approved',
|
||||
facturapiInvoiceId: null,
|
||||
kind: 'subscription',
|
||||
amount: { gt: 0 },
|
||||
},
|
||||
include: {
|
||||
subscription: { select: { plan: true, frequency: true } },
|
||||
tenant: { select: { nombre: true, rfc: true } },
|
||||
},
|
||||
orderBy: { paidAt: 'desc' },
|
||||
});
|
||||
|
||||
res.json(payments);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function emitirFacturaPago(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) {
|
||||
return res.status(403).json({ message: 'Solo admin global puede emitir facturas de pago' });
|
||||
}
|
||||
|
||||
const paymentId = String(req.params.paymentId);
|
||||
const payment = await prisma.payment.findUnique({
|
||||
where: { id: paymentId },
|
||||
include: { subscription: true },
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
return next(new AppError(404, 'Pago no encontrado'));
|
||||
}
|
||||
if (payment.status !== 'approved') {
|
||||
return next(new AppError(400, 'Solo pagos aprobados pueden facturarse'));
|
||||
}
|
||||
if (payment.facturapiInvoiceId) {
|
||||
return next(new AppError(400, 'Este pago ya tiene una factura emitida'));
|
||||
}
|
||||
|
||||
// Reutilizar helpers del servicio de facturación
|
||||
const { getEmitterTenant, getCustomerFromTenant } = await import('../services/payment/invoicing.service.js');
|
||||
|
||||
const emitter = await getEmitterTenant();
|
||||
const amount = Number(payment.amount);
|
||||
const plan = (payment as any).subscription?.plan || 'custom';
|
||||
const frequency = (payment as any).subscription?.frequency || 'monthly';
|
||||
const descFrecuencia = frequency === 'annual' ? 'anual' : 'mensual';
|
||||
const description = `Suscripción ${plan} ${descFrecuencia} a Horux Despachos`;
|
||||
|
||||
const customer = await getCustomerFromTenant(payment.tenantId);
|
||||
if (!customer) {
|
||||
return next(new AppError(400, 'El tenant no tiene datos fiscales completos. No se puede facturar.'));
|
||||
}
|
||||
|
||||
const tenantPref = await prisma.tenant.findUnique({
|
||||
where: { id: payment.tenantId },
|
||||
select: { factUsoCfdi: true },
|
||||
});
|
||||
const usoCfdi = customer ? (tenantPref?.factUsoCfdi || 'G03') : 'S01';
|
||||
|
||||
const formaPagoMap: Record<string, string> = {
|
||||
master: '04', visa: '04', amex: '04',
|
||||
debmaster: '28', debvisa: '28',
|
||||
account_money: '03', bank_transfer: '03',
|
||||
};
|
||||
const normalizedMethod = (payment.paymentMethod || '').toLowerCase().replace(/^proration-/, '');
|
||||
const formaPago = formaPagoMap[normalizedMethod] || '03';
|
||||
|
||||
const payload = {
|
||||
customer: {
|
||||
legalName: customer.legalName,
|
||||
taxId: customer.taxId,
|
||||
taxSystem: customer.taxSystem,
|
||||
email: customer.email,
|
||||
zip: customer.zip,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
description,
|
||||
productKey: '81112502',
|
||||
unitKey: 'E48',
|
||||
unitName: 'Servicio',
|
||||
quantity: 1,
|
||||
price: amount,
|
||||
taxIncluded: true,
|
||||
taxes: [{ type: 'IVA', rate: 0.16, factor: 'Tasa' }],
|
||||
},
|
||||
],
|
||||
use: usoCfdi,
|
||||
paymentForm: formaPago,
|
||||
paymentMethod: 'PUE',
|
||||
currency: 'MXN',
|
||||
};
|
||||
|
||||
const invoice = await facturapiService.createInvoice(emitter.id, payload as any);
|
||||
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: { facturapiInvoiceId: invoice.id },
|
||||
});
|
||||
|
||||
auditFromReq(req, 'invoice.emitted_manual', {
|
||||
entityType: 'Payment',
|
||||
entityId: payment.id,
|
||||
metadata: { facturapiInvoiceId: invoice.id, amount, plan, frequency },
|
||||
});
|
||||
|
||||
res.json({ success: true, invoiceId: invoice.id, paymentId: payment.id });
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export async function retry(req: Request, res: Response): Promise<void> {
|
||||
*/
|
||||
export async function cronInfo(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
|
||||
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
|
||||
return;
|
||||
}
|
||||
@@ -151,7 +151,7 @@ export async function cronInfo(req: Request, res: Response): Promise<void> {
|
||||
*/
|
||||
export async function runCron(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
|
||||
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
import { auditFromReq } from '../utils/audit.js';
|
||||
|
||||
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' });
|
||||
}
|
||||
@@ -19,7 +19,7 @@ async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean>
|
||||
*/
|
||||
async function requireOwnTenantOrGlobalAdmin(req: Request, res: Response, targetTenantId: string): Promise<boolean> {
|
||||
if (targetTenantId === req.user!.tenantId) return true;
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({ message: 'Solo puedes gestionar la suscripción de tu propio tenant' });
|
||||
}
|
||||
|
||||
@@ -6,16 +6,24 @@ import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
import { isOwnerSomewhere } from '../utils/memberships.js';
|
||||
|
||||
async function requireGlobalAdmin(req: Request): Promise<void> {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede gestionar clientes');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireGlobalAdmin(req);
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
if (!isAdmin) {
|
||||
// Evita 403 en consola del frontend cuando componentes sin-gate hacen polling
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const tenants = await tenantsService.getAllTenants();
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
res.json(tenants);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -24,7 +32,10 @@ export async function getAllTenants(req: Request, res: Response, next: NextFunct
|
||||
|
||||
export async function getTenant(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireGlobalAdmin(req);
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
if (!isAdmin) {
|
||||
return res.status(404).json({ message: 'Cliente no encontrado' });
|
||||
}
|
||||
|
||||
const tenant = await tenantsService.getTenantById(String(req.params.id));
|
||||
if (!tenant) {
|
||||
@@ -68,13 +79,15 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
|
||||
await requireGlobalAdmin(req);
|
||||
|
||||
const id = String(req.params.id);
|
||||
const { nombre, rfc, plan, active } = req.body;
|
||||
const { nombre, rfc, plan, active, amount, firstPaymentDueAt } = req.body;
|
||||
|
||||
const tenant = await tenantsService.updateTenant(id, {
|
||||
nombre,
|
||||
rfc,
|
||||
plan,
|
||||
active,
|
||||
amount,
|
||||
firstPaymentDueAt: firstPaymentDueAt || null,
|
||||
});
|
||||
|
||||
res.json(tenant);
|
||||
|
||||
130
apps/api/src/controllers/trial-invitations.controller.ts
Normal file
130
apps/api/src/controllers/trial-invitations.controller.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as trialInvitationService from '../services/trial-invitations.service.js';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({ message: 'Solo el administrador global puede gestionar invitaciones de trial' });
|
||||
}
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
export async function createInvitation(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requireGlobalAdmin(req, res))) return;
|
||||
|
||||
const { tenantId, plan, durationDays } = req.body;
|
||||
if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) {
|
||||
return res.status(400).json({ message: 'tenantId y durationDays (1-365) son requeridos' });
|
||||
}
|
||||
|
||||
const invitation = await trialInvitationService.createInvitation({
|
||||
tenantId,
|
||||
invitedByUserId: req.user!.userId,
|
||||
plan: plan || 'business_control',
|
||||
durationDays: parseInt(durationDays, 10),
|
||||
});
|
||||
|
||||
res.status(201).json(invitation);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('ya tiene') || error.message?.includes('no encontrado')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllInvitations(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requireGlobalAdmin(req, res))) return;
|
||||
|
||||
const { tenantId, status } = req.query;
|
||||
const invitations = await trialInvitationService.getInvitations({
|
||||
tenantId: typeof tenantId === 'string' ? tenantId : undefined,
|
||||
status: typeof status === 'string' ? status : undefined,
|
||||
});
|
||||
|
||||
res.json(invitations);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMyPendingInvitation(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const invitation = await trialInvitationService.getPendingInvitationForTenant(req.user!.tenantId);
|
||||
res.json(invitation);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptInvitation(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const token = typeof req.params.token === 'string' ? req.params.token : '';
|
||||
if (!token) {
|
||||
return res.status(400).json({ message: 'Token requerido' });
|
||||
}
|
||||
|
||||
const result = await trialInvitationService.acceptInvitation(token, req.user!.userId);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error.message?.includes('no encontrada') ||
|
||||
error.message?.includes('ya ') ||
|
||||
error.message?.includes('expirado') ||
|
||||
error.message?.includes('Solo el dueño')
|
||||
) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelInvitation(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requireGlobalAdmin(req, res))) return;
|
||||
|
||||
const id = typeof req.params.id === 'string' ? req.params.id : '';
|
||||
const result = await trialInvitationService.cancelInvitation(id);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('no encontrada') || error.message?.includes('Solo se pueden')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInvitationByToken(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const token = typeof req.params.token === 'string' ? req.params.token : '';
|
||||
const invitation = await prisma.trialInvitation.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
return res.status(404).json({ message: 'Invitación no encontrada' });
|
||||
}
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: invitation.tenantId },
|
||||
select: { nombre: true, rfc: true },
|
||||
});
|
||||
|
||||
// No exponer datos sensibles
|
||||
res.json({
|
||||
id: invitation.id,
|
||||
tenantId: invitation.tenantId,
|
||||
plan: invitation.plan,
|
||||
durationDays: invitation.durationDays,
|
||||
status: invitation.status,
|
||||
expiresAt: invitation.expiresAt,
|
||||
tenant,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const updateGlobalSchema = z.object({
|
||||
});
|
||||
|
||||
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
}
|
||||
|
||||
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
import { hasAnyPlatformRole } from '../utils/platform-admin.js';
|
||||
import { decryptAesGcm, deriveAesKey } from '@horux/core';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
@@ -68,11 +69,12 @@ export async function tenantMiddleware(req: Request, res: Response, next: NextFu
|
||||
|
||||
let tenantId = req.user.tenantId;
|
||||
|
||||
// Admin impersonation via X-View-Tenant header (global admin only)
|
||||
// Admin impersonation via X-View-Tenant header (global admin or platform staff)
|
||||
const viewTenantHeader = req.headers['x-view-tenant'] as string;
|
||||
if (viewTenantHeader) {
|
||||
const globalAdmin = await isGlobalAdmin(req.user.tenantId, req.user.role);
|
||||
if (!globalAdmin) {
|
||||
const isPlatformStaff = await hasAnyPlatformRole(req.user.userId, 'platform_admin', 'platform_ti');
|
||||
const globalAdmin = !isPlatformStaff && await isGlobalAdmin(req.user.tenantId, req.user.role);
|
||||
if (!isPlatformStaff && !globalAdmin) {
|
||||
return res.status(403).json({ message: 'No autorizado para ver otros tenants' });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,8 @@ import * as ctrl from '../controllers/despacho-stats.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
router.get('/contribuyentes-stats', ctrl.getContribuyentesStats);
|
||||
router.get('/mis-asignados', ctrl.getMisAsignados);
|
||||
router.get('/equipo-stats', ctrl.getEquipoStats);
|
||||
router.get('/contribuyentes-stats', authenticate, tenantMiddleware, ctrl.getContribuyentesStats);
|
||||
router.get('/mis-asignados', authenticate, tenantMiddleware, ctrl.getMisAsignados);
|
||||
router.get('/equipo-stats', authenticate, tenantMiddleware, ctrl.getEquipoStats);
|
||||
|
||||
export { router as despachoStatsRoutes };
|
||||
|
||||
@@ -61,4 +61,8 @@ router.get('/cfdis-ppd', facturacionController.getCfdisPpdPendientes);
|
||||
// CFDIs emitidos por el contribuyente al receptor (para sección "CFDIs relacionados")
|
||||
router.get('/cfdis-relacionables', facturacionController.getCfdisRelacionables);
|
||||
|
||||
// Admin global: pagos de suscripción sin factura + emisión manual
|
||||
router.get('/pagos-sin-factura', facturacionController.getPagosSinFactura);
|
||||
router.post('/emitir-factura-pago/:paymentId', strictLimit, facturacionController.emitirFacturaPago);
|
||||
|
||||
export { router as facturacionRoutes };
|
||||
|
||||
22
apps/api/src/routes/trial-invitations.routes.ts
Normal file
22
apps/api/src/routes/trial-invitations.routes.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import * as trialInvitationsController from '../controllers/trial-invitations.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Public endpoint: anyone can view invitation details by token (no auth required)
|
||||
router.get('/token/:token', trialInvitationsController.getInvitationByToken);
|
||||
|
||||
// Authenticated endpoints
|
||||
router.use(authenticate);
|
||||
|
||||
// Global admin endpoints
|
||||
router.post('/', trialInvitationsController.createInvitation);
|
||||
router.get('/', trialInvitationsController.getAllInvitations);
|
||||
router.post('/:id/cancel', trialInvitationsController.cancelInvitation);
|
||||
|
||||
// Self-serve endpoints (autenticado, owner validation in controller)
|
||||
router.get('/pending', trialInvitationsController.getMyPendingInvitation);
|
||||
router.post('/:token/accept', trialInvitationsController.acceptInvitation);
|
||||
|
||||
export { router as trialInvitationRoutes };
|
||||
@@ -19,7 +19,7 @@ export async function getDashboardMetrics() {
|
||||
prisma.subscription.count({ where: { status: 'cancelled' } }),
|
||||
prisma.tenant.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
prisma.tenant.findMany({
|
||||
where: { dbMode: 'BYO', connectorTunnelHostname: { not: null } },
|
||||
where: { connectorTunnelHostname: { not: null } },
|
||||
select: { id: true, nombre: true, rfc: true, connectorLastSeen: true, connectorVersion: true },
|
||||
}),
|
||||
prisma.payment.aggregate({
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function provisionConnector(tenantId: string): Promise<{
|
||||
await prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
dbMode: 'BYO',
|
||||
// El conector es una feature de respaldo; el tenant siempre permanece MANAGED
|
||||
connectorTokenEnc: tokenEncoded,
|
||||
connectorTunnelHostname: hostname,
|
||||
},
|
||||
@@ -92,7 +92,7 @@ export async function verifyConnectorToken(token: string): Promise<string | null
|
||||
// Find tenant by trying to decrypt stored tokens.
|
||||
// This is O(N) — for production, use a hashed token lookup table.
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { dbMode: 'BYO', connectorTokenEnc: { not: null } },
|
||||
where: { connectorTokenEnc: { not: null } },
|
||||
select: { id: true, connectorTokenEnc: true },
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ export async function getConnectorStatus(tenantId: string): Promise<{
|
||||
select: { dbMode: true, connectorTunnelHostname: true, connectorLastSeen: true, connectorVersion: true },
|
||||
});
|
||||
|
||||
if (!tenant || tenant.dbMode !== 'BYO' || !tenant.connectorTunnelHostname) {
|
||||
if (!tenant || !tenant.connectorTunnelHostname) {
|
||||
return { configured: false, status: 'not_configured' };
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,11 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
||||
// caso donde el click sintético no dispara el handler del SAT. Si algún
|
||||
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
|
||||
const headless = process.env.SAT_HEADLESS !== 'false';
|
||||
const browser = await chromium.launch({ headless });
|
||||
const browser = await chromium.launch({
|
||||
headless,
|
||||
args: ['--disable-blink-features=AutomationControlled'],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
});
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
|
||||
@@ -171,6 +175,28 @@ async function matchRegimenesToCatalogo(regimenesCsf: RegimenCsf[]): Promise<num
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Límites de longitud en el schema Prisma de Tenant (defensivo para
|
||||
* evitar P2000 cuando el SAT devuelve valores más largos de lo esperado).
|
||||
*/
|
||||
const TENANT_FIELD_LIMITS: Record<string, number> = {
|
||||
codigoPostal: 5,
|
||||
calle: 255,
|
||||
numExterior: 20,
|
||||
numInterior: 20,
|
||||
colonia: 255,
|
||||
ciudad: 100,
|
||||
municipio: 100,
|
||||
estado: 100,
|
||||
telefono: 20,
|
||||
};
|
||||
|
||||
function truncateToLimit(key: string, value: string): string {
|
||||
const limit = TENANT_FIELD_LIMITS[key];
|
||||
if (!limit || value.length <= limit) return value;
|
||||
return value.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica el domicilio + regímenes activos de la CSF al tenant. Idempotente:
|
||||
* se puede llamar N veces, el resultado final refleja el último CSF.
|
||||
@@ -183,7 +209,9 @@ export async function sincronizarDatosFiscales(
|
||||
const fields = domicilioToTenantFields(csf.domicilio);
|
||||
const updates: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v && v.trim().length > 0) updates[k] = v.trim();
|
||||
if (v && v.trim().length > 0) {
|
||||
updates[k] = truncateToLimit(k, v.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function signupDespacho(data: DespachoSignupRequest) {
|
||||
plan: 'trial',
|
||||
databaseName: databaseName,
|
||||
verticalProfile: despacho.verticalProfile as any,
|
||||
dbMode: (despacho.plan === 'business_control' ? 'BYO' : 'MANAGED') as any,
|
||||
dbMode: 'MANAGED',
|
||||
dbSchemaVersion: 0,
|
||||
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
codigoPostal: despacho.codigoPostal,
|
||||
@@ -91,40 +91,9 @@ export async function signupDespacho(data: DespachoSignupRequest) {
|
||||
email: result.user.email,
|
||||
}).catch(err => console.error('[Signup] Welcome email failed:', err));
|
||||
|
||||
// If paid plan, create MP checkout via subscriptionService.subscribe()
|
||||
// que también crea la fila Subscription en BD (clave para que el webhook
|
||||
// pueda aplicar la dualidad firstYear→renewal tras el primer cobro aprobado).
|
||||
let paymentUrl: string | undefined;
|
||||
if (data.despacho.plan && data.despacho.plan !== 'trial') {
|
||||
try {
|
||||
const subscriptionService = await import('./payment/subscription.service.js');
|
||||
const result2 = await subscriptionService.subscribe({
|
||||
tenantId: result.tenant.id,
|
||||
plan: data.despacho.plan as any,
|
||||
// mi_empresa(+) acepta monthly/annual; los demás solo annual
|
||||
// — el subscribe valida y rechaza monthly cuando no aplica.
|
||||
frequency: data.despacho.frequency || 'annual',
|
||||
payerEmail: owner.email,
|
||||
});
|
||||
paymentUrl = result2.paymentUrl;
|
||||
} catch (err: any) {
|
||||
// Rollback: delete tenant + user since payment couldn't be set up
|
||||
await prisma.tenantMembership.deleteMany({ where: { tenantId: result.tenant.id } }).catch(() => {});
|
||||
await prisma.refreshToken.deleteMany({ where: { userId: result.user.id } }).catch(() => {});
|
||||
await prisma.tenant.delete({ where: { id: result.tenant.id } }).catch(() => {});
|
||||
await prisma.user.delete({ where: { id: result.user.id } }).catch(() => {});
|
||||
const msg = err?.message || '';
|
||||
if (msg.includes('MercadoPago no está configurado') || msg.includes('Unauthorized access')) {
|
||||
throw new Error('No se pudo procesar el cobro. Verifica que el sistema de pagos esté configurado o selecciona el plan Trial.');
|
||||
}
|
||||
throw new Error(msg || 'No se pudo procesar el cobro.');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
paymentUrl,
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email,
|
||||
|
||||
@@ -68,6 +68,18 @@ export const emailService = {
|
||||
await sendEmail(env.ADMIN_EMAIL, `Nuevo cliente: ${data.clienteNombre} (${data.clienteRfc})`, newClientAdminEmail(data));
|
||||
},
|
||||
|
||||
sendPrimerPagoFacturar: async (data: {
|
||||
clienteNombre: string;
|
||||
clienteRfc: string;
|
||||
amount: number;
|
||||
plan: string;
|
||||
paymentDate: string;
|
||||
paymentId: string;
|
||||
}) => {
|
||||
const { primerPagoFacturarEmail } = await import('./templates/primer-pago-facturar.js');
|
||||
await sendEmail(env.ADMIN_EMAIL, `Factura pendiente: primer pago de ${data.clienteNombre}`, primerPagoFacturarEmail(data));
|
||||
},
|
||||
|
||||
sendWeeklyUpdate: async (to: string, data: import('./templates/weekly-update.js').WeeklyUpdateData) => {
|
||||
const { weeklyUpdateEmail } = await import('./templates/weekly-update.js');
|
||||
await sendEmail(to, `Actualización semanal — ${data.empresa}`, weeklyUpdateEmail(data));
|
||||
@@ -91,6 +103,17 @@ export const emailService = {
|
||||
await sendEmail(to, `Prueba finalizada — ${data.despachoNombre}`, trialExpiredEmail(data));
|
||||
},
|
||||
|
||||
sendTrialInvitation: async (to: string, data: {
|
||||
despachoNombre: string;
|
||||
plan: string;
|
||||
durationDays: number;
|
||||
acceptUrl: string;
|
||||
expiresAt: string;
|
||||
}) => {
|
||||
const { trialInvitationEmail } = await import('./templates/trial-invitation.js');
|
||||
await sendEmail(to, `Invitación especial — Prueba ${data.plan === 'business_control' ? 'Business Control' : data.plan}`, trialInvitationEmail(data));
|
||||
},
|
||||
|
||||
/**
|
||||
* Notifica la subida de una declaración o documento extra al despacho.
|
||||
* `recipients` debe venir deduplicado por el caller. El subject se
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { baseTemplate, heading, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function row(label: string, value: string, isLast = false) {
|
||||
const border = isLast ? '' : `border-bottom:1px solid ${C.border};`;
|
||||
return `<tr>
|
||||
<td style="padding:10px 16px;${border}font-weight:500;color:${C.textMuted};width:40%;font-size:13px;">${label}</td>
|
||||
<td style="padding:10px 16px;${border}color:${C.textPrimary};font-size:14px;">${value}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
export function primerPagoFacturarEmail(data: {
|
||||
clienteNombre: string;
|
||||
clienteRfc: string;
|
||||
amount: number;
|
||||
plan: string;
|
||||
paymentDate: string;
|
||||
paymentId: string;
|
||||
}): string {
|
||||
const formattedAmount = new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(data.amount);
|
||||
|
||||
return baseTemplate(`
|
||||
${heading('Primer pago aprobado — factura pendiente')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 24px;">
|
||||
El cliente <strong>${escapeHtml(data.clienteNombre)}</strong> realizó su primer pago exitosamente.
|
||||
Como es el primer pago, la factura <strong>debe emitirse manualmente</strong>.
|
||||
</p>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:24px;border:1px solid ${C.border};border-radius:8px;overflow:hidden;">
|
||||
${row('Cliente', `<strong>${escapeHtml(data.clienteNombre)}</strong>`)}
|
||||
${row('RFC', `<span style="font-family:monospace;">${escapeHtml(data.clienteRfc)}</span>`)}
|
||||
${row('Plan', escapeHtml(data.plan))}
|
||||
${row('Monto', `<strong>${formattedAmount}</strong>`)}
|
||||
${row('Fecha de pago', data.paymentDate, true)}
|
||||
</table>
|
||||
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<a href="https://horuxfin.com/admin/facturas-pendientes" style="display:inline-block;background-color:${C.primary};color:#ffffff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px;">
|
||||
Ver facturas pendientes
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color:#fef9c3;border-left:4px solid #eab308;border-radius:8px;padding:12px 16px;margin:0 0 16px;">
|
||||
<p style="margin:0;color:#854d0e;font-size:13px;">
|
||||
<strong>ℹ️ Nota:</strong> Los pagos subsecuentes se facturarán automáticamente. Solo el primer pago requiere emisión manual.
|
||||
</p>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
63
apps/api/src/services/email/templates/trial-invitation.ts
Normal file
63
apps/api/src/services/email/templates/trial-invitation.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface TrialInvitationData {
|
||||
despachoNombre: string;
|
||||
plan: string;
|
||||
durationDays: number;
|
||||
acceptUrl: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export function trialInvitationEmail(data: TrialInvitationData): string {
|
||||
const planDisplay = data.plan === 'business_control' ? 'Business Control' : data.plan;
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #1e40af; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 8px 8px; }
|
||||
.button { display: inline-block; background: #1e40af; color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: bold; margin: 20px 0; }
|
||||
.highlight { background: #dbeafe; padding: 15px; border-radius: 6px; margin: 15px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎉 Invitación especial para ${data.despachoNombre}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hola,</p>
|
||||
<p>Has recibido una invitación exclusiva para probar <strong>${planDisplay}</strong> durante <strong>${data.durationDays} días</strong> completamente gratis.</p>
|
||||
|
||||
<div class="highlight">
|
||||
<strong>¿Qué incluye?</strong>
|
||||
<ul>
|
||||
<li>Hasta 100 RFCs</li>
|
||||
<li>Usuarios ilimitados</li>
|
||||
<li>API de integración</li>
|
||||
<li>SAT incremental</li>
|
||||
<li>Todas las funciones de Business Control</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="${data.acceptUrl}" class="button">Aceptar invitación</a>
|
||||
</p>
|
||||
|
||||
<p><strong>Importante:</strong> Esta invitación expira el <strong>${data.expiresAt}</strong>. Una vez que aceptes, tendrás ${data.durationDays} días para probar todas las funciones sin compromiso.</p>
|
||||
|
||||
<p>Al finalizar el periodo de prueba, podrás contratar el plan para continuar con el servicio.</p>
|
||||
|
||||
<p>Si tienes alguna duda, contacta a nuestro equipo de soporte.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Horux Despachos — Simplificando la contabilidad</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
@@ -80,7 +80,7 @@ async function isFirstApprovedPayment(
|
||||
* Busca el tenant emisor (Horux 360) con su organización Facturapi configurada.
|
||||
* Si falta, lanza error — el admin global tiene que crear la organización primero.
|
||||
*/
|
||||
async function getEmitterTenant() {
|
||||
export async function getEmitterTenant() {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { rfc: GLOBAL_ADMIN_RFC },
|
||||
select: {
|
||||
@@ -125,7 +125,7 @@ interface CustomerData {
|
||||
* Retorna `null` si falta cualquier dato requerido — el caller debe caer
|
||||
* a público en general en ese caso.
|
||||
*/
|
||||
async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerData | null> {
|
||||
export async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerData | null> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: payerTenantId },
|
||||
select: {
|
||||
@@ -179,7 +179,7 @@ async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerDat
|
||||
* Construye el payload para Facturapi. Acepta customer real (datos del cliente)
|
||||
* o fallback a público en general si `customer` es null.
|
||||
*/
|
||||
function buildInvoicePayload(params: {
|
||||
export function buildInvoicePayload(params: {
|
||||
amount: number;
|
||||
description: string; // Texto del concepto — varía por kind (subscription vs timbres)
|
||||
emitterCp: string;
|
||||
@@ -272,6 +272,22 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
|
||||
// Gate 4: primer pago del tenant → manual
|
||||
if (await isFirstApprovedPayment(payment.tenantId, payment.id)) {
|
||||
console.log(`[Invoicing] Payment ${paymentId} es el PRIMER pago aprobado del tenant ${payment.tenantId}, skip (factura manual)`);
|
||||
// Notificar al admin global para que emita la factura manualmente
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: payment.tenantId },
|
||||
select: { nombre: true, rfc: true },
|
||||
});
|
||||
if (tenant) {
|
||||
const { emailService } = await import('../email/email.service.js');
|
||||
emailService.sendPrimerPagoFacturar({
|
||||
clienteNombre: tenant.nombre,
|
||||
clienteRfc: tenant.rfc || '',
|
||||
amount,
|
||||
plan: payment.subscription?.plan || 'custom',
|
||||
paymentDate: payment.paidAt?.toISOString() || new Date().toISOString(),
|
||||
paymentId: payment.id,
|
||||
}).catch(err => console.error('[Invoicing] Error enviando notificación de primer pago:', err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -323,6 +323,7 @@ export function verifyWebhookSignature(
|
||||
const parts: Record<string, string> = {};
|
||||
for (const part of xSignature.split(',')) {
|
||||
const [key, value] = part.split('=');
|
||||
if (!key || value === undefined) continue;
|
||||
parts[key.trim()] = value.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@ export async function loginSatCsf(
|
||||
keyPath: string,
|
||||
password: string,
|
||||
): Promise<CsfLoginSession> {
|
||||
const context = await browser.newContext({ acceptDownloads: true });
|
||||
const context = await browser.newContext({
|
||||
acceptDownloads: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
});
|
||||
const publicPage = await context.newPage();
|
||||
publicPage.setDefaultTimeout(60_000);
|
||||
|
||||
@@ -66,12 +72,34 @@ export async function loginSatCsf(
|
||||
await fileInputs.nth(0).setInputFiles(cerPath);
|
||||
await fileInputs.nth(1).setInputFiles(keyPath);
|
||||
|
||||
// Esperar a que el cert async parsing termine (RFC auto-populado por SAT).
|
||||
try {
|
||||
await loginPage.waitForFunction(
|
||||
() => {
|
||||
const rfc = document.getElementById('rfc') as HTMLInputElement | null;
|
||||
return rfc !== null && rfc.value.length >= 12;
|
||||
},
|
||||
null,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
} catch (err) {
|
||||
const html = await loginPage.content();
|
||||
const { writeFileSync, mkdirSync } = await import('node:fs');
|
||||
const debugDir = '/tmp/horux-csf-debug';
|
||||
try { mkdirSync(debugDir, { recursive: true }); } catch { /* ok */ }
|
||||
writeFileSync(`${debugDir}/04c-rfc-timeout-html.html`, html);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Password + Enviar
|
||||
await loginPage.locator('input[type="password"]').first().fill(password);
|
||||
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click();
|
||||
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click({ noWaitAfter: true });
|
||||
|
||||
// Esperar a que salga del dominio de login
|
||||
await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 });
|
||||
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
||||
await loginPage.waitForURL(
|
||||
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||
await loginPage.waitForTimeout(2000);
|
||||
|
||||
|
||||
@@ -85,12 +85,29 @@ function extractLabels(text: string): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
const labelAlternation = LABELS.map(escapeRegex).join('|');
|
||||
const re = new RegExp(
|
||||
`(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s+(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`,
|
||||
`(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s*(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`,
|
||||
'g',
|
||||
);
|
||||
for (const match of text.matchAll(re)) {
|
||||
const label = match[1];
|
||||
const value = match[2].replace(/\s+/g, ' ').trim();
|
||||
let value = match[2].replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Defensa: el SAT a veces pone etiquetas consecutivas sin valor intermedio
|
||||
// (ej. "Número Interior:\nNombre de la Colonia: X"). El regex lazy captura
|
||||
// de más y el valor termina incluyendo el nombre de la siguiente etiqueta.
|
||||
// Limpiamos cualquier prefijo de otra etiqueta del SAT que haya quedado al
|
||||
// inicio del valor.
|
||||
for (const otherLabel of LABELS) {
|
||||
if (otherLabel === label) continue;
|
||||
const prefix = otherLabel + ':';
|
||||
const lowerValue = value.toLowerCase();
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
if (lowerValue.startsWith(lowerPrefix)) {
|
||||
value = value.slice(prefix.length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.has(label)) result.set(label, value);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -111,7 +111,12 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.entryName.toLowerCase().endsWith('.xml')) {
|
||||
const content = entry.getData().toString('utf-8');
|
||||
let content = entry.getData().toString('utf-8');
|
||||
// Remover UTF-8 BOM si existe — fast-xml-parser no lo maneja y devuelve
|
||||
// result.Comprobante = undefined, dejando el CFDI sin parsear.
|
||||
if (content.charCodeAt(0) === 0xFEFF) {
|
||||
content = content.slice(1);
|
||||
}
|
||||
xmlFiles.push({
|
||||
filename: entry.entryName,
|
||||
content,
|
||||
@@ -140,8 +145,13 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
|
||||
*/
|
||||
function parseCfdiDate(str: string | null | undefined): Date {
|
||||
if (!str) return new Date(0);
|
||||
const s = String(str).trim();
|
||||
let s = String(str).trim();
|
||||
if (!s) return new Date(0);
|
||||
// Defensa: el SAT a veces concatena múltiples fechas con '|' (ej. en
|
||||
// FechaTimbrado duplicado). Tomamos solo la primera fecha válida.
|
||||
if (s.includes('|')) {
|
||||
s = s.split('|')[0].trim();
|
||||
}
|
||||
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
|
||||
return new Date(hasTz ? s : s + 'Z');
|
||||
}
|
||||
@@ -155,18 +165,28 @@ function pf(val: any): number {
|
||||
return parseFloat(val || '0') || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el UUID del TimbreFiscalDigital
|
||||
*/
|
||||
function getFirstTimbre(comprobante: any): any {
|
||||
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
|
||||
if (!timbre) return null;
|
||||
return Array.isArray(timbre) ? timbre[0] : timbre;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el UUID del TimbreFiscalDigital
|
||||
*/
|
||||
function extractUuid(comprobante: any): string {
|
||||
return comprobante.Complemento?.TimbreFiscalDigital?.['@_UUID'] || '';
|
||||
const timbre = getFirstTimbre(comprobante);
|
||||
return timbre?.['@_UUID'] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae datos del timbre: fecha cert SAT y PAC
|
||||
*/
|
||||
function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } {
|
||||
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
|
||||
const timbre = getFirstTimbre(comprobante);
|
||||
if (!timbre) return { fechaCertSat: null, pac: null };
|
||||
|
||||
return {
|
||||
@@ -322,7 +342,7 @@ function extractPagos(comprobante: any): {
|
||||
}
|
||||
}
|
||||
|
||||
result.fechaPagoP = fechas.length > 0 ? fechas.join('|') : null;
|
||||
result.fechaPagoP = fechas.length > 0 ? parseCfdiDate(fechas[0]).toISOString() : null;
|
||||
result.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null;
|
||||
result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null;
|
||||
result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null;
|
||||
@@ -370,9 +390,9 @@ function extractNomina(comprobante: any): {
|
||||
const nomina = complemento.Nomina;
|
||||
if (!nomina) return result;
|
||||
|
||||
result.fechaPago = nomina['@_FechaPago'] || null;
|
||||
result.fechaInicialPago = nomina['@_FechaInicialPago'] || null;
|
||||
result.fechaFinalPago = nomina['@_FechaFinalPago'] || null;
|
||||
result.fechaPago = nomina['@_FechaPago'] ? parseCfdiDate(nomina['@_FechaPago']).toISOString() : null;
|
||||
result.fechaInicialPago = nomina['@_FechaInicialPago'] ? parseCfdiDate(nomina['@_FechaInicialPago']).toISOString() : null;
|
||||
result.fechaFinalPago = nomina['@_FechaFinalPago'] ? parseCfdiDate(nomina['@_FechaFinalPago']).toISOString() : null;
|
||||
result.numDiasPagados = pf(nomina['@_NumDiasPagados']);
|
||||
result.totalPercepciones = pf(nomina['@_TotalPercepciones']);
|
||||
result.totalDeducciones = pf(nomina['@_TotalDeducciones']);
|
||||
|
||||
@@ -547,9 +547,35 @@ async function requestAndDownload(
|
||||
// Intentar reusar requestId previo del mismo job/kindKey (caso retry)
|
||||
const jobRow = await prisma.satSyncJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { satRequestIds: true },
|
||||
select: { satRequestIds: true, tenantId: true, contribuyenteId: true, dateFrom: true, dateTo: true },
|
||||
});
|
||||
const existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
||||
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
||||
|
||||
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
|
||||
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
|
||||
if (!existingMap[kindKey]) {
|
||||
const previousJob = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId: jobRow?.tenantId,
|
||||
contribuyenteId: jobRow?.contribuyenteId ?? null,
|
||||
id: { not: jobId },
|
||||
dateFrom: jobRow?.dateFrom,
|
||||
dateTo: jobRow?.dateTo,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { satRequestIds: true },
|
||||
});
|
||||
if (previousJob?.satRequestIds) {
|
||||
const prevMap = previousJob.satRequestIds as Record<string, string>;
|
||||
if (prevMap[kindKey]) {
|
||||
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
||||
// Copiar al job actual para futuros usos
|
||||
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
||||
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let requestId: string | null = existingMap[kindKey] || null;
|
||||
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
||||
|
||||
@@ -651,7 +677,8 @@ async function processDateRange(
|
||||
jobId: string,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date,
|
||||
tipoCfdi: CfdiSyncType
|
||||
tipoCfdi: CfdiSyncType,
|
||||
skipJobUpdate = false
|
||||
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
||||
let totalFound = 0;
|
||||
let totalDownloaded = 0;
|
||||
@@ -678,12 +705,14 @@ async function processDateRange(
|
||||
console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`);
|
||||
}
|
||||
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisFound: totalFound,
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
});
|
||||
if (!skipJobUpdate) {
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisFound: totalFound,
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
found: totalFound,
|
||||
@@ -787,7 +816,9 @@ async function processInitialSync(
|
||||
customDateTo?: Date
|
||||
): Promise<void> {
|
||||
const ahora = new Date();
|
||||
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
|
||||
// Exactamente 6 años atrás desde hoy (mismo día del mes), no inicio de mes.
|
||||
// El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años.
|
||||
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), ahora.getDate());
|
||||
const fechaFin = customDateTo || ahora;
|
||||
|
||||
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
||||
@@ -802,13 +833,29 @@ async function processInitialSync(
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
|
||||
const totalSteps = xmlChunks.length * 2 + metaChunks.length * 2; // emitidos + recibidos por cada chunk
|
||||
let completedSteps = 0;
|
||||
|
||||
// Helper para actualizar progreso acumulado
|
||||
async function reportProgress() {
|
||||
completedSteps++;
|
||||
const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisFound: totalFound,
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
progressPercent,
|
||||
});
|
||||
}
|
||||
|
||||
// Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses)
|
||||
for (let i = 0; i < xmlChunks.length; i++) {
|
||||
const { start, end } = xmlChunks[i];
|
||||
console.log(`[SAT] XML bloque ${i + 1}/${xmlChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`);
|
||||
|
||||
try {
|
||||
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos');
|
||||
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos', true);
|
||||
totalFound += emitidos.found;
|
||||
totalDownloaded += emitidos.downloaded;
|
||||
totalInserted += emitidos.inserted;
|
||||
@@ -816,9 +863,10 @@ async function processInitialSync(
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message);
|
||||
}
|
||||
await reportProgress();
|
||||
|
||||
try {
|
||||
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos');
|
||||
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos', true);
|
||||
totalFound += recibidos.found;
|
||||
totalDownloaded += recibidos.downloaded;
|
||||
totalInserted += recibidos.inserted;
|
||||
@@ -826,6 +874,7 @@ async function processInitialSync(
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message);
|
||||
}
|
||||
await reportProgress();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
@@ -842,6 +891,7 @@ async function processInitialSync(
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message);
|
||||
}
|
||||
await reportProgress();
|
||||
|
||||
try {
|
||||
const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'recibidos');
|
||||
@@ -850,6 +900,7 @@ async function processInitialSync(
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message);
|
||||
}
|
||||
await reportProgress();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
@@ -859,6 +910,7 @@ async function processInitialSync(
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
progressPercent: 100,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ export interface SweepResult {
|
||||
}>;
|
||||
}
|
||||
|
||||
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||
initial: 8,
|
||||
daily: 4,
|
||||
incremental: 2,
|
||||
custom: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Watchdog para jobs `sat_sync_jobs` stale.
|
||||
*
|
||||
@@ -22,35 +29,45 @@ export interface SweepResult {
|
||||
* (dev, caída, reinicio largo) el job queda colgado y bloquea el
|
||||
* lock para nuevos syncs del mismo (tenant, contribuyente).
|
||||
*
|
||||
* 2. `running` con `startedAt` > runningHours atrás. Un sync inicial
|
||||
* típico termina en <2h; si lleva >runningHours es casi seguro
|
||||
* huérfano de un proceso que murió. La solicitud SAT ya expiró.
|
||||
* 2. `running` con `startedAt` > runningHours atrás. Thresholds difieren
|
||||
* por tipo: initial (8h) porque un bootstrap de 6 años puede tardar
|
||||
* varias horas; daily (4h); incremental (2h) porque es ventana corta.
|
||||
* Si lleva >threshold es casi seguro huérfano de un proceso que murió.
|
||||
*
|
||||
* Marca ambos como `failed` con `errorMessage` descriptivo. Idempotente
|
||||
* (volver a correrlo no reabre los ya-marcados-failed).
|
||||
*
|
||||
* - `apply=false` (default): dry-run, no toca BD.
|
||||
* - `pendingHours`/`runningHours`: thresholds (default 12h / 4h).
|
||||
* - `pendingHours`: threshold pending (default 12h).
|
||||
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 4h).
|
||||
* - `runningHoursByType`: override por tipo de sync.
|
||||
*/
|
||||
export async function sweepStaleSatJobs(params: {
|
||||
apply: boolean;
|
||||
pendingHours?: number;
|
||||
runningHours?: number;
|
||||
runningHoursByType?: Record<string, number>;
|
||||
} = { apply: false }): Promise<SweepResult> {
|
||||
const pendingHours = params.pendingHours ?? 12;
|
||||
const runningHours = params.runningHours ?? 4;
|
||||
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
|
||||
const now = new Date();
|
||||
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
|
||||
const runningCutoff = new Date(now.getTime() - runningHours * 3600 * 1000);
|
||||
|
||||
const stalePending = await prisma.satSyncJob.findMany({
|
||||
where: { status: 'pending', nextRetryAt: { lt: pendingCutoff } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const staleRunning = await prisma.satSyncJob.findMany({
|
||||
where: { status: 'running', startedAt: { lt: runningCutoff } },
|
||||
|
||||
// running: evaluar por tipo usando thresholds distintos
|
||||
const allRunning = await prisma.satSyncJob.findMany({
|
||||
where: { status: 'running' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const staleRunning = allRunning.filter(j => {
|
||||
const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4;
|
||||
const cutoff = new Date(now.getTime() - thresholdHours * 3600 * 1000);
|
||||
return (j.startedAt ?? j.createdAt) < cutoff;
|
||||
});
|
||||
|
||||
const result: SweepResult = {
|
||||
pendingFound: stalePending.length,
|
||||
@@ -83,12 +100,13 @@ export async function sweepStaleSatJobs(params: {
|
||||
result.pendingMarked++;
|
||||
}
|
||||
for (const j of staleRunning) {
|
||||
const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4;
|
||||
await prisma.satSyncJob.update({
|
||||
where: { id: j.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
completedAt: now,
|
||||
errorMessage: `Abandoned by watchdog: running with startedAt ${j.startedAt?.toISOString()} > ${runningHours}h (process crash / orphan). SAT request is lost; re-launch manually.`,
|
||||
errorMessage: `Abandoned by watchdog: running ${j.type} with startedAt ${j.startedAt?.toISOString()} > ${thresholdHours}h (process crash / orphan). SAT request is lost; re-launch manually.`,
|
||||
},
|
||||
});
|
||||
result.runningMarked++;
|
||||
|
||||
@@ -17,7 +17,17 @@ export async function getAllTenants() {
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: { memberships: { where: { active: true } } as any }
|
||||
}
|
||||
},
|
||||
subscriptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
currentPeriodEnd: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { nombre: 'asc' }
|
||||
});
|
||||
@@ -266,8 +276,10 @@ export async function updateTenant(id: string, data: {
|
||||
rfc?: string;
|
||||
plan?: DespachoPlan;
|
||||
active?: boolean;
|
||||
amount?: number;
|
||||
firstPaymentDueAt?: string | null;
|
||||
}) {
|
||||
return prisma.tenant.update({
|
||||
const tenant = await prisma.tenant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.nombre && { nombre: data.nombre }),
|
||||
@@ -285,6 +297,29 @@ export async function updateTenant(id: string, data: {
|
||||
createdAt: true,
|
||||
}
|
||||
});
|
||||
|
||||
// Actualizar subscription del tenant (plan custom o cualquier plan con amount)
|
||||
if (data.amount !== undefined || data.firstPaymentDueAt !== undefined) {
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { tenantId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (subscription) {
|
||||
const updateData: any = {};
|
||||
if (data.amount !== undefined) {
|
||||
updateData.amount = data.amount;
|
||||
}
|
||||
if (data.firstPaymentDueAt !== undefined) {
|
||||
updateData.currentPeriodEnd = data.firstPaymentDueAt ? new Date(data.firstPaymentDueAt) : null;
|
||||
}
|
||||
await prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
export async function getDatosFiscales(id: string) {
|
||||
|
||||
191
apps/api/src/services/trial-invitations.service.ts
Normal file
191
apps/api/src/services/trial-invitations.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import { getTenantOwnerEmail } from '../utils/memberships.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
function generateToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export async function createInvitation(params: {
|
||||
tenantId: string;
|
||||
invitedByUserId: string;
|
||||
plan?: string;
|
||||
durationDays: number;
|
||||
}) {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: params.tenantId },
|
||||
select: { nombre: true, rfc: true, plan: true },
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
|
||||
// Verificar que no haya ya una invitación pendiente para este tenant
|
||||
const existingPending = await prisma.trialInvitation.findFirst({
|
||||
where: { tenantId: params.tenantId, status: 'pending' },
|
||||
});
|
||||
if (existingPending) {
|
||||
throw new Error('Este tenant ya tiene una invitación de trial pendiente');
|
||||
}
|
||||
|
||||
// Verificar que el tenant no tenga ya una suscripción activa del mismo plan
|
||||
const existingSub = await prisma.subscription.findFirst({
|
||||
where: {
|
||||
tenantId: params.tenantId,
|
||||
status: { in: ['authorized', 'pending', 'trial'] },
|
||||
plan: (params.plan || 'business_control') as any,
|
||||
},
|
||||
});
|
||||
if (existingSub) {
|
||||
throw new Error(`Este tenant ya tiene una suscripción activa o en trial de ${params.plan || 'business_control'}`);
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días para aceptar
|
||||
|
||||
const invitation = await prisma.trialInvitation.create({
|
||||
data: {
|
||||
tenantId: params.tenantId,
|
||||
invitedBy: params.invitedByUserId,
|
||||
plan: params.plan || 'business_control',
|
||||
durationDays: params.durationDays,
|
||||
token,
|
||||
expiresAt,
|
||||
emailSentTo: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Enviar email al owner (fire-and-forget)
|
||||
const ownerEmail = await getTenantOwnerEmail(params.tenantId);
|
||||
if (ownerEmail) {
|
||||
await prisma.trialInvitation.update({
|
||||
where: { id: invitation.id },
|
||||
data: { emailSentTo: ownerEmail },
|
||||
});
|
||||
const acceptUrl = `${process.env.FRONTEND_URL || 'https://app.horux360.com'}/invitacion/trial/${token}`;
|
||||
emailService.sendTrialInvitation(ownerEmail, {
|
||||
despachoNombre: tenant.nombre,
|
||||
plan: invitation.plan,
|
||||
durationDays: invitation.durationDays,
|
||||
acceptUrl,
|
||||
expiresAt: expiresAt.toLocaleDateString('es-MX'),
|
||||
}).catch((err: any) => console.error('[TrialInvitation] Email failed:', err.message));
|
||||
}
|
||||
|
||||
return invitation;
|
||||
}
|
||||
|
||||
export async function acceptInvitation(token: string, userId: string) {
|
||||
const invitation = await prisma.trialInvitation.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
if (!invitation) throw new Error('Invitación no encontrada');
|
||||
if (invitation.status !== 'pending') throw new Error(`Invitación ya ${invitation.status}`);
|
||||
if (invitation.expiresAt < new Date()) {
|
||||
await prisma.trialInvitation.update({
|
||||
where: { id: invitation.id },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
throw new Error('La invitación ha expirado');
|
||||
}
|
||||
|
||||
// Verificar que el usuario sea owner del tenant
|
||||
const membership = await prisma.tenantMembership.findFirst({
|
||||
where: { userId, tenantId: invitation.tenantId, isOwner: true, active: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new Error('Solo el dueño del despacho puede aceptar esta invitación');
|
||||
}
|
||||
|
||||
const trialEndsAt = new Date();
|
||||
trialEndsAt.setDate(trialEndsAt.getDate() + invitation.durationDays);
|
||||
const now = new Date();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Actualizar tenant
|
||||
await tx.tenant.update({
|
||||
where: { id: invitation.tenantId },
|
||||
data: {
|
||||
plan: invitation.plan as any,
|
||||
trialEndsAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Cancelar cualquier subscription trial anterior genérica
|
||||
await tx.subscription.updateMany({
|
||||
where: { tenantId: invitation.tenantId, status: 'trial' },
|
||||
data: { status: 'trial_converted' },
|
||||
});
|
||||
|
||||
// Crear nueva subscription de trial con el plan específico
|
||||
await tx.subscription.create({
|
||||
data: {
|
||||
tenantId: invitation.tenantId,
|
||||
plan: invitation.plan as any,
|
||||
status: 'trial',
|
||||
amount: 0,
|
||||
frequency: 'annual',
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: trialEndsAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Marcar invitación como aceptada
|
||||
await tx.trialInvitation.update({
|
||||
where: { id: invitation.id },
|
||||
data: { status: 'accepted', acceptedAt: now },
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true, trialEndsAt, plan: invitation.plan, durationDays: invitation.durationDays };
|
||||
}
|
||||
|
||||
export async function getInvitations(filters?: { tenantId?: string; status?: string }) {
|
||||
const where: any = {};
|
||||
if (filters?.tenantId) where.tenantId = filters.tenantId;
|
||||
if (filters?.status) where.status = filters.status;
|
||||
|
||||
const invitations = await prisma.trialInvitation.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Enrich with tenant data
|
||||
const tenantIds = [...new Set(invitations.map(i => i.tenantId))];
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { id: { in: tenantIds } },
|
||||
select: { id: true, nombre: true, rfc: true },
|
||||
});
|
||||
const tenantMap = new Map(tenants.map(t => [t.id, t]));
|
||||
|
||||
return invitations.map(inv => ({
|
||||
...inv,
|
||||
tenant: tenantMap.get(inv.tenantId) || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getPendingInvitationForTenant(tenantId: string) {
|
||||
const invitation = await prisma.trialInvitation.findFirst({
|
||||
where: { tenantId, status: 'pending', expiresAt: { gt: new Date() } },
|
||||
});
|
||||
if (!invitation) return null;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true, rfc: true },
|
||||
});
|
||||
|
||||
return { ...invitation, tenant };
|
||||
}
|
||||
|
||||
export async function cancelInvitation(invitationId: string) {
|
||||
const invitation = await prisma.trialInvitation.findUnique({
|
||||
where: { id: invitationId },
|
||||
});
|
||||
if (!invitation) throw new Error('Invitación no encontrada');
|
||||
if (invitation.status !== 'pending') throw new Error('Solo se pueden cancelar invitaciones pendientes');
|
||||
|
||||
return prisma.trialInvitation.update({
|
||||
where: { id: invitationId },
|
||||
data: { status: 'cancelled' },
|
||||
});
|
||||
}
|
||||
@@ -107,7 +107,12 @@ export async function getPlatformRoles(userId: string): Promise<PlatformRole[]>
|
||||
* owner del tenant HTS240708LJA, se considera platform_admin (cubre el escenario
|
||||
* post-deploy pre-seed).
|
||||
*/
|
||||
export async function isGlobalAdmin(tenantId: string, role: string): Promise<boolean> {
|
||||
export async function isGlobalAdmin(tenantId: string, role: string, userId?: string): Promise<boolean> {
|
||||
// Si se pasa userId y tiene rol de plataforma superset, permitir acceso global
|
||||
if (userId && await hasAnyPlatformRole(userId, ...SUPERSET_ROLES)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Las firmas viejas no tienen userId disponible. Lo resolvemos buscando el user
|
||||
// que matchea tenantId + rol 'owner'. Para evitar ese hit extra, la preferencia
|
||||
// es usar `hasPlatformRole(req.user.userId, 'platform_admin')` en código nuevo.
|
||||
|
||||
Reference in New Issue
Block a user