feat(notificaciones): configuración de notificaciones por rol

- Nueva tabla tenant notification_role_preferences para guardar (email_type, role, enabled).
- Migración 051 aplicada a todos los tenants.
- Backend expone endpoint /notificaciones con matriz de preferencias por rol.
- Filtrado por rol en documento_subido, weekly_update, subscription_expiring,
  alertas_nuevas y recordatorio_proximo.
- Frontend rediseñado como tabla notificación × rol con toggles inmediatos.
This commit is contained in:
Horux Dev
2026-06-17 00:04:37 +00:00
parent 8a1fbceb38
commit b217342a96
8 changed files with 380 additions and 192 deletions

View File

@@ -3,29 +3,42 @@ import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js';
import {
EMAIL_TYPES,
getEmailPreferencesPorContribuyente,
setContribuyenteEmailPreferences,
NOTIFICATION_ROLES,
getRoleEmailPreferences,
setRoleEmailPreference,
type EmailType,
type NotificationRole,
} from '../services/notification-preferences.service.js';
export async function listPreferences(req: Request, res: Response, next: NextFunction) {
try {
const data = await getEmailPreferencesPorContribuyente(req.tenantPool!);
res.json({ emailTypes: EMAIL_TYPES, data });
const preferences = await getRoleEmailPreferences(req.tenantPool!);
res.json({
emailTypes: EMAIL_TYPES,
roles: NOTIFICATION_ROLES,
preferences,
});
} catch (error) {
next(error);
}
}
const updateSchema = z.object({
contribuyenteId: z.string().uuid(),
preferences: z.record(z.string(), z.boolean()),
emailType: z.enum([...EMAIL_TYPES] as [string, ...string[]]),
role: z.enum([...NOTIFICATION_ROLES] as [string, ...string[]]),
enabled: z.boolean(),
});
export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
try {
const { contribuyenteId, preferences } = updateSchema.parse(req.body);
const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences);
res.json({ contribuyenteId, preferences: updated });
const { emailType, role, enabled } = updateSchema.parse(req.body);
const preferences = await setRoleEmailPreference(
req.tenantPool!,
emailType as EmailType,
role as NotificationRole,
enabled,
);
res.json({ preferences });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);

View File

