Files
HoruxDespachosNuevo/docs/plans/2026-04-14-trial-abuse-prevention.md

5.6 KiB

Prevención de abuso de prueba gratuita por RFC

Resumen

Bloqueo persistente de startTrial por RFC: cada RFC tiene derecho a una sola prueba gratuita de 30 días en toda la vida del sistema, independiente del ciclo de vida del Tenant (borrado, soft-delete, recreación).

Motivación

Vector de abuso detectado: alguien registra empresa (Tenant A con RFC X), consume trial, cancela o borra. Crea nueva empresa (Tenant B intenta mismo RFC X). Sin este fix, si el Tenant A desapareciera, el flag tenant.trialEndsAt también, y el Tenant B obtendría otro trial gratis.

Escenarios que esto bloquea:

  • Borrado manual + recreación del tenant con mismo RFC
  • Soft-delete (active=false) + creación de otro con mismo RFC (si ese flujo se permitiera)
  • Race condition: dos requests simultáneas de startTrial para el mismo RFC

Escenarios que esto NO bloquea (fuera de scope):

  • Persona con varias empresas legales (RFCs distintos) — cada RFC legítimamente recibe su trial
  • Mismo RFC + diferente email/admin — es el mismo RFC, mismo trial
  • Detección de "misma persona física detrás de múltiples RFCs" — requiere KYC

Mecanismo

Tabla nueva en BD central:

model TrialUsage {
  id        Int      @id @default(autoincrement())
  rfc       String   @unique @db.VarChar(13)
  tenantId  String?  @map("tenant_id")
  startedAt DateTime @default(now()) @map("started_at")
  @@map("trial_usages")
}

Cuando startTrial(tenantId, plan, frequency) corre:

  1. Carga tenant.rfc, lo normaliza a uppercase
  2. SELECT FROM trial_usages WHERE rfc = <normalized> — si existe, 400 con mensaje explícito
  3. Dentro de la transacción que crea la Subscription:
    • UPDATE tenants SET trialEndsAt=..., plan=...
    • INSERT INTO trial_usages (rfc, tenantId, startedAt) — unique constraint previene race
    • INSERT INTO subscriptions (...)

Si cualquier paso falla, la transacción hace rollback y el padrón queda sin la marca (consistente con la no-creación del trial).

Por qué @unique en rfc

  • Race protection: dos procesos podrían leer trial_usages simultáneamente y ambos ver "no existe" → ambos intentarían insert. El constraint hace que la segunda inserción falle con violación de unique → propaga como error al request, usuario ve que alguien más está procesando.
  • Invariante de datos: imposible tener dos rows para el mismo RFC. Simplifica la lógica de lectura (solo un resultado posible).

Normalización a uppercase

HTS240708LJA y hts240708lja son el mismo RFC legalmente. Guardando todo uppercase en trial_usages.rfc:

  • Evita que un bug de normalización cree dos marcas para el mismo RFC real
  • El query de check usa tenant.rfc.toUpperCase() antes de findUnique
  • El insert también normaliza

Backfill de tenants existentes

El seed ejecuta idempotentemente:

INSERT INTO trial_usages (rfc, tenant_id, started_at)
SELECT UPPER(rfc), id, COALESCE(created_at, NOW())
FROM tenants
WHERE trial_ends_at IS NOT NULL
ON CONFLICT (rfc) DO NOTHING

Tenants que ya consumieron trial antes de esta feature quedan registrados. Re-correr seed no duplica (ON CONFLICT DO NOTHING).

Tolerancia al borrado de tenant

El campo tenantId en trial_usages es nullable a propósito:

  • Si el tenant se hard-delete (por ejemplo, GDPR request), el RFC sigue bloqueado aunque tenant_id quede huérfano (null si agregas FK con ON DELETE SET NULL; hoy sin FK para máxima resiliencia)
  • Histórico: útil saber qué tenant original consumió el trial (traceability) aunque ya no exista

Mensajes de error

Cuando el RFC ya consumió trial:

"El RFC HTS240708LJA ya consumió su prueba gratuita. Cada RFC tiene derecho
a una sola prueba de 30 días. Contrata un plan para continuar."

El frontend lo propaga tal cual al usuario vía err.response.data.message.

Archivos

  • apps/api/prisma/schema.prisma — modelo TrialUsage
  • apps/api/prisma/seed.ts — backfill idempotente post-rename-roles
  • apps/api/src/services/payment/subscription.service.ts — gate + insert en startTrial

Deploy

cd apps/api
pnpm prisma db push   # crea trial_usages
pnpm db:seed          # idempotente — renombra roles, rellena plan_prices Y hace backfill trial_usages

Decisiones descartadas

Email unique dedupe

Tentación: bloquear también por email del admin.

Por qué no: emails son fáciles de generar (aliasing con Gmail +tag). RFC es legalmente único por empresa. Además, bloquear por email castiga casos legítimos (admin que rotó su email).

Device fingerprinting

Tentación: rastrear navegador/IP para detectar mismo usuario creando múltiples tenants.

Por qué no: falsos positivos altos (oficina con 50 empleados compartiendo IP). Requiere stack adicional (fingerprint library, GDPR compliance). Scope muy distinto al fix simple por RFC.

Foreign key tenantId → Tenant.id ON DELETE SET NULL

Tentación: referencial integrity explícita.

Por qué no: el punto de trial_usages es sobrevivir al borrado del tenant. FK sin ON DELETE SET NULL bloquearía el borrado del tenant; con SET NULL funcionaría pero agrega complejidad de migración. Por ahora sin FK — el RFC es el identificador funcional.

Pendientes

  1. UI admin global para ver/resetear trial_usages — caso de soporte: cliente legítimo con RFC nuevo obtenido por error humano. Hoy solo se puede vía SQL directo.
  2. Métricas de intentos bloqueados — log los RFCs que intentaron re-consumir trial; útil para detectar patrones de abuso sistemático.
  3. Endpoint GET /trial-usages (admin global) — listado para auditoría.