127 lines
5.6 KiB
Markdown
127 lines
5.6 KiB
Markdown
# 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:
|
|
|
|
```prisma
|
|
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:
|
|
|
|
```sql
|
|
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
|
|
|
|
```bash
|
|
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.
|