@@ -13,6 +13,7 @@ import { tenantDb } from '../config/database.js';
import { getKpis } from '../services/dashboard.service.js';
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js';
import { emailService } from '../services/email/email.service.js';
import { filterRecipientsByRole } from '../services/notification-preferences.service.js';
const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM
@@ -45,19 +46,27 @@ export async function sendWeeklyUpdateForTenant(tenantId: string): Promise<{ sen
return { sent: 0 };
}
// Recipientes: owners activos del tenant
// Pool del tenant para queries de preferencias y CFDI
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
// Recipientes: owners activos del tenant (filtrados por preferencias de rol)
const owners = await prisma.tenantMembership.findMany({
where: { tenantId, isOwner: true, active: true },
include: { user: { select: { email: true, nombre: true, active: true } } },
});
const recipients = owners.filter(o => o.user.active);
if (recipients.length === 0) {
const activeOwners = owners.filter(o => o.user.active);
if (activeOwners.length === 0) {
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
return { sent: 0 };
}
// Pool del tenant para queries de CFDI
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const recipientsWithRole = activeOwners.map(o => ({ email: o.user.email, role: 'owner' as const }));
const allowedEmails = new Set(await filterRecipientsByRole(pool, 'weekly_update', recipientsWithRole));
const recipients = activeOwners.filter(o => allowedEmails.has(o.user.email));
if (recipients.length === 0) {
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners con weekly_update habilitado, skip`);
return { sent: 0 };
}
const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange();

View File

@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS notification_role_preferences (
id SERIAL PRIMARY KEY,
email_type VARCHAR(50) NOT NULL,
role VARCHAR(20) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (email_type, role)
);
INSERT INTO notification_role_preferences (email_type, role, enabled)
VALUES
('documento_subido','owner',true),
('documento_subido','supervisor',true),
('documento_subido','auxiliar',true),
('documento_subido','cliente',true),
('weekly_update','owner',true),
('weekly_update','supervisor',true),
('weekly_update','auxiliar',true),
('weekly_update','cliente',true),
('subscription_expiring','owner',true),
('subscription_expiring','supervisor',true),
('subscription_expiring','auxiliar',true),
('subscription_expiring','cliente',true),
('recordatorio_fiscal','owner',true),
('recordatorio_fiscal','supervisor',true),
('recordatorio_fiscal','auxiliar',true),
('recordatorio_fiscal','cliente',true),
('alertas_nuevas','owner',true),
('alertas_nuevas','supervisor',true),
('alertas_nuevas','auxiliar',true),
('alertas_nuevas','cliente',true),
('recordatorio_proximo','owner',true),
('recordatorio_proximo','supervisor',true),
('recordatorio_proximo','auxiliar',true),
('recordatorio_proximo','cliente',true)
ON CONFLICT (email_type, role) DO NOTHING;

View File

@@ -1,30 +1,49 @@
import type { Pool } from 'pg';
/**
* Tipos de correos informativos cuyo envío puede desactivarse por
* contribuyente. NO incluye correos transaccionales críticos
* (welcome, password-reset, payment-*) — esos siempre se envían.
* Tipos de correos informativos cuyo envío puede desactivarse por rol.
* NO incluye correos transaccionales críticos (welcome, password-reset,
* payment-*, invitaciones) — esos siempre se envían.
*
* Estado de implementación:
* - documento_subido: ✅ implementado y configurable por contribuyente
* - weekly_update: ✅ implementado (job tenant-wide, no configurable)
* - subscription_expiring: ✅ implementado (aviso a owner, no configurable)
* - documento_subido: ✅ implementado (owner + supervisor del contribuyente)
* - weekly_update: ✅ implementado (job tenant-wide, owners)
* - subscription_expiring: ✅ implementado (aviso a owner)
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas
* - alertas_nuevas: ✅ implementado (supervisor + auxiliares + clientes)
* - recordatorio_proximo: ✅ implementado (auxiliar/supervisor/cliente/owner)
*/
export const EMAIL_TYPES = [
'documento_subido',
'weekly_update',
'subscription_expiring',
'recordatorio_fiscal',
'alertas_nuevas',
'recordatorio_proximo',
] as const;
export type EmailType = (typeof EMAIL_TYPES)[number];
/**
* Roles que pueden recibir notificaciones informativas. Se excluyen roles
* que hoy no son destinatarios de ninguna notificación (cfo, contador, visor).
*/
export const NOTIFICATION_ROLES = [
'owner',
'supervisor',
'auxiliar',
'cliente',
] as const;
export type NotificationRole = (typeof NOTIFICATION_ROLES)[number];
export type EmailPreferences = Record<EmailType, boolean>;
export type RoleEmailPreferences = Record<EmailType, Record<NotificationRole, boolean>>;
/**
* Default: todo activado. Si el JSONB en BD viene vacío o falta una
* key, asumimos `true` para preservar el comportamiento previo.
* Default legacy (por contribuyente). Se mantiene por compatibilidad con la
* columna `contribuyentes.email_preferences`; la UI nueva ya no lo usa.
*/
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
const out = {} as EmailPreferences;
@@ -38,10 +57,10 @@ function sanitizeUuid(id: string): string {
return id.replace(/[^a-f0-9-]/gi, '');
}
/**
* Lee las preferencias de un contribuyente. Devuelve defaults (todo
* activado) si no hay fila o la columna está vacía.
*/
// ═══════════════════════════════════════════════════════════════════════════
// Preferencias por contribuyente (legacy — conservado por compatibilidad)
// ═══════════════════════════════════════════════════════════════════════════
export async function getContribuyenteEmailPreferences(
pool: Pool,
contribuyenteId: string,
@@ -55,11 +74,6 @@ export async function getContribuyenteEmailPreferences(
return applyDefaults(raw);
}
/**
* Actualiza las preferencias de un contribuyente. Solo persiste las
* keys conocidas (filtra extras maliciosos). Merge sobre la columna
* existente (no sobreescribe keys no enviadas).
*/
export async function setContribuyenteEmailPreferences(
pool: Pool,
contribuyenteId: string,
@@ -81,10 +95,6 @@ export async function setContribuyenteEmailPreferences(
return getContribuyenteEmailPreferences(pool, contribuyenteId);
}
/**
* Lee preferencias para múltiples contribuyentes en una sola query.
* Útil para la UI de `/configuracion/notificaciones` que lista todos.
*/
export async function getEmailPreferencesPorContribuyente(
pool: Pool,
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
@@ -108,3 +118,89 @@ export async function getEmailPreferencesPorContribuyente(
preferences: applyDefaults(r.email_preferences ?? {}),
}));
}
// ═══════════════════════════════════════════════════════════════════════════
// Preferencias por rol (nuevo modelo)
// ═══════════════════════════════════════════════════════════════════════════
function applyRoleDefaults(raw: Array<{ email_type: string; role: string; enabled: boolean }>): RoleEmailPreferences {
const out = {} as RoleEmailPreferences;
for (const t of EMAIL_TYPES) {
out[t] = {} as Record<NotificationRole, boolean>;
for (const r of NOTIFICATION_ROLES) {
const row = raw.find(x => x.email_type === t && x.role === r);
out[t][r] = row ? row.enabled : true;
}
}
return out;
}
/**
* Lee las preferencias de notificación por rol. Si la tabla está vacía para
* un (type, role), asume `true` para no romper el comportamiento previo.
*/
export async function getRoleEmailPreferences(pool: Pool): Promise<RoleEmailPreferences> {
const { rows } = await pool.query<{ email_type: string; role: string; enabled: boolean }>(
`SELECT email_type, role, enabled FROM notification_role_preferences`
);
return applyRoleDefaults(rows);
}
/**
* Actualiza una celda (emailType, role). Ignora valores desconocidos.
*/
export async function setRoleEmailPreference(
pool: Pool,
emailType: EmailType,
role: NotificationRole,
enabled: boolean,
): Promise<RoleEmailPreferences> {
await pool.query(
`INSERT INTO notification_role_preferences (email_type, role, enabled)
VALUES ($1, $2, $3)
ON CONFLICT (email_type, role) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = NOW()`,
[emailType, role, enabled],
);
return getRoleEmailPreferences(pool);
}
/**
* Devuelve true si el rol tiene habilitado el tipo de notificación.
* Fallback a true si no hay fila (comportamiento seguro).
*/
export async function isRoleEnabled(
pool: Pool,
emailType: EmailType,
role: NotificationRole,
): Promise<boolean> {
const { rows } = await pool.query<{ enabled: boolean }>(
`SELECT enabled FROM notification_role_preferences WHERE email_type = $1 AND role = $2`,
[emailType, role],
);
return rows[0]?.enabled ?? true;
}
interface RecipientWithRole {
email: string;
role: NotificationRole;
}
/**
* Filtra una lista de destinatarios con rol según las preferencias guardadas.
* Si no hay preferencias para un (type, role), se conserva el destinatario.
*/
export async function filterRecipientsByRole(
pool: Pool,
emailType: EmailType,
recipients: RecipientWithRole[],
): Promise<string[]> {
const prefs = await getRoleEmailPreferences(pool);
const typePrefs = prefs[emailType];
const filtered = recipients.filter(r => {
if (!typePrefs) return true;
return typePrefs[r.role] !== false;
});
return [...new Set(filtered.map(r => r.email))];
}
export type { RecipientWithRole };

View File

@@ -26,6 +26,12 @@ import { generarAlertasAutomaticas, type AlertaAuto } from './alertas-auto.servi
import { emailService } from './email/email.service.js';
import type { AlertaItem } from './email/templates/alertas-nuevas.js';
import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js';
import {
filterRecipientsByRole,
type RecipientWithRole,
type EmailType,
type NotificationRole,
} from './notification-preferences.service.js';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
@@ -100,39 +106,60 @@ async function getUserContacts(userIds: string[]): Promise<UserContact[]> {
/**
* Destinatarios de una alerta: supervisor + auxiliares + clientes del
* contribuyente. Si el owner del tenant es supervisor, ya queda incluido
* (no se duplica).
* contribuyente. Retorna emails con su rol para poder filtrar por
* preferencias de notificación.
*/
async function recipientsForAlerta(
pool: Pool,
tenantId: string,
contribuyenteId: string,
): Promise<string[]> {
): Promise<RecipientWithRole[]> {
const ids = await getUserIdsContribuyente(pool, contribuyenteId);
const userIds = new Set<string>();
if (ids.supervisor) userIds.add(ids.supervisor);
ids.auxiliares.forEach(id => userIds.add(id));
ids.clientes.forEach(id => userIds.add(id));
const contacts = await getUserContacts([...userIds]);
return [...new Set(contacts.map(c => c.email))];
const byRole = new Map<string, NotificationRole>();
if (ids.supervisor) byRole.set(ids.supervisor, 'supervisor');
ids.auxiliares.forEach(id => byRole.set(id, 'auxiliar'));
ids.clientes.forEach(id => byRole.set(id, 'cliente'));
const contacts = await getUserContacts([...byRole.keys()]);
return contacts
.filter(c => byRole.has(c.userId))
.map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
}
async function getUserRole(
tenantId: string,
userId: string,
): Promise<NotificationRole | null> {
const m = await prisma.tenantMembership.findFirst({
where: { userId, tenantId, active: true },
include: { rol: { select: { nombre: true } } },
});
if (!m) return null;
const role = m.rol.nombre;
if (role === 'owner' || role === 'supervisor' || role === 'auxiliar' || role === 'cliente') {
return role;
}
return null;
}
/**
* Destinatarios de un recordatorio. Los recordatorios del despacho son
* tenant-level (no atados a contribuyente). Para públicos: clientes con
* algún acceso + auxiliares de cualquier cartera; si no hay auxiliares,
* supervisores; si owner aparece como supervisor, también recibe.
* tenant-level (no atados a contribuyente). Retorna emails con rol para
* filtrado por preferencias.
*
* Públicos: clientes + auxiliares + supervisores + owners.
* Privados: solo el creador.
*/
async function recipientsForRecordatorio(
pool: Pool,
tenantId: string,
recordatorio: { creadoPor: string; privado: boolean },
): Promise<string[]> {
): Promise<RecipientWithRole[]> {
if (recordatorio.privado) {
const role = await getUserRole(tenantId, recordatorio.creadoPor);
if (!role) return [];
const contacts = await getUserContacts([recordatorio.creadoPor]);
return [...new Set(contacts.map(c => c.email))];
return contacts.map(c => ({ email: c.email, role }));
}
// Recordatorio público: lee universos relevantes del tenant.
@@ -158,27 +185,19 @@ async function recipientsForRecordatorio(
), ARRAY[]::uuid[]) AS cliente_user_ids
`);
const auxiliares = r?.auxiliar_user_ids ?? [];
const supervisores = r?.supervisor_user_ids ?? [];
const clientes = r?.cliente_user_ids ?? [];
const byRole = new Map<string, NotificationRole>();
(r?.auxiliar_user_ids ?? []).forEach(id => byRole.set(id, 'auxiliar'));
(r?.supervisor_user_ids ?? []).forEach(id => byRole.set(id, 'supervisor'));
(r?.cliente_user_ids ?? []).forEach(id => byRole.set(id, 'cliente'));
// Owners siempre se consideran owner aunque también aparezcan como supervisor.
const owners = await getOwnerUserIds(tenantId);
owners.forEach(id => byRole.set(id, 'owner'));
// Regla del owner: clientes y auxiliares siempre. Si no hay auxiliares,
// agregar supervisores. Si owner es supervisor y no hay auxiliares,
// owner queda incluido vía la lista de supervisores.
const userIds = new Set<string>();
clientes.forEach(id => userIds.add(id));
auxiliares.forEach(id => userIds.add(id));
if (auxiliares.length === 0) {
supervisores.forEach(id => userIds.add(id));
// Solo si owner aparece como supervisor (intersección):
for (const ownerId of owners) {
if (supervisores.includes(ownerId)) userIds.add(ownerId);
}
}
const contacts = await getUserContacts([...userIds]);
return [...new Set(contacts.map(c => c.email))];
const contacts = await getUserContacts([...byRole.keys()]);
return contacts
.filter(c => byRole.has(c.userId))
.map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
}
// ────────────────────────────────────────────────────────────────────────
@@ -276,8 +295,10 @@ async function processAlertasContribuyente(
return { nuevas: 0, resueltas };
}
// Envía email batched a los responsables del contribuyente.
const recipients = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
// Envía email batched a los responsables del contribuyente, filtrando por
// preferencias de rol para alertas_nuevas.
const recipientsWithRole = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
const recipients = await filterRecipientsByRole(pool, 'alertas_nuevas', recipientsWithRole);
if (recipients.length === 0) {
console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`);
return { nuevas: nuevas.length, resueltas };
@@ -361,10 +382,11 @@ export async function processProximosRecordatorios(
for (const r of rows) {
try {
const recipients = await recipientsForRecordatorio(pool, tenantId, {
const recipientsWithRole = await recipientsForRecordatorio(pool, tenantId, {
creadoPor: r.creado_por,
privado: r.privado,
});
const recipients = await filterRecipientsByRole(pool, 'recordatorio_proximo', recipientsWithRole);
if (recipients.length === 0) {
console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`);
continue;

