<!--
  LEDGER DE AUDITORÍA — documento compartido entre máquinas/sesiones.
  Léelo junto con CLAUDE.md y CHANGELOG.md. Es versionado a propósito: es el
  ÚNICO registro de auditoría que ambas sesiones de Claude Code (distintas
  máquinas) comparten — la memoria local ~/.claude/** NO se comparte.
-->

# Ledger de Auditoría — Credify Go!

> 🔎 **Documento compartido.** Registro vivo de las auditorías de código y su estado.
> Cualquier sesión (cualquier máquina) que audite o corrija debe **leer y actualizar
> este archivo** en su PR, igual que el [`CHANGELOG.md`](../CHANGELOG.md). El detalle
> en prosa de cada corrección vive en el CHANGELOG; aquí está el **índice de hallazgos
> con su estado**, la metodología y la lista de vigilancia abierta.

## Cómo usar este ledger (protocolo)

- **Antes de auditar**: lee este archivo para no re-auditar lo ya cubierto ni perseguir hallazgos obsoletos.
- **Al corregir un hallazgo**: marca su fila como ✅ (con PR y test que lo prueba) en el mismo PR del fix.
- **Al abrir una ronda nueva**: añade una sección `## Ronda N` con fecha, alcance y tabla de hallazgos.
- **Regla de oro**: un hallazgo no está "resuelto" hasta que hay un **test que lo fija** y la suite pasa. Los hallazgos de dinero/estado se verifican de forma **independiente** (no se aprueban a ciegas los fixes hechos en otra máquina).
- **Convención de severidad**: ALTO (dinero/seguridad/datos), MEDIO (lógica incorrecta acotada), BAJO (defensa en profundidad / cosmético).

## Metodología

Cada ronda se ejecuta con **agentes en paralelo por dominio** (pagos, créditos, finanzas, cobranza, transversales, PWA, Filament, esquema) que **comparan superficies** (back de Filament vs API/PWA) y **cazan bugs** con `file:line`, y luego una verificación adversarial + un test por hallazgo confirmado. Herramientas de control: `php artisan test`, `phpstan` (nivel 5), `pint`.

## Estado global

| Ronda | Fecha | Alcance | Hallazgos | Estado |
|-------|-------|---------|-----------|--------|
| 1 | 2026-06-18 | Discrepancias back Filament ↔ PWA | 11 (#67–#77) | ✅ todos cerrados |
| 2 | 2026-06-20/21 | Caza de bugs profunda | 9 (#89–#97) | ✅ todos cerrados |
| 3 | 2026-07-03 | Barrido fresco (PWA/offline, Filament admin, esquema) | 6 (#124–#129) | ✅ **6/6 cerrados** |
| 4 | 2026-07-13 | Proceso de suscripciones (web → trial → gate → conversión → enforcement) | 10 (#163–#172) | ✅ **10/10 cerrados** (4 ALTO #163–#166 · 6 MEDIO #167–#172; #171 fue falso positivo) |

---

## Ronda 1 — Discrepancias Filament ↔ PWA (2026-06-18)

Dos superficies implementan los mismos dominios; el riesgo era que una regla de negocio se aplicara en una y no en la otra. Detalle en prosa: CHANGELOG `2026-06-23`/`2026-06-20`.

| # | Sev | Área | Hallazgo | Estado / PR |
|---|-----|------|----------|-------------|
| #67 | ALTO | Pagos | Validación de pago divergente (Filament no validaba saldo/hoja/idempotencia) → `PaymentPolicyValidator` + `Credit::activeLeafCredits()` compartidos | ✅ #78/#100 |
| #68 | ALTO | Créditos | `CreditForm` (Filament) ignoraba límites por empresa → lee `CompanyFinancialSettings` | ✅ #78 |
| #69 | ALTO | Finanzas | Egreso de admin podía quedar PENDING → `CreateExpenses` usa `applyApprovalPolicyForCreator()` | ✅ #78 |
| #70 | ALTO | Cobranza | "Cobrado hoy" por `registered_by` vs cobrador asignado → unificado a `collector_user_id` + hoja + activo | ✅ #78 |
| #71 | ALTO | Visibilidad | Supervisor veía toda la empresa en morosidad/rendimiento → `CollectorVisibilityResolver` en todas las superficies | ✅ #79/#86 |
| #72 | ALTO | Cobranza | Bucket de aging del cobrador mal rotulado (`8_30` cubría 1–30) → `1_30` | ✅ #78 |
| #73 | MEDIO | Finanzas | Income por Eloquent directo → validación de integridad en hook del modelo | ✅ #80 |
| #74 | MEDIO | PWA | Preview de cuota hardcodeada a flat-rate → respeta `interest_method` | ✅ #80 |
| #75 | MEDIO | Dinero | Comparaciones con tolerancia `±0.01` en float → `Money`/BC Math | ✅ #80/#87 |
| #76 | MEDIO | Fechas | `now()->toDateString()` en controllers PWA → `Carbon::today()` | ✅ #80 |
| #77 | MEDIO | Métricas | "Collectors activos" con semánticas opuestas → definición unificada | ✅ #80 |

---

## Ronda 2 — Caza de bugs profunda (2026-06-20/21)

Sweep más profundo (transformaciones, concurrencia, comandos/jobs, Money, SaaS, seguridad). Detalle: CHANGELOG `2026-06-29`/`2026-06-23`.

| # | Sev | Área | Hallazgo | Estado / PR |
|---|-----|------|----------|-------------|
| #84 | ALTO | Pagos | Reversión clonaba pivotes vivos → doble conteo del saldo. Reversión sin pivotes + invariante D + migración de limpieza | ✅ #84 |
| #89 | ALTO | SaaS | Ciclo de vida roto: `grace`/`expired` inalcanzables; `scopeCurrent` con OR sin agrupar; gate ignoraba gracia | ✅ #100 |
| #90 | ALTO | Créditos | Extensión simple recargaba interés sobre interés → hijo a tasa 0 que redistribuye el saldo | ✅ #108 |
| #91 | MEDIO | Interés | `FlatRateStrategy`: por cuota `total ≠ principal+interés` → derivar `total = P+I` por fila | ✅ (test `FlatRateStrategyRowInvariantTest`) |
| #92 | MEDIO | Pagos | Race de idempotencia → 500 en vez de duplicado; captura `QueryException` 1062 → 200/`duplicate` | ✅ #100 |
| #93 | MEDIO | Cobranza | `FixCompletedCredits` dejaba huérfanas en `collector_credit_order` → despacha `CreditStatusSynced` | ✅ #100 |
| #94 | MEDIO | Dinero | `Money::multiply/divide/percentage` no normalizaban factor float (notación científica → `ValueError`) → `normalize()` | ✅ #100 |
| #95 | MEDIO | Créditos | Renovación contradictoria (`assertNoCapitalPaid` + exigir pagos) → renovación ya no valida capital pagado | ✅ (test `RenewalCapitalPolicyTest`) |
| #96 | MEDIO | Transformaciones | (1) `cash_out` falso en restructure custom → `Money::zero()`; (2) `ExtendWithInterestOperation` floats → BC Math; (3) `hasCapitalPayments` proxy frágil → `amount_paid > interest_amount` | ✅ (tests `ExtendWithInterestOperationTest`, `RefinanceRestructureCapitalTest`) |
| #97 | BAJO | Hardening | Scope de empresa en reglas `exists`, `throttle:pwa-write` en escrituras faltantes, `Money::cents()` consistente | ✅ #107 |

**Descartados con justificación** (no eran bugs): `MultiTenantScope` desactivado en consola (intencional: los jobs ven todos los tenants); `PaymentReverser` no debe bloquear créditos `paid` (el reopen del pago final es legítimo, cubierto por `PaymentReversalTest`); `CollectionVisitController::today` ya está scoped por `company_id`.

**Relacionados (misma ventana):** #3 (fuga de scope supervisor, #86), #85 (excluir anulados del recaudado), #87 (5 MEDIO), #88 (3 BAJO), #59 (ruido notificaciones Filament), #60 (app-password Bitbucket revocado).

---

## Ronda 3 — Barrido fresco (2026-07-03)

Áreas poco cubiertas por las rondas 1–2: **capa PWA/offline (Vue/Dexie/sync)**, **recursos Filament admin no auditados** (Clients, Partners, Subscriptions/Plans/SubscriptionRequests, Incomes) y **esquema/migraciones/eventos**. Todos los hallazgos **verificados adversarialmente** (lectura directa del código) antes de registrarlos. **Abiertos** — pendientes de corregir.

| # | Sev | Área | Hallazgo | Estado |
|---|-----|------|----------|--------|
| #124 | **ALTO** | Filament/seguridad | Autorización por-registro/acción cae a *default-allow*: `canAccessPanel` sin gate de rol + solo `CreditPolicy` + recursos que solo overridean `canViewAny`. → IDOR cross-tenant de `SubscriptionRequest` (+`withoutGlobalScopes`), auto-aprobación de upgrade, auto-provisión de `Subscription` por URL, y `UserResource`/`CompanyResource` permiten crear admin (escalación) | ✅ cerrado (PR #138 · `canAccessPanel` con gate de rol + policies + sin `withoutGlobalScopes` · `FilamentPanelAuthorizationTest`) |
| #125 | MEDIO | Filament/SaaS | `SubscriptionForm` usa `Select::make('plan')` (campo inexistente); el modelo tiene `plan_id`/`billing_cycle` → se descartan → `plan_id=null` → límites = ilimitado; `is_active` toggle inerte (el acceso lo maneja `status`) | ✅ cerrado (`plan_id`+`billing_cycle`+`status`; test `SubscriptionFormTest` · PR #141) |
| #126 | MEDIO-ALTO | PWA/offline | Estado optimista `paid` (`deductCreditBalance`) no se revierte al rechazo permanente (rollback solo restaura saldo; `syncData` solo corre si `successCount>0`) → el crédito sale de la ruta del cobrador | ✅ cerrado (PR #139 · rollback restaura estado+saldo + `syncData()` forzado tras rechazo permanente) |
| #127 | MEDIO | PWA/SW | Background sync es código muerto: el handler `sync` tag `sync-payments` existe pero nadie hace `sync.register` → nunca dispara; depende del listener `online` con app abierta | ✅ cerrado (removido — código muerto/redundante; iOS no soporta bg-sync; el sync de foreground es la vía correcta · PR #140) |
| #128 | MEDIO | Esquema/dinero | `credits.amount` + 5 columnas de `installments` son `decimal(10,2)`, no `(12,2)` como el resto del ledger y el invariante documentado (tope 99.999.999,99; `PaymentMaterializer` suma 12,2 → 10,2) | ✅ cerrado (migración `widen_money_columns_to_12_2` → todas a `decimal(12,2)`; test `MoneyColumnPrecisionTest` de round-trip > tope viejo) |
| #129 | BAJO | Varios | `CreateIncomes` pisa `company_id`; `CollectorSyncService` asimétrico con el observer (gaps de `sort_order`); `CreditDeleted` sin listener; `distributeProfits` sin `effective()` (dormido); PWA menores (X-Device-ID, floats display, `created_at_local` UTC, TOCTOU `syncing`) | ✅ cerrado — `CreateIncomes` usa `??=`; `CollectorCreditOrderService` gana `insertRespectingHistory()`/`removeAndReindex()` (extraídos de `CreditObserver`) y `CollectorSyncService` los reutiliza (paridad real); `CreditDeleted` (evento + dispatch) **eliminado** — un listener seria arquitectónicamente imposible (`CreditAuditLog.credit_id` tiene `cascadeOnDelete`, cualquier fila post-borrado se auto-eliminaría); `distributeProfits` usa `->effective()`; X-Device-ID se genera y persiste en sessionStorage; `expireOldData` usa `getDaysAgoDateString()` (local, no UTC); TOCTOU de `syncing` cerrado en las 3 funciones de sync (flag antes del primer `await`). Tests: `CreateIncomeCompanyIdTest`, `DistributeProfitsEffectiveExpensesTest`, `CollectorSyncServiceRouteHistoryTest` (3, incluye el caso "collector sin filas de ruta"). Suite completa 507 passed. |

**Verificado limpio en Ronda 3:** esquema Dexie (v1→v7 monótono, sin migraciones destructivas); fechas financieras críticas (`payment_date`, `visit_date`) usan hora local (sin bug UTC); token PWA memory-only + logout limpia IndexedDB/PII; idempotencia preserva la key en reintentos; `PartnerResource` + RelationManagers tenant-scoped vía `PartnerInvestmentService` (sin doble conteo); widgets de supervisor aplican `CollectorVisibilityResolver`; wiring de eventos `PaymentRegistered/Reversed`/`CreditStatusSynced` correcto (sync vs async); enums de estado alineados con las constantes de modelo; FKs con `cascade`/`nullOnDelete` coherentes.

---

## Ronda 4 — Proceso de suscripciones (2026-07-13)

Auditoría end-to-end del SaaS billing: **cómo se muestran los planes en la web → alta con trial → gate de acceso → ciclo de vida → conversión a cliente de pago → enforcement de límites**. 4 agentes en paralelo + verificación adversarial de los ALTO.

**Veredicto:** la mitad de *entitlements* (gate + límites del plan) y la máquina de estados están **sólidas**; la mitad de *billing/conversión* **no existe como sistema** — es un handoff manual de back-office (sin cobro, facturas, avisos ni self-serve), y el workflow de solicitudes **no está conectado** a la activación.

| # | Sev | Área | Hallazgo | Estado |
|---|-----|------|----------|--------|
| #163 | **ALTO** | Conversión | Aprobar/Implementar una `SubscriptionRequest` no reactiva la suscripción (solo cambia el status de la solicitud); reactivación 100% manual. `activate()` es código muerto | ✅ cerrado (PR #175: `SubscriptionActivationService`) |
| #164 | **ALTO** | Ciclo de vida | Ventana de bloqueo indebido: `grace_period_ends_at` NULL hasta el cron 00:00 → cliente con gracia vigente bloqueado hasta ~24h | ✅ cerrado (PR #174: gracia en vivo en `scopeCurrent`) |
| #165 | **ALTO** | Billing | Sin pasarela de pago ni facturas; `amount_paid`/`payment_reference` ni en el form (decisión de producto: integrar o documentar manual) | ✅ cerrado (PR #176: billing manual documentado + campos en el form) |
| #166 | **ALTO** | Conversión | Sin avisos de expiración/recordatorios de trial → el cliente se entera al quedar bloqueado (dep: SMTP) | ✅ cerrado (PR #177: avisos in-app T-3/T-1; email pend. SMTP) |
| #167 | MEDIO | Web | CTA de Básico/Enterprise provisiona un trial Profesional (no arrastra el plan) | ✅ cerrado (PR #179: copy aclara que la prueba es Profesional) |
| #168 | MEDIO | Web/veracidad | Claims falsos para Básico ("todas las funciones"/"reportes y tu marca") + grilla oculta diferencias de features | ✅ cerrado (PR #179/#180 grilla data-driven + pie honesto + bot; **refinado en PR #191**: los flags de "features" no se hacían cumplir en código → la grilla muestra solo **límites reales** + desembolso mensual, la marca pasa a incluida en todos, y los flags sin implementar se **eliminaron del modelo/esquema**) |
| #169 | MEDIO | Enforcement | Race TOCTOU en límites por conteo (contar→crear sin lock) | ✅ cerrado (PR #183: runGuardedCreate con lock por empresa) |
| #170 | MEDIO | Enforcement | `EditUser` evade el cupo de cobradores/usuarios (guard solo en Create) | ✅ cerrado (PR #181: guard en EditUser::beforeSave sobre el alta neta) |
| #171 | MEDIO | Billing | Sin self-serve de upgrade/downgrade/cancelar/reactivar (solo solicitud UPGRADE hardcodeada) | ✅ **falso positivo** — ya existe en `CompanySettings` › Suscripción (solicitudes tipadas); solo faltaba test (PR #184) |
| #172 | MEDIO | Enforcement | Volumen mensual cuenta créditos de transformación y todos los estados → puede bloquear de más | ✅ cerrado (PR #182: solo desembolsos reales, raíces no canceladas) |

**Seguimiento — verificación de enforcement (2026-07-16):** re-auditoría end-to-end de que los límites del plan se cumplen leyendo los valores **en vivo de la BD** (editables). Extras cerrados sobre lo anterior: el **clon «Duplicar»** (`cloneAsNewEditableCredit`) creaba un crédito nuevo con desembolso real sin pasar por el cupo → ahora gateado (PR #188); el **TOCTOU del panel Filament** (assert sin lock) se cerró con `$hasDatabaseTransactions`+`lockForUpdate` por empresa (PR #190, complementa el `runGuardedCreate` de la PWA de #169); el **medidor de volumen** ahora cuenta el dinero nuevo de refinanciar/renovar/clonar (`FinancialOperation.cash_out` de hijos, PR #189/#187, refina #172); y se **eliminaron del modelo/esquema los flags de plan sin implementar** (`has_api_access`/`has_advanced_reports`/`has_multi_branch`/`has_custom_branding`/`has_priority_support`) — no gateaban nada y anunciarlos era humo (PR #191).

**Verificado correcto en Ronda 4:** gate coherente en todas las superficies (panel + PWA), basado en `status`; `scopeCurrent` con OR agrupado (#89) y el comando mueve el `status` de verdad; enforcement de límites estricto sin grandfathering, off-by-one correcto (`<` conteos / `<=` volumen), `custom_limits` override; comando de estados idempotente y en el scheduler (00:00). Provisioning del trial atómico y con `company_id` explícito.

**BAJO / limpieza:** `is_active` vestigial e incoherente con `status`; `activate()/cancel()/suspend()` código muerto (el panel hace mass-assignment de `status` → `canceled_at`/`reason` sin llenar); caché de acceso sobre-concede ≤5 min; `getTrialPlan()` (fallback Básico) puede discrepar con el servicio (exige `professional`); enumeración de emails en `/registro`; "Uso del plan"/`isOverPlanLimit` omiten cobradores; `custom_limits=0` = ilimitado (no "cero"); `config/app.php` fija `America/Bogota` ignorando `.env` (riesgo latente); sin dunning ni proración (esperable sin pasarela).

---

## Lista de vigilancia (abierta, no-bloqueante)

- **La PWA (Vue/Dexie/Pinia) no tiene harness de tests JS** (sin vitest/jest). Los fixes de PWA se verifican con `npm run build` + revisión; los invariantes offline (rollback del update optimista, cola de sync, materialización local) no tienen cobertura automatizada. Considerar `vitest` + `fake-indexeddb`.
- **Test flaky por orden de ejecución.** Varios tests fallan solo en la suite completa (p. ej. `ExtendWithInterestOperationTest`, `SupervisorPaymentVisibilityTest`, `DelinquencyMetricsServiceTest`, `PaymentMaterializerTest`) por agotamiento de `faker->unique()` en `setUp` / estado compartido en la BD de dev; **pasan aislados**. Recomendado: sembrar faker por test o BD de test dedicada. No es bug de producto pero ensucia la señal de CI.
- **#58 — Backups offsite.** El dump diario local con retención ya existe en prod; falta la copia externa.
- **Dependabot #98–#106** — barrido de bumps pendientes.
- **Idempotencia de pagos**: el índice único de `payments.idempotency_key` es **global**, no compuesto por `company_id` (sin impacto práctico por UUID v4; la race dentro de una empresa ya devuelve duplicado tras #92).
