Initial commit: Horux Despachos project
This commit is contained in:
95
docs/plans/2026-04-14-reactivate-subscription.md
Normal file
95
docs/plans/2026-04-14-reactivate-subscription.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Reactivar suscripción cancelada dentro del período pagado
|
||||
|
||||
## Resumen
|
||||
|
||||
Cuando un cliente cancela su suscripción, el acceso continúa hasta `currentPeriodEnd` (política existente). Antes, durante esa ventana no había manera de revertir la cancelación — el cliente tenía que esperar a que expirara para re-contratar. Ahora puede reactivarla con un botón desde `/configuracion/suscripcion`.
|
||||
|
||||
## Motivación
|
||||
|
||||
UX real: cliente cancela por error o cambia de opinión, quiere volver. Forzar que espere a fin de período + re-contratar desde cero es fricción innecesaria (y posible pérdida si se van con la competencia en esa ventana).
|
||||
|
||||
## Mecánica MercadoPago
|
||||
|
||||
Un preapproval cancelado en MP es **terminal** — no se puede reactivar. Reactivar = crear un preapproval **nuevo** con los mismos parámetros (plan, amount, frequency) y `auto_recurring.start_date` apuntando al final del período actual para evitar doble cobro.
|
||||
|
||||
```
|
||||
T0 (hoy): cancelled, currentPeriodEnd = T0+15d
|
||||
T0: Usuario click "Reactivar"
|
||||
→ backend crea NUEVO preapproval
|
||||
reason: "Reactivación Plan X (frequency) - Tenant"
|
||||
amount: mismo
|
||||
frequency: mismo
|
||||
start_date: T0+15d ← clave para no cobrar doble
|
||||
→ Subscription.status = 'pending'
|
||||
→ Subscription.mpPreapprovalId = nuevo ID
|
||||
→ Retorna paymentUrl
|
||||
T0..T0+14d: Usuario sigue teniendo acceso (período ya pagado)
|
||||
T0+15d: MP ejecuta primer cobro → webhook → status 'authorized'
|
||||
T0+15d+30d (o +365d): siguiente cobro
|
||||
```
|
||||
|
||||
## Reglas y validaciones
|
||||
|
||||
| Condición | Resultado |
|
||||
|-----------|-----------|
|
||||
| Tenant no tiene suscripción cancelada | 400 "No hay suscripción cancelada para reactivar" |
|
||||
| Suscripción cancelled pero `currentPeriodEnd` ya venció | 400 "El período pagado ya venció — contrata un nuevo plan desde el selector" (UI ya lo sabe: `isCancelledExpired` muestra el picker) |
|
||||
| Plan cancelado era custom | 400 "Reactivación de plan custom requiere coordinación con el admin global" |
|
||||
| MP no configurado | 503 con mensaje claro |
|
||||
| MP rechaza (token inválido) | 503 "MercadoPago rechazó la solicitud" |
|
||||
| Reactivación exitosa | Limpia `pendingPlan`, `upgradeTargetPlan`, etc. El estado queda limpio como una sub recién contratada |
|
||||
|
||||
## Archivos
|
||||
|
||||
### Backend
|
||||
- `apps/api/src/services/payment/mercadopago.service.ts` — `createPreapproval` ahora acepta `startDate?: Date` opcional (traduce a `auto_recurring.start_date` ISO si es fecha futura; MP rechaza fechas pasadas)
|
||||
- `apps/api/src/services/payment/subscription.service.ts` — `reactivateSubscription({ tenantId, payerEmail })`
|
||||
- `apps/api/src/controllers/subscription.controller.ts` — `reactivateMe` handler
|
||||
- `apps/api/src/routes/subscription.routes.ts` — `POST /api/subscriptions/me/reactivate`
|
||||
|
||||
### Frontend
|
||||
- `apps/web/lib/api/subscription.ts` — `reactivateMe()`
|
||||
- `apps/web/lib/hooks/use-subscription.ts` — `useReactivateMe()`
|
||||
- `apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx`:
|
||||
- Handler `handleReactivate` — llama mutation, abre `paymentUrl` en nueva pestaña
|
||||
- Banner naranja `isCancelledInPeriod` incluye botón "Reactivar suscripción"
|
||||
- Card "Tu Suscripción" muestra "Reactivar" como acción principal (primary) en vez de "Cancelar"
|
||||
- Limpia la condición redundante `(isActive || ... || isCancelledInPeriod) && !isCancelledInPeriod`
|
||||
|
||||
## Flow UI
|
||||
|
||||
```
|
||||
Estado: isCancelledInPeriod (status='cancelled', currentPeriodEnd en el futuro)
|
||||
↓
|
||||
Banner naranja "Suscripción cancelada — tienes acceso hasta X"
|
||||
+ botón inline "Reactivar suscripción"
|
||||
↓
|
||||
Card "Tu Suscripción" con botón primary "Reactivar suscripción"
|
||||
↓
|
||||
Click → POST /me/reactivate → { paymentUrl }
|
||||
↓
|
||||
window.open(paymentUrl, '_blank') → MP checkout
|
||||
↓
|
||||
Usuario autoriza en MP
|
||||
↓
|
||||
Webhook `subscription_preapproval` → status='authorized'
|
||||
↓
|
||||
UI refresca (React Query invalidation) → muestra "Activa"
|
||||
```
|
||||
|
||||
## Decisiones descartadas
|
||||
|
||||
### Reactivar con cambio de plan en el mismo flow
|
||||
**Tentación:** mostrar modal con picker en vez de botón "Reactivar" plano.
|
||||
|
||||
**Por qué no:** complica el flujo (reactivar + cambiar plan = dos semánticas mezcladas). MVP = simple: reactiva con mismo plan. Si quiere otro plan, usa "Cambiar plan" después de reactivar (ya existe).
|
||||
|
||||
### "Undo cancel" retentivo (sin re-autorización de MP)
|
||||
**Tentación:** si la cancelación fue hace < 24h y el preapproval MP aún está `paused` (no `cancelled`), teóricamente podríamos re-activarlo sin crear uno nuevo.
|
||||
|
||||
**Por qué no:** `cancelSubscription` llama `cancelPreapproval` inmediatamente → MP lo marca `cancelled` terminal. Recuperarlo requeriría: (a) no cancelar el preapproval al cancelar, (b) cron que lo cancele 7 días después. Complejidad innecesaria para un caso marginal.
|
||||
|
||||
## Pendientes
|
||||
|
||||
1. **Email confirmación de reactivación** — análogo al de cancelación, para cerrar el loop de comunicación.
|
||||
2. **Métrica de reactivaciones** — útil para el admin global: cuántos clientes cancelan pero reactivan antes del fin de período. Indicador de UX y de churn real.
|
||||
Reference in New Issue
Block a user