# 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.