View File

@@ -3,7 +3,7 @@ import { prisma } from '../config/database.js';
import { emailService } from './email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js';
import { getContribuyenteEmailPreferences } from './notification-preferences.service.js';
import { filterRecipientsByRole, type RecipientWithRole } from './notification-preferences.service.js';
import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
/**
@@ -34,10 +34,7 @@ export async function notifyDocumentoSubido(params: {
// subject informativo ni supervisor — skip.
if (!contribuyenteId) return;
// Respeta preferencias de notificación del contribuyente. Si el user
// desactivó `documento_subido` para este contribuyente, no enviar.
const prefs = await getContribuyenteEmailPreferences(pool, contribuyenteId);
if (!prefs.documento_subido) return;
const { rows } = await pool.query<{
rfc: string;
@@ -54,14 +51,17 @@ export async function notifyDocumentoSubido(params: {
const contrib = rows[0];
// 2. Recipients. Owners primero; luego supervisor si aplica.
const owners = await getTenantOwnerEmails(tenantId);
const recipients = new Set<string>(owners);
const ownerEmails = await getTenantOwnerEmails(tenantId);
const recipientsWithRole: RecipientWithRole[] = ownerEmails.map(email => ({ email, role: 'owner' }));
if (contrib.supervisor_user_id) {
const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id);
if (supervisorEmail) recipients.add(supervisorEmail);
if (supervisorEmail) recipientsWithRole.push({ email: supervisorEmail, role: 'supervisor' });
}
// Filtra por preferencias de rol para documento_subido.
const recipients = new Set(await filterRecipientsByRole(pool, 'documento_subido', recipientsWithRole));
// Excluir al uploader: no notificarle su propia acción.
recipients.delete(subidoPor.toLowerCase());
recipients.delete(subidoPor);

View File

@@ -1,8 +1,9 @@
import { prisma } from '../../config/database.js';
import { prisma, tenantDb } from '../../config/database.js';
import * as mpService from './mercadopago.service.js';
import { emailService } from '../email/email.service.js';
import { auditLog } from '../../utils/audit.js';
import { getTenantOwnerEmail } from '../../utils/memberships.js';
import { getTenantOwnerEmail, getTenantOwnerEmails } from '../../utils/memberships.js';
import { filterRecipientsByRole } from '../notification-preferences.service.js';
import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared';
import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js';
import {
@@ -1191,7 +1192,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
{ status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } },
],
},
include: { tenant: { select: { nombre: true, rfc: true } } },
include: { tenant: { select: { nombre: true, rfc: true, databaseName: true } } },
});
let sent = 0;
@@ -1235,33 +1236,48 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
// Hay algo que avisar.
try {
const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
if (!ownerEmail) {
// Para suscripciones de pago, respeta preferencia 'subscription_expiring' del rol owner.
// Para trials siempre avisa al owner (no depende de preferencias de notificación informativa).
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
let emailsToNotify: string[] = [];
if (isTrialFlow) {
const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
if (ownerEmail) emailsToNotify = [ownerEmail];
} else {
const pool = await tenantDb.getPool(sub.tenantId, sub.tenant.databaseName);
const ownerEmails = await getTenantOwnerEmails(sub.tenantId);
const recipientsWithRole = ownerEmails.map(email => ({ email, role: 'owner' as const }));
emailsToNotify = await filterRecipientsByRole(pool, 'subscription_expiring', recipientsWithRole);
}
if (emailsToNotify.length === 0) {
skipped++;
continue;
}
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
if (isTrialFlow) {
if (bucket === 0) {
await emailService.sendTrialExpired(ownerEmail, {
nombre: sub.tenant.nombre,
despachoNombre: sub.tenant.nombre,
});
for (const ownerEmail of emailsToNotify) {
if (isTrialFlow) {
if (bucket === 0) {
await emailService.sendTrialExpired(ownerEmail, {
nombre: sub.tenant.nombre,
despachoNombre: sub.tenant.nombre,
});
} else {
await emailService.sendTrialReminder(ownerEmail, {
nombre: sub.tenant.nombre,
despachoNombre: sub.tenant.nombre,
diasRestantes: Math.max(0, daysUntil),
wizardCompleto: true,
});
}
} else {
await emailService.sendTrialReminder(ownerEmail, {
await emailService.sendSubscriptionExpiring(ownerEmail, {
nombre: sub.tenant.nombre,
despachoNombre: sub.tenant.nombre,
diasRestantes: Math.max(0, daysUntil),
wizardCompleto: true,
plan: sub.plan,
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
});
}
} else {
await emailService.sendSubscriptionExpiring(ownerEmail, {
nombre: sub.tenant.nombre,
plan: sub.plan,
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
});
}
await prisma.subscription.update({