Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
# 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.