Backend:
- Nuevo modelo Prisma ClientInvitation con token unico, expiracion
y estados (pending/accepted/expired).
- Migracion: 20260511213955_add_client_invitations
- Service client-invitations.service.ts: crear invitacion,
validar token, registrar desde invitacion (reutiliza logica
de creacion de tenant + usuario de despacho.service).
- Controller + routes: POST /invitations/client (admin),
GET /invitations/client/validate/:token (publico),
POST /invitations/client/register/:token (publico),
GET /invitations/client (admin).
- Email template client-invitation.ts con link a
/invitacion/registro/{token}.
- Agregado sendClientInvitation a email.service.
Frontend:
- Pagina /invitacion/registro/[token] para que el invitado
complete registro (nombre, password, despacho, RFC, perfil).
- Pagina /admin/invitar-cliente para que admin global envie
invitaciones y vea el historial.
- Hooks useCreateInvitation, useValidateInvitationToken,
useRegisterFromInvitation, useClientInvitations.
- API client lib/api/client-invitations.ts.
Infra:
- PM2 ecosystem.config.js: usa node --import tsx con
kill_timeout aumentado a 15s para evitar EADDRINUSE.
- React Query retry=2 con delay exponencial para resiliencia.
Refs: docs/CAMBIOS-2026-05-09.md
805 lines
31 KiB
Plaintext
805 lines
31 KiB
Plaintext
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
model Tenant {
|
|
id String @id @default(uuid())
|
|
nombre String
|
|
rfc String @unique
|
|
plan Plan @default(trial)
|
|
databaseName String @unique @map("database_name")
|
|
active Boolean @default(true)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
expiresAt DateTime? @map("expires_at")
|
|
// Prueba gratuita: si está set y en el futuro, el tenant está en trial.
|
|
// Se consume una sola vez por tenant (al activarla, nunca se regenera).
|
|
trialEndsAt DateTime? @map("trial_ends_at")
|
|
|
|
facturapiOrgId String? @map("facturapi_org_id")
|
|
/// Live Secret Key cifrada (AES-256-GCM, misma derivación FIEL_ENCRYPTION_KEY).
|
|
/// Cacheada tras primer PUT idempotente a /v2/organizations/{id}/apikeys/live.
|
|
facturapiOrgKeyEnc Bytes? @map("facturapi_org_key_enc")
|
|
facturapiOrgKeyIv Bytes? @map("facturapi_org_key_iv")
|
|
facturapiOrgKeyTag Bytes? @map("facturapi_org_key_tag")
|
|
|
|
// Domicilio fiscal
|
|
codigoPostal String? @map("codigo_postal") @db.VarChar(5)
|
|
calle String? @db.VarChar(255)
|
|
numExterior String? @map("num_exterior") @db.VarChar(20)
|
|
numInterior String? @map("num_interior") @db.VarChar(20)
|
|
colonia String? @db.VarChar(255)
|
|
ciudad String? @db.VarChar(100)
|
|
municipio String? @db.VarChar(100)
|
|
estado String? @db.VarChar(100)
|
|
telefono String? @db.VarChar(20)
|
|
|
|
// Preferencias de auto-facturación de pagos de suscripción.
|
|
// Default: facturar con datos del cliente cuando hay CSF disponible.
|
|
// Si `factPreferencia='publico_general'` siempre va a XAXX010101000.
|
|
factPreferencia String @default("mis_datos") @map("fact_preferencia") @db.VarChar(20)
|
|
// Uso CFDI default cuando se factura con datos del cliente.
|
|
// G03 = Gastos en general (más común para SaaS).
|
|
factUsoCfdi String @default("G03") @map("fact_uso_cfdi") @db.VarChar(5)
|
|
// Si el tenant tiene múltiples regímenes activos, cuál usar para factura.
|
|
// Null = usar el primero activo (heurística por createdAt).
|
|
factRegimenPreferido String? @map("fact_regimen_preferido") @db.VarChar(3)
|
|
|
|
// === Despacho fields ===
|
|
verticalProfile VerticalProfile? @map("vertical_profile")
|
|
dbMode DbMode? @map("db_mode")
|
|
dbConnectionEnc String? @map("db_connection_enc")
|
|
dbConnectionIv String? @map("db_connection_iv")
|
|
dbSchemaVersion Int @default(0) @map("db_schema_version")
|
|
connectorTokenEnc String? @map("connector_token_enc")
|
|
connectorTunnelHostname String? @map("connector_tunnel_hostname")
|
|
connectorLastSeen DateTime? @map("connector_last_seen")
|
|
connectorVersion String? @map("connector_version") @db.VarChar(20)
|
|
|
|
memberships TenantMembership[]
|
|
fielCredential FielCredential?
|
|
satSyncJobs SatSyncJob[]
|
|
subscriptions Subscription[]
|
|
payments Payment[]
|
|
regimenesIgnorados TenantRegimenIgnorado[]
|
|
regimenesActivos TenantRegimenActivo[]
|
|
coeficientes CoeficienteUtilidad[]
|
|
timbreSuscripcion TimbreSuscripcion?
|
|
timbrePaquetes TimbrePaquete[]
|
|
connectorHeartbeats ConnectorHeartbeat[]
|
|
|
|
@@map("tenants")
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(uuid())
|
|
email String @unique
|
|
passwordHash String @map("password_hash")
|
|
nombre String
|
|
active Boolean @default(true)
|
|
lastLogin DateTime? @map("last_login")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
// Contador para invalidar sesiones masivamente. Al incrementar, todos los
|
|
// JWT emitidos antes (con tokenVersion menor) quedan rechazados en el
|
|
// siguiente request. Se incrementa en: password change, password reset,
|
|
// logout-all. Default 0 para compat con users pre-rollout.
|
|
tokenVersion Int @default(0) @map("token_version")
|
|
// Último tenant que el user activó (via switch-tenant). Se usa para resolver
|
|
// el "tenant activo al login". Si es null, el login cae al primer membership
|
|
// por joinedAt. Se actualiza en cada switch.
|
|
lastTenantId String? @map("last_tenant_id")
|
|
// Cuenta sesiones (login exitoso, NO refresh). Usado para auto-dismiss del
|
|
// onboarding tras N logins. Default 0 → users pre-rollout siguen viendo el
|
|
// onboarding hasta acumular logins post-deploy.
|
|
loginCount Int @default(0) @map("login_count")
|
|
// Marca explícita de que el onboarding ya no debe mostrarse. Se setea cuando
|
|
// el user completa todos los pasos requeridos o desde el endpoint de dismiss.
|
|
onboardingDismissedAt DateTime? @map("onboarding_dismissed_at")
|
|
|
|
memberships TenantMembership[]
|
|
platformRoles UserPlatformRole[]
|
|
passwordResetTokens PasswordResetToken[]
|
|
|
|
@@map("users")
|
|
}
|
|
|
|
/// Relación many-to-many entre User y Tenant. Permite que un mismo user (p.ej.
|
|
/// un dueño/contador) pertenezca a varios tenants con distintos roles. Esta
|
|
/// tabla es la fuente de verdad del "¿a qué tenants tiene acceso este user?".
|
|
///
|
|
/// Durante la transición, `User.tenantId` y `User.rolId` se mantienen como
|
|
/// "default tenant" para login UX. El backfill inicial crea 1 membership por
|
|
/// user basado en esos campos. Cuando se agregue la UI de multi-tenant, los
|
|
/// nuevos accesos solo tocarán esta tabla.
|
|
model TenantMembership {
|
|
id Int @id @default(autoincrement())
|
|
userId String @map("user_id")
|
|
tenantId String @map("tenant_id")
|
|
rolId Int @map("rol_id")
|
|
isOwner Boolean @default(false) @map("is_owner")
|
|
active Boolean @default(true)
|
|
joinedAt DateTime @default(now()) @map("joined_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
rol Rol @relation(fields: [rolId], references: [id])
|
|
|
|
@@unique([userId, tenantId])
|
|
@@index([userId, active])
|
|
@@index([tenantId, active])
|
|
@@map("tenant_memberships")
|
|
}
|
|
|
|
model Rol {
|
|
id Int @id @default(autoincrement())
|
|
nombre String @unique @db.VarChar(20)
|
|
descripcion String?
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
memberships TenantMembership[]
|
|
|
|
@@map("roles")
|
|
}
|
|
|
|
model RefreshToken {
|
|
id String @id @default(uuid())
|
|
userId String @map("user_id")
|
|
token String @unique
|
|
expiresAt DateTime @map("expires_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
@@map("refresh_tokens")
|
|
}
|
|
|
|
/// Tokens para recuperación de contraseña. Expiran en 1 hora, son single-use
|
|
/// (se marca `usedAt` al consumir). Al completar reset se invalidan todos los
|
|
/// refresh tokens del user — cierra todas sus sesiones forzando re-login.
|
|
model PasswordResetToken {
|
|
id String @id @default(uuid())
|
|
userId String @map("user_id")
|
|
token String @unique
|
|
expiresAt DateTime @map("expires_at")
|
|
usedAt DateTime? @map("used_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@index([expiresAt])
|
|
@@map("password_reset_tokens")
|
|
}
|
|
|
|
enum Plan {
|
|
trial
|
|
custom
|
|
business_control
|
|
business_cloud
|
|
mi_empresa
|
|
mi_empresa_plus
|
|
}
|
|
|
|
enum VerticalProfile {
|
|
CONTABLE
|
|
JURIDICO
|
|
ARQUITECTURA
|
|
}
|
|
|
|
enum DbMode {
|
|
BYO
|
|
MANAGED
|
|
}
|
|
|
|
|
|
// ============================================
|
|
// Catálogo de Regímenes Fiscales SAT
|
|
// ============================================
|
|
|
|
model Regimen {
|
|
id Int @id @default(autoincrement())
|
|
clave String @unique @db.VarChar(3)
|
|
descripcion String
|
|
tipoPersona String @map("tipo_persona") @db.VarChar(20) // fisica, moral, ambos
|
|
activo Boolean @default(true)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
tenantIgnorados TenantRegimenIgnorado[]
|
|
tenantActivos TenantRegimenActivo[]
|
|
|
|
@@map("regimenes")
|
|
}
|
|
|
|
model TenantRegimenIgnorado {
|
|
id Int @id @default(autoincrement())
|
|
tenantId String @map("tenant_id")
|
|
regimenId Int @map("regimen_id")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([tenantId, regimenId])
|
|
@@map("tenant_regimenes_ignorados")
|
|
}
|
|
|
|
model TenantRegimenActivo {
|
|
id Int @id @default(autoincrement())
|
|
tenantId String @map("tenant_id")
|
|
regimenId Int @map("regimen_id")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([tenantId, regimenId])
|
|
@@map("tenant_regimenes_activos")
|
|
}
|
|
|
|
// ============================================
|
|
// Catálogo de Eventos Fiscales
|
|
// ============================================
|
|
|
|
model EventoFiscalCatalogo {
|
|
id Int @id @default(autoincrement())
|
|
titulo String
|
|
descripcion String?
|
|
tipo String @db.VarChar(20) // declaracion, pago, obligacion, informativa
|
|
diaBase Int @map("dia_base") // día del mes (17, 3, 31, etc.)
|
|
mesRelativo Int @default(1) @map("mes_relativo") // 1=mes posterior, 2=segundo mes posterior, 0=mes fijo
|
|
mesFijo Int? @map("mes_fijo") // para anuales: 2=feb, 3=mar, 4=abr
|
|
recurrencia String @default("mensual") @db.VarChar(20) // mensual, anual
|
|
usaExtensionRfc Boolean @default(false) @map("usa_extension_rfc")
|
|
regimenes String @default("todos") // 'todos' o CSV de claves: '601,603,612'
|
|
condicion String? @db.VarChar(50) // null, 'tiene_nomina', 'ingresos_4m'
|
|
activo Boolean @default(true)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
@@map("eventos_fiscales_catalogo")
|
|
}
|
|
|
|
/// Lista negra SAT (Art. 69-B CFF)
|
|
model ListaNegra {
|
|
id Int @id @default(autoincrement())
|
|
rfc String @unique @db.VarChar(13)
|
|
nombre String
|
|
situacion String @db.VarChar(30) // Definitivo, Presunto, Desvirtuado, Sentencia Favorable
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
@@index([rfc])
|
|
@@map("lista_negra")
|
|
}
|
|
|
|
/// Días inhábiles fiscales (festivos oficiales de México)
|
|
model DiaInhabil {
|
|
id Int @id @default(autoincrement())
|
|
fecha DateTime @unique @db.Date
|
|
nombre String
|
|
|
|
@@map("dias_inhabiles")
|
|
}
|
|
|
|
// ============================================
|
|
// ISR Tables
|
|
// ============================================
|
|
|
|
/// Tasas RESICO (Art. 113-E) - tasa plana por bracket mensual
|
|
model IsrResicoTasa {
|
|
id Int @id @default(autoincrement())
|
|
anio Int @map("anio")
|
|
montoMaximo Decimal @map("monto_maximo") @db.Decimal(18, 2)
|
|
porcentaje Decimal @db.Decimal(5, 2)
|
|
|
|
@@unique([anio, montoMaximo])
|
|
@@map("isr_resico_tasas")
|
|
}
|
|
|
|
/// Tarifa ISR progresiva (Art. 96) - mensual
|
|
model IsrTarifa {
|
|
id Int @id @default(autoincrement())
|
|
anio Int @map("anio")
|
|
limiteInferior Decimal @map("limite_inferior") @db.Decimal(18, 2)
|
|
limiteSuperior Decimal? @map("limite_superior") @db.Decimal(18, 2)
|
|
cuotaFija Decimal @map("cuota_fija") @db.Decimal(18, 2)
|
|
porcentajeExcedente Decimal @map("porcentaje_excedente") @db.Decimal(5, 2)
|
|
|
|
@@unique([anio, limiteInferior])
|
|
@@map("isr_tarifas")
|
|
}
|
|
|
|
/// Coeficiente de utilidad por tenant/año (no se sobrescribe)
|
|
model CoeficienteUtilidad {
|
|
id Int @id @default(autoincrement())
|
|
tenantId String @map("tenant_id")
|
|
anio Int @map("anio")
|
|
coeficiente Decimal @db.Decimal(10, 4)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([tenantId, anio])
|
|
@@map("coeficiente_utilidad")
|
|
}
|
|
|
|
// ============================================
|
|
// SAT Sync Models
|
|
// ============================================
|
|
|
|
model FielCredential {
|
|
id String @id @default(uuid())
|
|
tenantId String @unique @map("tenant_id")
|
|
rfc String @db.VarChar(13)
|
|
cerData Bytes @map("cer_data")
|
|
keyData Bytes @map("key_data")
|
|
keyPasswordEncrypted Bytes @map("key_password_encrypted")
|
|
cerIv Bytes @map("cer_iv")
|
|
cerTag Bytes @map("cer_tag")
|
|
keyIv Bytes @map("key_iv")
|
|
keyTag Bytes @map("key_tag")
|
|
passwordIv Bytes @map("password_iv")
|
|
passwordTag Bytes @map("password_tag")
|
|
serialNumber String? @map("serial_number") @db.VarChar(50)
|
|
validFrom DateTime @map("valid_from")
|
|
validUntil DateTime @map("valid_until")
|
|
isActive Boolean @default(true) @map("is_active")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("fiel_credentials")
|
|
}
|
|
|
|
model Subscription {
|
|
id String @id @default(uuid())
|
|
tenantId String @map("tenant_id")
|
|
plan Plan
|
|
mpPreapprovalId String? @map("mp_preapproval_id")
|
|
status String @default("pending")
|
|
amount Decimal @db.Decimal(10, 2)
|
|
frequency String @default("monthly")
|
|
currentPeriodStart DateTime? @map("current_period_start")
|
|
currentPeriodEnd DateTime? @map("current_period_end")
|
|
// Cambio programado al próximo período (downgrades y cambios de frecuencia)
|
|
pendingPlan Plan? @map("pending_plan")
|
|
pendingFrequency String? @map("pending_frequency")
|
|
pendingEffectiveAt DateTime? @map("pending_effective_at")
|
|
// Upgrade inmediato en curso: preference MP esperando cobro prorateado.
|
|
// Cuando el webhook confirma el pago, se aplica el plan nuevo y se limpian estos campos.
|
|
upgradePreferenceId String? @map("upgrade_preference_id")
|
|
upgradeTargetPlan Plan? @map("upgrade_target_plan")
|
|
upgradeTargetAmount Decimal? @db.Decimal(10, 2) @map("upgrade_target_amount")
|
|
// Idempotencia del cron de aviso pre-vencimiento. Guarda el bucket de días
|
|
// que ya se notificó (7, 3, 1 ó 0) para no spamear al owner si el cron corre
|
|
// dos veces el mismo día. Se resetea cuando se renueva la suscripción o
|
|
// arranca un período nuevo.
|
|
lastReminderDay Int? @map("last_reminder_day")
|
|
lastReminderSentAt DateTime? @map("last_reminder_sent_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
payments Payment[]
|
|
addons SubscriptionAddon[]
|
|
|
|
@@index([tenantId])
|
|
@@index([status])
|
|
@@index([pendingEffectiveAt])
|
|
@@map("subscriptions")
|
|
}
|
|
|
|
model SubscriptionAddon {
|
|
id String @id @default(uuid())
|
|
subscriptionId String @map("subscription_id")
|
|
planAddonCatalogoId String @map("plan_addon_catalogo_id")
|
|
/// UUID del contribuyente (entidad_id en tenant BD) cuando el add-on
|
|
/// aplica a un RFC específico. NULL para add-ons a nivel tenant (módulos
|
|
/// globales, +RFCs, +timbres). Sin FK porque contribuyente vive en BD tenant.
|
|
contribuyenteId String? @map("contribuyente_id")
|
|
mpPreapprovalId String? @map("mp_preapproval_id")
|
|
status String @default("pending")
|
|
quantity Int @default(1)
|
|
amount Decimal @db.Decimal(10, 2)
|
|
currentPeriodStart DateTime? @map("current_period_start")
|
|
currentPeriodEnd DateTime? @map("current_period_end")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
subscription Subscription @relation(fields: [subscriptionId], references: [id])
|
|
planAddonCatalogo PlanAddonCatalogo @relation(fields: [planAddonCatalogoId], references: [id])
|
|
|
|
/// Sin UNIQUE compuesto: la validación de "un solo add-on activo por
|
|
/// (subscription, addon, contribuyente?)" queda a nivel aplicación
|
|
/// (findFirst en subscribeAddon), porque Postgres trata NULL!=NULL y no
|
|
/// hay forma trivial de enforcar unicidad con contribuyenteId opcional.
|
|
@@index([subscriptionId])
|
|
@@index([subscriptionId, contribuyenteId])
|
|
@@map("subscription_addons")
|
|
}
|
|
|
|
/// Roles de plataforma (staff interno de Horux 360) — ortogonales al rol per-tenant.
|
|
/// Un user puede tener 0, 1 o varios roles. `platform_admin` es el superrol.
|
|
/// Ver `docs/plans/2026-04-14-platform-admin-roles.md`.
|
|
enum PlatformRole {
|
|
platform_admin // Todo: precios, clientes, facturas, suscripciones, gestión de staff
|
|
platform_ti // Mismos permisos que admin (equipo de TI / tech ops). Diferencia solo en trazabilidad.
|
|
platform_support // Ver todos los tenants, resolver tickets, NO facturación/precios
|
|
platform_sales // Crear/editar tenants (onboarding), ver suscripciones, NO precios
|
|
platform_finance // Ver payments, emitir facturas manuales, editar precios, reportes fiscales
|
|
}
|
|
|
|
model UserPlatformRole {
|
|
id Int @id @default(autoincrement())
|
|
userId String @map("user_id")
|
|
role PlatformRole
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
createdBy String? @map("created_by") // User.id de quien asignó (audit trail)
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, role])
|
|
@@index([role])
|
|
@@map("user_platform_roles")
|
|
}
|
|
|
|
/// Registro de acciones críticas para auditoría (SAT compliance, forense, disputas).
|
|
/// Se instrumenta vía `utils/audit.ts` con helper fire-and-forget — un fallo al
|
|
/// escribir aquí NUNCA debe romper la acción principal.
|
|
model AuditLog {
|
|
id String @id @default(uuid())
|
|
userId String? @map("user_id")
|
|
tenantId String? @map("tenant_id")
|
|
action String @db.VarChar(64) // "price.updated", "subscription.cancelled", etc.
|
|
entityType String? @map("entity_type") @db.VarChar(32)
|
|
entityId String? @map("entity_id")
|
|
metadata Json? // before/after, ip, userAgent, contexto
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
@@index([userId, createdAt])
|
|
@@index([tenantId, createdAt])
|
|
@@index([action, createdAt])
|
|
@@index([entityType, entityId])
|
|
@@map("audit_log")
|
|
}
|
|
|
|
/// Padrón persistente de RFCs que ya consumieron su prueba gratuita de 30 días.
|
|
/// Sobrevive al ciclo de vida del Tenant (si se borra/recrea, el RFC sigue aquí),
|
|
/// bloqueando el abuso de "registro nuevo con el mismo RFC para otro trial".
|
|
model TrialUsage {
|
|
id Int @id @default(autoincrement())
|
|
rfc String @unique @db.VarChar(13)
|
|
tenantId String? @map("tenant_id") // Tenant que consumió (null si el tenant se borró después)
|
|
startedAt DateTime @default(now()) @map("started_at")
|
|
|
|
@@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")
|
|
}
|
|
|
|
/// Invitaciones para nuevos clientes enviadas por admin global.
|
|
/// El destinatario recibe un email con un link para completar su registro.
|
|
model ClientInvitation {
|
|
id String @id @default(uuid())
|
|
email String
|
|
invitedBy String @map("invited_by")
|
|
nombreDespacho String? @map("nombre_despacho")
|
|
rfc String?
|
|
status String @default("pending") // pending | accepted | expired
|
|
token String @unique
|
|
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([token])
|
|
@@index([status])
|
|
@@index([email])
|
|
@@map("client_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.
|
|
/// Incluye filas para `trial` y `custom` (sin precios — null).
|
|
model DespachoPlanPrice {
|
|
plan String @id // trial | custom | mi_empresa | mi_empresa_plus | business_control | business_cloud
|
|
nombre String @db.VarChar(50)
|
|
monthly Decimal? @db.Decimal(10, 2)
|
|
firstYear Decimal? @db.Decimal(10, 2) @map("first_year")
|
|
renewal Decimal? @db.Decimal(10, 2)
|
|
permiteMonthly Boolean @default(false) @map("permite_monthly")
|
|
/// Limits del plan. -1 = ilimitado donde aplique (maxUsers).
|
|
maxRfcs Int @map("max_rfcs")
|
|
maxUsers Int @map("max_users")
|
|
timbresIncluidosMes Int @default(0) @map("timbres_incluidos_mes")
|
|
dbMode DbMode @map("db_mode")
|
|
permiteServidorBackup Boolean @default(false) @map("permite_servidor_backup")
|
|
/// Habilita SAT incremental (3 syncs/día adicionales al daily). Mi Empresa +,
|
|
/// Business Control y Enterprise lo tienen activo por default; planes
|
|
/// inferiores se quedan solo con el daily de las 03:00.
|
|
permiteSatIncremental Boolean @default(false) @map("permite_sat_incremental")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@map("despacho_plan_prices")
|
|
}
|
|
|
|
model PlanAddonCatalogo {
|
|
id String @id @default(uuid())
|
|
codename String @unique @db.VarChar(50)
|
|
nombre String
|
|
verticalProfile VerticalProfile?
|
|
precio Decimal @db.Decimal(10, 2)
|
|
frecuencia String @db.VarChar(10)
|
|
delta Json
|
|
active Boolean @default(true)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
subscriptionAddons SubscriptionAddon[]
|
|
|
|
@@map("plan_addon_catalogo")
|
|
}
|
|
|
|
model ConnectorHeartbeat {
|
|
id String @id @default(uuid())
|
|
tenantId String @map("tenant_id")
|
|
timestamp DateTime @default(now())
|
|
latencyMs Int @map("latency_ms")
|
|
version String @db.VarChar(20)
|
|
pgVersion String? @map("pg_version") @db.VarChar(50)
|
|
status String @db.VarChar(20)
|
|
errorMsg String? @map("error_msg")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([tenantId, timestamp])
|
|
@@map("connector_heartbeats")
|
|
}
|
|
|
|
enum PaymentKind {
|
|
subscription
|
|
timbres_pack
|
|
}
|
|
|
|
model Payment {
|
|
id String @id @default(uuid())
|
|
tenantId String @map("tenant_id")
|
|
subscriptionId String? @map("subscription_id")
|
|
mpPaymentId String? @map("mp_payment_id")
|
|
amount Decimal @db.Decimal(10, 2)
|
|
status String @default("pending")
|
|
paymentMethod String? @map("payment_method")
|
|
paidAt DateTime? @map("paid_at")
|
|
// Tipo de pago. subscription = cobro mensual/anual del plan.
|
|
// timbres_pack = compra de paquete de timbres adicionales.
|
|
kind PaymentKind @default(subscription)
|
|
// ID de la factura emitida auto por Facturapi. Null si no se facturó:
|
|
// primer pago (manual), trial sin monto, o fallo al emitir.
|
|
facturapiInvoiceId String? @map("facturapi_invoice_id")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
|
timbrePaquete TimbrePaquete?
|
|
|
|
@@index([tenantId])
|
|
@@index([subscriptionId])
|
|
@@map("payments")
|
|
}
|
|
|
|
/// Catálogo de paquetes de timbres adicionales vendibles. Precios editables
|
|
/// desde panel admin. Los 3 defaults (100/$200, 1000/$1400, 10000/$8600) se
|
|
/// insertan en seed idempotente.
|
|
model TimbrePaqueteCatalogo {
|
|
id Int @id @default(autoincrement())
|
|
cantidad Int @unique // 100, 1000, 10000
|
|
precio Decimal @db.Decimal(10, 2)
|
|
active Boolean @default(true)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@map("timbre_paquetes_catalogo")
|
|
}
|
|
|
|
/// Compra individual de timbres adicionales. Los timbres del plan (mensuales)
|
|
/// se rastrean en TimbreSuscripcion — esto es SOLO para los extras pagados.
|
|
/// Vigencia 1 año desde `adquiridoEn`. El orden de consumo es FIFO por
|
|
/// `expiraEn` (menor primero) para no desperdiciar paquetes próximos a vencer.
|
|
model TimbrePaquete {
|
|
id Int @id @default(autoincrement())
|
|
tenantId String @map("tenant_id")
|
|
paymentId String? @unique @map("payment_id") // Payment que lo compró; null si admin grant manual
|
|
cantidad Int // cuántos timbres tenía originalmente
|
|
usados Int @default(0)
|
|
precio Decimal @db.Decimal(10, 2) // precio pagado (historial, no cambia si el catálogo cambia)
|
|
adquiridoEn DateTime @default(now()) @map("adquirido_en")
|
|
expiraEn DateTime @map("expira_en") // adquiridoEn + 1 año
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
payment Payment? @relation(fields: [paymentId], references: [id])
|
|
|
|
@@index([tenantId, expiraEn])
|
|
@@map("timbre_paquetes")
|
|
}
|
|
|
|
model SatSyncJob {
|
|
id String @id @default(uuid())
|
|
tenantId String @map("tenant_id")
|
|
contribuyenteId String? @map("contribuyente_id")
|
|
type SatSyncType
|
|
status SatSyncStatus @default(pending)
|
|
dateFrom DateTime @map("date_from") @db.Date
|
|
dateTo DateTime @map("date_to") @db.Date
|
|
cfdiType CfdiSyncType? @map("cfdi_type")
|
|
satRequestId String? @map("sat_request_id") @db.VarChar(50)
|
|
// Mapa { kindKey: requestId } de TODOS los requests creados durante el job.
|
|
// Permite que retries reusen requestIds previos en lugar de quemar cuota
|
|
// del SAT creando nuevos. kindKey = `${requestType}-${tipoCfdi}-${from}-${to}`.
|
|
satRequestIds Json @default("{}") @map("sat_request_ids")
|
|
satPackageIds String[] @map("sat_package_ids")
|
|
cfdisFound Int @default(0) @map("cfdis_found")
|
|
cfdisDownloaded Int @default(0) @map("cfdis_downloaded")
|
|
cfdisInserted Int @default(0) @map("cfdis_inserted")
|
|
cfdisUpdated Int @default(0) @map("cfdis_updated")
|
|
progressPercent Int @default(0) @map("progress_percent")
|
|
errorMessage String? @map("error_message")
|
|
startedAt DateTime? @map("started_at")
|
|
completedAt DateTime? @map("completed_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
retryCount Int @default(0) @map("retry_count")
|
|
nextRetryAt DateTime? @map("next_retry_at")
|
|
// True cuando el job es `initial` con rango de fechas personalizado por el
|
|
// usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del
|
|
// bootstrap puro. Daily/incremental ignoran este campo.
|
|
isCustomRange Boolean @default(false) @map("is_custom_range")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([tenantId])
|
|
@@index([status])
|
|
@@index([status, nextRetryAt])
|
|
@@map("sat_sync_jobs")
|
|
}
|
|
|
|
enum SatSyncType {
|
|
initial
|
|
daily
|
|
incremental
|
|
}
|
|
|
|
enum SatSyncStatus {
|
|
pending
|
|
running
|
|
completed
|
|
failed
|
|
}
|
|
|
|
enum CfdiSyncType {
|
|
emitidos
|
|
recibidos
|
|
}
|
|
|
|
// ============================================
|
|
// Catálogos SAT para Facturación (CFDI 4.0)
|
|
// ============================================
|
|
|
|
model CatFormaPago {
|
|
id Int @id @default(autoincrement())
|
|
clave String @unique @db.VarChar(2)
|
|
descripcion String
|
|
|
|
@@map("cat_forma_pago")
|
|
}
|
|
|
|
model CatMetodoPago {
|
|
id Int @id @default(autoincrement())
|
|
clave String @unique @db.VarChar(3)
|
|
descripcion String
|
|
|
|
@@map("cat_metodo_pago")
|
|
}
|
|
|
|
model CatUsoCfdi {
|
|
id Int @id @default(autoincrement())
|
|
clave String @unique @db.VarChar(4)
|
|
descripcion String
|
|
personaFisica Boolean @default(true) @map("persona_fisica")
|
|
personaMoral Boolean @default(true) @map("persona_moral")
|
|
|
|
@@map("cat_uso_cfdi")
|
|
}
|
|
|
|
model CatMoneda {
|
|
id Int @id @default(autoincrement())
|
|
clave String @unique @db.VarChar(3)
|
|
descripcion String
|
|
decimales Int @default(2)
|
|
|
|
@@map("cat_moneda")
|
|
}
|
|
|
|
model CatClaveUnidad {
|
|
id Int @id @default(autoincrement())
|
|
clave String @unique @db.VarChar(10)
|
|
descripcion String
|
|
|
|
@@map("cat_clave_unidad")
|
|
}
|
|
|
|
model CatClaveProdServ {
|
|
id Int @id @default(autoincrement())
|
|
clave String @unique @db.VarChar(8)
|
|
descripcion String
|
|
|
|
@@index([descripcion])
|
|
@@map("cat_clave_prod_serv")
|
|
}
|
|
|
|
model CatObjetoImp {
|
|
id Int @id @default(autoincrement())
|
|
clave String @unique @db.VarChar(2)
|
|
descripcion String
|
|
|
|
@@map("cat_objeto_imp")
|
|
}
|
|
|
|
model CatTipoRelacion {
|
|
id Int @id @default(autoincrement())
|
|
clave String @unique @db.VarChar(2)
|
|
descripcion String
|
|
|
|
@@map("cat_tipo_relacion")
|
|
}
|
|
|
|
model CatExportacion {
|
|
id Int @id @default(autoincrement())
|
|
clave String @unique @db.VarChar(2)
|
|
descripcion String
|
|
|
|
@@map("cat_exportacion")
|
|
}
|
|
|
|
// ============================================
|
|
// Gestión de Timbres Facturapi
|
|
// ============================================
|
|
|
|
model TimbreSuscripcion {
|
|
id Int @id @default(autoincrement())
|
|
tenantId String @unique @map("tenant_id")
|
|
tipo String @db.VarChar(10) // mensual, anual
|
|
timbresLimite Int @map("timbres_limite") // 50 o 600
|
|
timbresUsados Int @default(0) @map("timbres_usados")
|
|
periodoInicio DateTime @map("periodo_inicio") @db.Date
|
|
periodoFin DateTime @map("periodo_fin") @db.Date
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("timbre_suscripciones")
|
|
}
|