Files
HoruxDespachosNuevo/docs/plans/2026-04-26-sprints-1-2-3.md

321 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sprints 1, 2, 3 y 6 — cierre del día (2026-04-26)
Cuatro sprints encadenados después del refactor IVA y del wire de
notificaciones email:
- **Sprint 1**: pre-deploy del refactor IVA. Validar otros tenants,
recompute de `metricas_mensuales`, alinear `dashboard.service.ts`.
- **Sprint 2**: bugs latentes (overage al cambiar de plan, `getMyPlan`
para Mi Empresa, métricas de Despacho, visibilidad auxiliares).
- **Sprint 3**: decisiones del owner (D1-D7) — billing dual, re-notif
alertas, etc.
- **Sprint 6**: investigación SAT — verificar logging `codeRequest`,
reconciliar record stale de Alexa.
---
## Sprint 1 — Pre-deploy refactor IVA
### 1.1 Validar otros tenants
Inventario por contribuyente con I/07 PUE/PPD/E:
| Tenant | Contribuyente | Lado | I PUE/07 | con E (cualquier tipoRel) | I PPD/07 con E |
|---|---|---|---:|---:|---:|
| Patito | TOAH (Husberto) | Receptor | 356 | 257 (72%) | 21/21 |
| Patito | TOAH (Husberto) | Emisor | 6 | 6 (100%) | — |
| Patito | TORC (Carlos) | Receptor | 8 | 6 (75%) | 22/22 |
| Zorro | (sin volumen) | — | 0 | 0 | 0 |
**Hallazgo crítico ajustado**: la primera medida (filtro estricto a
E con `tipoRel=07` apuntando al anticipo) sugería sobrecausa masiva.
La medida correcta (cualquier E PUE que cancele en el mismo mes,
incluyendo E/01 sustitución) muestra que 72-100% sí tienen contraparte.
**Decisión del owner**: para los huérfanos, **fidelidad al XML > interpretación**:
> "Si no existe la tipo E, lo correcto es mostrar los datos tal cual viene
> la información, ya que si hacemos cualquier modificación, podemos llegar
> a una discrepancia."
Esto cierra D4: confirmar la fórmula nueva sin compensación I PUE/07
ni clamp en P.
### 1.2 Recompute bulk de `metricas_mensuales`
Borrado de cache de años cerrados (< 2026) en los 3 tenants:
```sql
DELETE FROM metricas_mensuales WHERE anio < 2026;
```
| Tenant | Filas borradas | Filas restantes (2026 actual) |
|---|---:|---:|
| Patito | 250 | 0 |
| Zorro | 103 | 6 |
| mo3nhzvl | 0 | 0 |
Estrategia **lazy repopulation**: la próxima query de un usuario sobre
un mes pasado dispara el path on-the-fly con la fórmula nueva, y el
cron `metricas-invalidations.job.ts` repuebla en background.
### 1.3 Alinear `dashboard.service.ts` con la fórmula nueva
`calcularIvaBalancePorRegimen` tenía la lógica vieja inline (clamp P,
compensación I PUE/07, filtro `<> '07'` en s3/r3). Cambios:
- `IVA_NETO_PAGO`: removido clamp `LEAST(...)`, usa campos directos.
- `s1`/`r1` (I PUE emisor/receptor): removida la rama de compensación
con `SUM_REL_TRAS`. Ahora aportan IVA neto completo.
- `s3`/`r3` (E PUE): removido filtro `<> '07'`. Todas las E PUE entran
al NEG.
- **Nuevos `s4`/`r4`**: I PPD/07 hereda IVA neto de E que la cancele en
mismo mes. Mirror del `SUM_E_REFERENCING_*` de `impuestos.service.ts`.
Suma final actualizada:
```ts
causado = s1 + s2 + s4 - r3
acreditable = r1 + r2 + r4 - s3
balance = causado - acreditable
```
**Validación**: dashboard coincide centavo a centavo con `/impuestos`
para Husberto agosto 2025: T=$111,781.45, A=$182,683.84, balance=$70,902.39.
---
## Sprint 2 — Bugs latentes
### 2.1 Recomputar overage al cambiar de plan
`adjustDespachoOverage` no se invocaba desde el flujo de cambios de plan.
Si un tenant pasaba de Enterprise → Business Control (o Mi Empresa→Business
Control), el addon de overage quedaba huérfano: cobros incorrectos en MP.
**Cambios**:
- `addon.service.ts`:
- **Nuevo helper** `countActiveContribuyentesForTenant(tenantId)`
abre pool tenant y cuenta CONTRIBUYENTEs activos. Reemplaza el
helper local `countActiveContribuyentes` que vivía en
`contribuyente.controller.ts`.
- **Nuevo helper** `cancelOverageAddonForTenant(tenantId)` — cancela
el preapproval MP + setea status='cancelled'. Idempotente. No
requiere que la subscripción esté activa (útil al cancelarla).
- `subscription.service.ts`:
- **Nuevo helper privado** `reconcileOverageAfterPlanChange(tenantId, fromPlan, toPlan)`
— fail-soft. Si el plan target permite overage, llama
`adjustDespachoOverage`. Si no, llama `cancelOverageAddonForTenant`.
- `applyApprovedUpgrade`: invoca el reconcile tras el `$transaction`.
- `applyPendingChanges`: invoca el reconcile dentro del loop.
- `cancelSubscription`: invoca `cancelOverageAddonForTenant` antes de
marcar status='cancelled' (porque el lookup necesita la sub activa).
### 2.2 `getMyPlan` para Mi Empresa
Mapping anterior usaba `dbMode` como proxy: `BYO → business_control`,
`MANAGED → business_cloud`. Para Mi Empresa y Mi Empresa+ (también
MANAGED) reportaba `business_cloud` por error.
**Fix**: leer `tenant.plan` directamente. Soporta los 4 planes despacho.
Trial sigue detectándose por `trialEndsAt`.
```ts
let currentPlan: string;
if (isTrialActive) {
currentPlan = 'trial';
} else {
currentPlan = String(tenant.plan);
}
```
### 2.3 "Completadas > Pendientes" — NO es bug
Verificación contra datos reales:
| Contribuyente | Obl. pendientes | Obl. completadas |
|---|---:|---:|
| Horux 360 | 1 | 2 |
| Husberto | 2 | 3 |
`progresoDelMes = completadas / (pendientes + completadas)` está bien.
"Completadas > Pendientes" es señal positiva (despacho al día), no
error de cálculo. Cerrado.
### 2.4 Visibilidad auxiliares en carteras — sin reproducción
Datos actuales en Patito:
- Cartera `Demo` (top-level, sin auxiliar)
- Subcartera `Demo Auxiliar` con `auxiliar_user_id` apuntando al auxiliar.
El query del controller (`WHERE c.auxiliar_user_id = $1`) trae la
subcartera correctamente. Sin reproducción específica del bug original
(quién, qué pantalla, qué se ve vs qué se espera), cerrado como
"investigado, pendiente reporte específico".
---
## Sprint 3 — Decisiones del owner (D1-D7)
### Resumen de decisiones
| # | Decisión | Estado |
|---|---|---|
| D1 | Mi Empresa y Mi Empresa+ con billing dual: mensual default, anual = 10 meses (descuento 17%) | ✓ Implementado |
| D2 | Mi Empresa(+) sin overage de RFCs | ✓ Ya estaba |
| D3 | Enterprise sin timbres incluidos | ✓ Ya estaba |
| D4 | Confirmar fidelidad al XML (sin compensación I PUE/07, sin clamp P) | ✓ Confirmado tras Sprint 1 |
| D5 | Mantener sin clamp en IVA de P | ✓ Ya estaba |
| D6 | Sin email de "alerta resuelta" | ✓ Ya estaba |
| D7 | Re-notificación tras 30 días de resuelta | ✓ Implementado |
### D1 — Billing dual Mi Empresa(+)
**Catálogo nuevo** en `packages/shared/src/constants/despacho-plans.ts`:
```ts
export const DESPACHO_PLAN_PRICES = {
mi_empresa: { monthly: 580, firstYear: 5_800, renewal: 5_800, permiteMonthly: true },
mi_empresa_plus: { monthly: 900, firstYear: 9_000, renewal: 9_000, permiteMonthly: true },
business_control: { monthly: null, firstYear: 25_850, renewal: 25_850, permiteMonthly: false },
business_cloud: { monthly: null, firstYear: 43_000, renewal: 43_000, permiteMonthly: false },
};
```
**Helpers nuevos**:
- `getPrecioDespacho(plan, frequency, phase)` — resuelve precio según
frequency. Throws si el plan no permite la frecuencia.
- `permiteFrecuenciaMensual(plan)` — flag.
**Backend**:
- `subscription.service.ts:getPlanPrice` usa `permiteFrecuenciaMensual`
+ `getPrecioDespacho`.
- `subscription.controller.ts`: `DESPACHO_ONLY_ANNUAL` ahora solo
contiene `business_control` y `business_cloud`.
**UI** (`/configuracion/planes-despacho`):
- Nuevo `FrequencyToggle` con dos pestañas (Mensual / Anual `17%`)
inline en cada Card de Mi Empresa y Mi Empresa+. Toggle per-plan,
default mensual.
- Precio dinámico:
- Mensual: $580 — "o $5,800/año (ahorras 17%)" como CTA al anual.
- Anual: $5,800 — "Pagas 10 meses en lugar de 12" en verde.
- `handleContratar` y `handleCambiar` usan `frequencyFor(plan)` para
derivar la frecuencia del toggle.
### D7 — Re-notificación tras 30 días
`notifications.service.ts:processAlertasContribuyente` — antes del INSERT
de detección de alertas nuevas, borra registros con `resuelta_at < NOW() - 30 days`:
```sql
DELETE FROM alertas_notificadas
WHERE contribuyente_id = $1::uuid
AND resuelta_at IS NOT NULL
AND resuelta_at < NOW() - INTERVAL '30 days'
```
Si una alerta vuelve a aparecer después de >30 días resuelta, el INSERT
posterior la detecta como "nueva" y vuelve a notificar al equipo.
---
## Archivos modificados
```
packages/shared/src/constants/despacho-plans.ts [~] D1 catálogo + helpers
apps/api/src/services/dashboard.service.ts [~] Sprint 1.3 IVA balance
apps/api/src/services/payment/addon.service.ts [~] Sprint 2.1 helpers
apps/api/src/services/payment/subscription.service.ts [~] Sprint 2.1 reconcile + D1 getPlanPrice
apps/api/src/controllers/subscription.controller.ts [~] D1 DESPACHO_ONLY_ANNUAL
apps/api/src/controllers/despacho.controller.ts [~] Sprint 2.2 getMyPlan
apps/api/src/services/notifications.service.ts [~] D7 DELETE 30d
apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx [~] D1 UI toggle
```
Migraciones: ninguna nueva (las tablas/columnas necesarias se crearon
en sesiones previas).
Cache borrada: `metricas_mensuales WHERE anio < 2026` en los 3 tenants
(353 filas en total).
---
---
## Sprint 6 — Investigación SAT
### 6.1 Verificar logging de `codeRequest`
`apps/api/src/services/sat/sat-client.service.ts` líneas 116-184: el código
expone `codeRequestValue/Entry/Message` en cada `verify()` vía
`getCodeRequest()` de la lib `@nodecfdi/sat-ws-descarga-masiva`.
Cuando el SAT rechaza una solicitud, los 3 valores se loguean en
`[SAT Verify Debug]` (consola) y se incluyen en `error.message` del
`VerifyResult` cuando `status` es `rejected` o `failed`. Formato:
```
SAT request=<entry>(<value>) codeRequest=<entry>(<value>) — <message>
wrapperCode=<status> wrapperMsg="<msg>"
```
Permite diagnosticar los 5 códigos SAT documentados:
- `5000 Accepted` (happy path)
- `5002 Exhausted`
- `5003 MaximumLimit`
- `5004 EmptyResult`
- `5005 Duplicated` (la hipótesis principal de Manuel pre-2026-04-23)
**Logging activo y listo.** Solo falta capturar el primer caso real en
producción para confirmar la hipótesis 5005 y decidir mitigación
(esperar 72h, reducir rangos, cambiar FIEL, etc.).
### 6.2 Re-sync de Manuel — reformulado
El plan original era re-sync custom de bloques 3-9 de Manuel. Verificación
sobre datos reales:
| Contribuyente | Tenant | Job initial | CFDIs en BD |
|---|---|---|---:|
| Manuel (GADM9107165I0) | Zorro | ✓ completed | 242, distribuidos consistentemente |
| Alexa (TORA0007099R6) | Zorro | **failed** (stale) | 415 descargados |
**Manuel NO necesita re-sync** — su initial está completed con 242 CFDIs.
Los "bloques 3-9" mencionados en la sesión 2026-04-21 corresponden a
sub-fallos internos del job que terminó completed en su totalidad.
**El bug real era de Alexa**: 415 CFDIs descargados pero record marcado
`failed` por el bug stale del 2026-04-21 (cleanup manual de la sesión MP).
**Reconciliado** con UPDATE directo:
```sql
UPDATE sat_sync_jobs
SET status='completed',
error_message='Reconciliado 2026-04-26: el initial completo pero el
status quedo stale por el bug del 2026-04-21.',
cfdis_inserted=415,
completed_at=COALESCE(completed_at, NOW())
WHERE id='830bac32-1bfb-44cb-ab47-333eac840f81' AND status='failed';
```
La pendiente original apuntaba al contribuyente equivocado. Reformularla
y cerrarla con fix de datos en lugar de re-sync.
---
## Pendientes derivados
- **Sprint 4**: Cloudflare Tunnel + `FRONTEND_URL` HTTPS dev + typecheck
web cleanup. Decisión del owner: dejarlos al final.
- **Sprint 5**: Nómina (tipo N) y Carta Porte. Priorizar según demanda real.
- **Sprint 6 abierto**:
- Capturar el primer `codeRequest` real en producción cuando ocurra
una rejection SAT (logging ya activo).
- Prueba cross-contribuyente end-to-end (manual del owner).
- Mejora futura: **alerta automática "P con IVA > 16% del pago"**
follow-up de D5 para detectar XMLs malformados sin reintroducir clamp
global.
- **Reportar bug 2.4 con detalle**: si reaparece visibilidad de auxiliares
en carteras, capturar pantalla + rol del usuario + URL exacta.