Compare commits
2
Commits
159dcc4963
...
7df928c3ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df928c3ab | ||
|
|
26a4faa33e |
@@ -131,12 +131,23 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
|
|||||||
9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. **The separate Phase B Access additive sync is implemented:** `migration/run_all.py --sync` and the admin `SYNC` job upsert legacy-owned rows without truncating the database or touching manual rows. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set.
|
9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. **The separate Phase B Access additive sync is implemented:** `migration/run_all.py --sync` and the admin `SYNC` job upsert legacy-owned rows without truncating the database or touching manual rows. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set.
|
||||||
10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly.
|
10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly.
|
||||||
11. **Receipt capture ("Editor") completion + three net-new ops features — NOT STARTED, spec written.** Full design in [`docs/RECEIPT_CAPTURE_SPEC.md`](docs/RECEIPT_CAPTURE_SPEC.md), from the 2026-07-25/26 meeting with Jorge:
|
11. **Receipt capture ("Editor") completion + three net-new ops features — NOT STARTED, spec written.** Full design in [`docs/RECEIPT_CAPTURE_SPEC.md`](docs/RECEIPT_CAPTURE_SPEC.md), from the 2026-07-25/26 meeting with Jorge:
|
||||||
- **Receipt capture module** — the legacy "Editor" replacement: wires up the already-existing but unused `Transaction.outstanding` field (NOPAGO workflow), adds batch-by-check capture, and a check-reconciliation view replacing `REPORTE CHEQUE COUNT`. Builds directly on the single-movement capture already shipped in `billing/` (step 6) — smallest piece, do first.
|
- **Receipt capture module — DONE** (2026-07-27). The legacy "Editor" replacement, built on the single-movement capture from step 6. Wires up the previously-unused `Transaction.outstanding` (NOPAGO): capture flag on `POST /billing`, `?outstanding=` list filter, `POST /billing/:id/resolve-outstanding` (gated `ledger:create`, not `ledger:void` — resolving *completes* a capture), and exclusion from every balance aggregate exactly as the legacy `SALDOS ULTIMO 0`'s `HAVING NOPAGO = 0` did. Adds `POST /billing/batch` (one `$transaction`, check-level fields shared, per-line customer/amount) and `GET /billing/by-check`, plus the `cheque-count` report replacing `REPORTE CHEQUE COUNT` / `REPORTE POR CHEQUE` / `EDITA CHEQUE ALF|COUNT|NUM` — print/PDF/CSV/XLSX come free from the existing `/reportes/:slug` machinery. Web: `/estado-cuenta/lote` (the actual "Editor" screen, with live reconciliation against the physical check amount), plus an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog on `/estado-cuenta`. No new abilities. Verified end-to-end against dev, API + browser.
|
||||||
|
**Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows.
|
||||||
|
**OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled.
|
||||||
- **PDF/OCR auto-capture** — ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff currently key in by hand. Posts through the capture module above. Matching logic was checked field-by-field against `migration/transform_properties.py`'s actual output and found three real gaps to close first: no `TELEPHONE` service kind exists yet, `PROPERTY_TAX.accountNumber` was migrated from `PREDIAL` not `CLAVE` (needs verification against a real predial statement), and `GAS.meterNumber` was never populated by the migration at all.
|
- **PDF/OCR auto-capture** — ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff currently key in by hand. Posts through the capture module above. Matching logic was checked field-by-field against `migration/transform_properties.py`'s actual output and found three real gaps to close first: no `TELEPHONE` service kind exists yet, `PROPERTY_TAX.accountNumber` was migrated from `PREDIAL` not `CLAVE` (needs verification against a real predial statement), and `GAS.meterNumber` was never populated by the migration at all.
|
||||||
- **Multi-bank chequera** — `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register; today's `bank_transactions` is hardcoded single-account/MXN-only by design (see step 7 above) and needs a required `bankAccountId` plus scoping added to every read path in `bank.service.ts`, including two raw-SQL queries in `summary()`.
|
- **Multi-bank chequera** — `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register; today's `bank_transactions` is hardcoded single-account/MXN-only by design (see step 7 above) and needs a required `bankAccountId` plus scoping added to every read path in `bank.service.ts`, including two raw-SQL queries in `summary()`.
|
||||||
- **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s).
|
- **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s).
|
||||||
|
|
||||||
Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section.
|
Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section.
|
||||||
|
12. **Insurance features — NOT STARTED, spec written.** Full design in [`docs/INSURANCE_FEATURES_SPEC.md`](docs/INSURANCE_FEATURES_SPEC.md), the insurance half of the same 2026-07-25/26 meeting with Jorge that produced step 11:
|
||||||
|
- **Renewal notification emails** — a daily `@nestjs/schedule` sweep that mails the customer 30 days before expiry, 15 days before, and 7 days after, mapping onto `RenewalNotice.generation` 1/2/3 with **no schema change**. Sending is **Amazon SES** (`@aws-sdk/client-sesv2`, mirroring `StorageService`'s optional-client/degrade-don't-crash pattern) — the office already runs SES, so provider and budget are settled, not open. The letter body is the *existing* `aviso-renovacion` report (`reports.registry.ts:623-799`); `@@unique([policyId, generation])` is already-in-place idempotency, so a re-run cannot double-send. Volume ≈260 mails/month, and **815 of the 893 policyholders (91%) have an email**. Also adds the manual mark-as-sent mutation the report's own comment anticipates, so the report's permanently-zero `enviadas` total becomes real. Smallest useful piece — do first.
|
||||||
|
- **Liquidación batch workflow** — ~70% already built (`liquidated`/`liquidationNumber`/`liquidationDate` are wired through DTOs, list filter, stats, form and detail page); only the *batch* print-and-mark step is missing, against a live pending set of 226 policies. Adds a ramo-parameterized pending report plus `POST /policies/liquidate-batch` under a new MANAGER `policy:liquidate` ability. Parameterized by ramo, not MULT-only — legacy `TABLA LIQUIDA MF` served `MULT`, `INCENDIO` and `M EMPR` alike.
|
||||||
|
- **Certificate / "Solicitud Atlas"** — renders from the same `format: "letter"` machinery `aviso-renovacion` uses, then reaches customers as an extension of the step-8/9 replication (PDF generated here, pushed to MinIO, pointer replicated), **not** as a new public surface in this repo. Half-blocked: "Solicitud" has zero referent in the legacy system and normally means an *application form*, a different artifact from a certificate.
|
||||||
|
- **Carrier API integration (ANA Seguros + GMX)** — shape only (`CarrierConnector` + an import-review queue rather than direct `Policy` writes, matching how step 11's OCR results are routed). Carrier research done 2026-07-27: **the two carriers are one company** — both belong to **Grupo Valore** (ANA writes autos, GMX writes daños, which is exactly this database's `AUTO`/`LICENCIAS` vs `MULT`/`INCENDIO`/`M_EMPR` split), so it is one commercial relationship, not two. **ANA has a real live SOAP service** (`server.anaseguros.com.mx/ananetws/service.asmx`, ASP.NET `.asmx`) with a published operation list — catalogs, `CalculaValor`/`CalculaMSI`, `ValidaSerie`, `RecuperaCotizacion`, `Transaccion`. **GMX publishes no machine interface at all**, only human agent portals. ⚠️ **Critical mismatch:** every ANA operation serves *new-business quoting/issuance*, not "list the policies where I am agent of record" — so if the ask is inbound portfolio sync, no evidence exists that either carrier sells it. Blocked on one phone call to Grupo Valore ((55) 5480-4000) for credentials + a direction answer, not on further research. ("GDMX" in the meeting notes was a typo for `GMX` — confirmed 2026-07-27.)
|
||||||
|
|
||||||
|
**Two pre-existing defects were found while verifying this spec and should be fixed as part of the liquidación work:** (a) `policy_types` is missing its `INCENDIO` and `M_EMPR` rows and, because `policies_policyTypeId_fkey` is `ON DELETE SET NULL`, 5 `m_empr` policies silently lost their ramo — 4 of them are pending liquidación and are invisible to every ramo-filtered query; (b) the legacy settlement slots don't match what the target model assumed — `MULT`/`INCENDIO` carry two and `M EMPR` carries four, while `Policy` collapses to one, so ≤41 MULT second settlements were dropped in migration. Spec recommends moving settlement onto `PolicyPaymentInstallment` rather than adding a second slot.
|
||||||
|
|
||||||
|
**One long-standing open question is closed by this spec:** `DATGRAL.[NUM UTIL]` is authoritative for Utilities↔Seguros reconciliation and **`UTILSEG` must not be used** — its numbers resolve to unrelated people under every reading tested (name match 58/1,024 vs. 298/563 for `NUM UTIL`), and where the two sources overlap they contradict each other on 170 of 218 shared ids. This matters to step 11's customer-number recycling, which touches the same identity space.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -148,6 +159,8 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
|||||||
|
|
||||||
**Step 11 spec written, not built.** `docs/RECEIPT_CAPTURE_SPEC.md` covers the receipt-capture ("Editor") completion plus the three net-new ops features (OCR auto-capture, multi-bank chequera, customer-number recycling) — see Build sequencing step 11 above for the summary. Written from the 2026-07-25/26 meeting notes and verified against the real migration scripts and current API code, not just designed from the meeting notes alone.
|
**Step 11 spec written, not built.** `docs/RECEIPT_CAPTURE_SPEC.md` covers the receipt-capture ("Editor") completion plus the three net-new ops features (OCR auto-capture, multi-bank chequera, customer-number recycling) — see Build sequencing step 11 above for the summary. Written from the 2026-07-25/26 meeting notes and verified against the real migration scripts and current API code, not just designed from the meeting notes alone.
|
||||||
|
|
||||||
|
**Step 12 spec written, not built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. Two of the four features are much smaller than they sound: the renewal-notice table, its idempotency key and the letter body already exist, and the per-policy liquidación fields are already wired end to end.
|
||||||
|
|
||||||
## Decisions (locked)
|
## Decisions (locked)
|
||||||
|
|
||||||
- **Stack:** Next.js + NestJS + Prisma + **MySQL** (locked earlier — see engine rationale above).
|
- **Stack:** Next.js + NestJS + Prisma + **MySQL** (locked earlier — see engine rationale above).
|
||||||
@@ -164,9 +177,11 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
|||||||
- **VPS provisioning:** provider (Hetzner vs DigitalOcean), size, and Tailscale + MySQL replica setup on it — an ops task, still pending. Design is settled; only the box is missing.
|
- **VPS provisioning:** provider (Hetzner vs DigitalOcean), size, and Tailscale + MySQL replica setup on it — an ops task, still pending. Design is settled; only the box is missing.
|
||||||
- **Old external-DB credential** (hardcoded plaintext MySQL password in the old repo's `dbConnection.php`, in git history) — rotate it regardless, since it's already exposed.
|
- **Old external-DB credential** (hardcoded plaintext MySQL password in the old repo's `dbConnection.php`, in git history) — rotate it regardless, since it's already exposed.
|
||||||
|
|
||||||
## Open design questions (step 11 — need Jorge before/while building)
|
## Open design questions (steps 11 & 12 — need Jorge before/while building)
|
||||||
|
|
||||||
Unlike the ops items above, these block design decisions, not just infrastructure. Full detail in each section of `docs/RECEIPT_CAPTURE_SPEC.md`:
|
Unlike the ops items above, these block design decisions, not just infrastructure. Full detail in each section of `docs/RECEIPT_CAPTURE_SPEC.md` (step 11) and `docs/INSURANCE_FEATURES_SPEC.md` (step 12):
|
||||||
|
|
||||||
|
**Step 11 — utilities/ops side:**
|
||||||
|
|
||||||
- OCR provider/budget for the statement auto-capture pipeline (self-hosted vs. a paid per-page API, given 300+ statements/month/service provider).
|
- OCR provider/budget for the statement auto-capture pipeline (self-hosted vs. a paid per-page API, given 300+ statements/month/service provider).
|
||||||
- Whether `PROPERTY_TAX.accountNumber` (migrated from `DATMEX.PREDIAL`) is actually the same number as "Clave Catastral" (`DATMEX.CLAVE`) — blocks OCR matching for predial statements until confirmed against a real bill.
|
- Whether `PROPERTY_TAX.accountNumber` (migrated from `DATMEX.PREDIAL`) is actually the same number as "Clave Catastral" (`DATMEX.CLAVE`) — blocks OCR matching for predial statements until confirmed against a real bill.
|
||||||
@@ -174,6 +189,17 @@ Unlike the ops items above, these block design decisions, not just infrastructur
|
|||||||
- The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility.
|
- The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility.
|
||||||
- Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention.
|
- Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention.
|
||||||
|
|
||||||
|
**Step 12 — insurance side:**
|
||||||
|
|
||||||
|
- Which SES region + verified sending identity/configuration set the renewal mail goes out under, and whether it reuses the existing IAM credentials or gets its own scoped `ses:SendEmail` user. (Provider and budget are *not* open — SES is settled.)
|
||||||
|
- What to do with the 78 policyholders who have no email on file: skip silently, or produce a print worklist? Recommended: the worklist, since `aviso-renovacion` already renders exactly those letters.
|
||||||
|
- Whether renewal notices go out in Spanish or English — `Customer` carries no language preference.
|
||||||
|
- What "garantías" refers to — it has zero referent in the legacy data, and it blocks the liquidación batch's exclusion filter.
|
||||||
|
- Whether policy settlement should move onto `PolicyPaymentInstallment` (recommended) or gain a second slot on `Policy`, and whether to backfill the ≤41 MULT second settlements lost in migration.
|
||||||
|
- Whether batch liquidación warrants a new MANAGER-level `policy:liquidate` ability (recommended) or should reuse the existing STAFF-level `policy:update`.
|
||||||
|
- **What "Solicitud Atlas" actually is** — an application form or a certificate. These are different artifacts with different data and timing; this blocks the whole certificate feature.
|
||||||
|
- **Carrier integration direction** — outbound quote/issue (which ANA's SOAP service supports today) or inbound sync of the office's existing book (which nothing found suggests either carrier offers)? This decides whether the feature is buildable at all. Bundle with the other three carrier questions into one call to Grupo Valore ((55) 5480-4000): WSDL + credentials for the ANA service, whether a cartera/portfolio download exists for an agent's own book, whether GMX daños has any machine interface, and whether one credential spans both carriers. ("GDMX" is resolved — it was a typo for `GMX`.)
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- Migration: automated row-count/sum reconciliation between `staging` and final schema per table group (see step 5 above), run as part of the migration script, not a manual spot-check.
|
- Migration: automated row-count/sum reconciliation between `staging` and final schema per table group (see step 5 above), run as part of the migration script, not a manual spot-check.
|
||||||
|
|||||||
@@ -200,9 +200,12 @@ the reconciliation pass (done, then corrected) are all closed. See §3 and §8.
|
|||||||
- **`npm` is pnpm-aliased**, and pnpm ignores the `workspaces` field. Consequences:
|
- **`npm` is pnpm-aliased**, and pnpm ignores the `workspaces` field. Consequences:
|
||||||
- there is **no root `node_modules/.bin`**. Binaries live per-app: `apps/api/node_modules/.bin/nest`, `apps/web/node_modules/.bin/next`.
|
- there is **no root `node_modules/.bin`**. Binaries live per-app: `apps/api/node_modules/.bin/nest`, `apps/web/node_modules/.bin/next`.
|
||||||
- Prisma CLI is run as `npx prisma@5`.
|
- Prisma CLI is run as `npx prisma@5`.
|
||||||
- **Dev servers** (both must be up to use the UI):
|
- **Dev servers** (both must be up to use the UI). ⚠️ **Ports come from the env files, not the
|
||||||
- API `cd apps/api && ./node_modules/.bin/nest start --watch` → `:3001`
|
framework defaults** — `apps/api/.env` sets `PORT=4501` and `WEB_ORIGIN=http://localhost:4500`,
|
||||||
- Web `cd apps/web && ./node_modules/.bin/next dev` → `:3000`
|
and `apps/web/.env.local` points at `NEXT_PUBLIC_API_ORIGIN=http://localhost:4501`. This doc
|
||||||
|
said `:3001`/`:3000` until 2026-07-27; that was wrong and cost a debugging detour.
|
||||||
|
- API `cd apps/api && ./node_modules/.bin/nest start --watch` → **`:4501`**
|
||||||
|
- Web `cd apps/web && ./node_modules/.bin/next dev -p 4500` → **`:4500`**
|
||||||
- Dev login: `admin@jorgecuadros.local`, password from `apps/api/scripts/seed-user.mjs` (`SEED_PASSWORD` env overrides the default).
|
- Dev login: `admin@jorgecuadros.local`, password from `apps/api/scripts/seed-user.mjs` (`SEED_PASSWORD` env overrides the default).
|
||||||
- **Dev DB**: `192.168.4.212:3307` (cubex Swarm stack `jorgecuadros-dev-db`). Credentials in gitignored `deploy/.env.dev`. **MinIO** for documents: `192.168.4.212:9100`, bucket `jorgecuadros-documents`.
|
- **Dev DB**: `192.168.4.212:3307` (cubex Swarm stack `jorgecuadros-dev-db`). Credentials in gitignored `deploy/.env.dev`. **MinIO** for documents: `192.168.4.212:9100`, bucket `jorgecuadros-documents`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -22,7 +23,11 @@ import {
|
|||||||
LedgerDirection,
|
LedgerDirection,
|
||||||
MovementSort,
|
MovementSort,
|
||||||
} from "./billing.service";
|
} from "./billing.service";
|
||||||
import { CreateMovementDto } from "./movement.dto";
|
import {
|
||||||
|
BatchCreateDto,
|
||||||
|
CreateMovementDto,
|
||||||
|
ResolveOutstandingDto,
|
||||||
|
} from "./movement.dto";
|
||||||
|
|
||||||
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
||||||
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
|
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
|
||||||
@@ -46,6 +51,11 @@ function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
|||||||
return allowed.includes(value as T) ? (value as T) : undefined;
|
return allowed.includes(value as T) ? (value as T) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tri-state query flag: "true"/"false" filter, anything else means no filter. */
|
||||||
|
function flag(v: string | undefined): boolean | undefined {
|
||||||
|
return v === "true" ? true : v === "false" ? false : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
||||||
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
||||||
if (!v) return undefined;
|
if (!v) return undefined;
|
||||||
@@ -97,6 +107,18 @@ export class BillingController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every movement cut against one check, with its total — the reconciliation
|
||||||
|
* view replacing the legacy REPORTE CHEQUE COUNT. Declared before the
|
||||||
|
* `customers/:id` and `:id`-shaped routes so the literal path wins.
|
||||||
|
*/
|
||||||
|
@Get("by-check")
|
||||||
|
byCheck(@Query("checkNumber") checkNumber?: string) {
|
||||||
|
const n = checkNumber?.trim();
|
||||||
|
if (!n) throw new BadRequestException("checkNumber es obligatorio");
|
||||||
|
return this.billing.byCheck(n);
|
||||||
|
}
|
||||||
|
|
||||||
/** One customer's full statement across both business lines. */
|
/** One customer's full statement across both business lines. */
|
||||||
@Get("customers/:id")
|
@Get("customers/:id")
|
||||||
statement(@Param("id") id: string) {
|
statement(@Param("id") id: string) {
|
||||||
@@ -115,6 +137,8 @@ export class BillingController {
|
|||||||
@Query("typeId") typeId?: string,
|
@Query("typeId") typeId?: string,
|
||||||
@Query("source") source?: string,
|
@Query("source") source?: string,
|
||||||
@Query("customerId") customerId?: string,
|
@Query("customerId") customerId?: string,
|
||||||
|
@Query("outstanding") outstanding?: string,
|
||||||
|
@Query("checkNumber") checkNumber?: string,
|
||||||
@Query("from") from?: string,
|
@Query("from") from?: string,
|
||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
@Query("sort") sort?: string,
|
@Query("sort") sort?: string,
|
||||||
@@ -129,6 +153,8 @@ export class BillingController {
|
|||||||
typeId: typeId || undefined,
|
typeId: typeId || undefined,
|
||||||
source: source || undefined,
|
source: source || undefined,
|
||||||
customerId: customerId || undefined,
|
customerId: customerId || undefined,
|
||||||
|
outstanding: flag(outstanding),
|
||||||
|
checkNumber: checkNumber?.trim() || undefined,
|
||||||
from: parseDate(from),
|
from: parseDate(from),
|
||||||
to: parseDate(to, true),
|
to: parseDate(to, true),
|
||||||
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
|
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
|
||||||
@@ -150,6 +176,42 @@ export class BillingController {
|
|||||||
return tx;
|
return tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch capture: many customers' receipts against one physical check.
|
||||||
|
* Same ability as single capture — batching is still capturing.
|
||||||
|
*/
|
||||||
|
@Post("batch")
|
||||||
|
@RequireAbility("ledger:create")
|
||||||
|
async createBatch(@Body() dto: BatchCreateDto, @Req() req: Request) {
|
||||||
|
const result = await this.billing.createBatch(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "ledger.batch", {
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
count: result.count,
|
||||||
|
total: result.total,
|
||||||
|
currency: result.currency,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an outstanding (NOPAGO) row — `ledger:create`, not `ledger:void`:
|
||||||
|
* resolving completes a capture, it doesn't reverse one.
|
||||||
|
*/
|
||||||
|
@Post(":id/resolve-outstanding")
|
||||||
|
@RequireAbility("ledger:create")
|
||||||
|
async resolveOutstanding(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: ResolveOutstandingDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const tx = await this.billing.resolveOutstanding(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "ledger.resolve-outstanding", {
|
||||||
|
transactionId: id,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
});
|
||||||
|
return tx;
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":id/void")
|
@Post(":id/void")
|
||||||
@RequireAbility("ledger:void")
|
@RequireAbility("ledger:void")
|
||||||
async void(@Param("id") id: string, @Req() req: Request) {
|
async void(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
|
import {
|
||||||
|
Prisma,
|
||||||
|
TransactionCaptureSource,
|
||||||
|
TransactionDomain,
|
||||||
|
} from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CreateMovementDto } from "./movement.dto";
|
import {
|
||||||
|
BatchCreateDto,
|
||||||
|
CreateMovementDto,
|
||||||
|
ResolveOutstandingDto,
|
||||||
|
} from "./movement.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared billing / statements module — plan step 6.
|
* Shared billing / statements module — plan step 6.
|
||||||
@@ -54,12 +62,29 @@ export interface MovementParams {
|
|||||||
typeId?: string;
|
typeId?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
/** Restrict to captured-but-unpaid rows (the legacy NOPAGO worklist). */
|
||||||
|
outstanding?: boolean;
|
||||||
|
/** Groups a capture batch: every row cut against one physical check. */
|
||||||
|
checkNumber?: string;
|
||||||
/** Inclusive ISO date bounds on `transactionDate`. */
|
/** Inclusive ISO date bounds on `transactionDate`. */
|
||||||
from?: Date;
|
from?: Date;
|
||||||
to?: Date;
|
to?: Date;
|
||||||
sort: MovementSort;
|
sort: MovementSort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-client-supplied options for a capture. Kept out of the DTO on purpose:
|
||||||
|
* these are set by the calling *module*, never by an HTTP body, so a client
|
||||||
|
* can't label its own rows as machine-captured or forge a capture ref.
|
||||||
|
* See `BillingService.createBatch` for the seam contract.
|
||||||
|
*/
|
||||||
|
export interface CaptureOptions {
|
||||||
|
/** Defaults to BATCH for the HTTP path; the OCR pipeline passes OCR. */
|
||||||
|
source?: TransactionCaptureSource;
|
||||||
|
/** Per-line artifact ids, positionally parallel to `dto.lines`. */
|
||||||
|
refs?: (string | undefined)[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface BalanceParams {
|
export interface BalanceParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
page: number;
|
page: number;
|
||||||
@@ -112,6 +137,21 @@ function dec(v: Prisma.Decimal | null | undefined): string {
|
|||||||
*/
|
*/
|
||||||
const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outstanding ("NOPAGO") rows are captured but unpaid — the office recorded the
|
||||||
|
* bill without funds to cover it. They are excluded from every *balance*
|
||||||
|
* aggregate, exactly as the legacy `SALDOS ULTIMO 0` query did with its
|
||||||
|
* `HAVING NOPAGO = 0`: the office hasn't paid the bill, so it isn't yet owed by
|
||||||
|
* the customer. Resolving one (POST /billing/:id/resolve-outstanding) clears the
|
||||||
|
* flag and the amount starts counting.
|
||||||
|
*
|
||||||
|
* This is deliberately narrower than NOT_VOIDED. Voided rows are excluded
|
||||||
|
* everywhere; outstanding rows are excluded only from balances — the movement
|
||||||
|
* browser still totals them, because "how much water did we capture in April"
|
||||||
|
* means every captured row regardless of whether the check cleared.
|
||||||
|
*/
|
||||||
|
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source tables excluded from the customer-facing statement.
|
* Source tables excluded from the customer-facing statement.
|
||||||
*
|
*
|
||||||
@@ -160,6 +200,10 @@ export class BillingService {
|
|||||||
if (p.typeId) and.push({ typeId: p.typeId });
|
if (p.typeId) and.push({ typeId: p.typeId });
|
||||||
if (p.source) and.push({ legacySourceTable: p.source });
|
if (p.source) and.push({ legacySourceTable: p.source });
|
||||||
if (p.customerId) and.push({ customerId: p.customerId });
|
if (p.customerId) and.push({ customerId: p.customerId });
|
||||||
|
if (p.outstanding !== undefined) and.push({ outstanding: p.outstanding });
|
||||||
|
// Exact match, not `contains`: this is the by-check reconciliation lookup,
|
||||||
|
// where "1234" must not drag in "51234".
|
||||||
|
if (p.checkNumber) and.push({ checkNumber: p.checkNumber });
|
||||||
if (p.from || p.to) {
|
if (p.from || p.to) {
|
||||||
and.push({
|
and.push({
|
||||||
transactionDate: {
|
transactionDate: {
|
||||||
@@ -216,6 +260,7 @@ export class BillingService {
|
|||||||
message: true,
|
message: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
voidedAt: true,
|
voidedAt: true,
|
||||||
|
outstanding: true,
|
||||||
type: { select: { nameEn: true, nameEs: true } },
|
type: { select: { nameEn: true, nameEs: true } },
|
||||||
customer: {
|
customer: {
|
||||||
select: { id: true, name: true, nameSource: true, city: true },
|
select: { id: true, name: true, nameSource: true, city: true },
|
||||||
@@ -263,6 +308,7 @@ export class BillingService {
|
|||||||
source: r.legacySourceTable,
|
source: r.legacySourceTable,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
voided: r.voidedAt != null,
|
voided: r.voidedAt != null,
|
||||||
|
outstanding: r.outstanding,
|
||||||
customerId: r.customer.id,
|
customerId: r.customer.id,
|
||||||
customerName: r.customer.name,
|
customerName: r.customer.name,
|
||||||
customerNameSource: r.customer.nameSource,
|
customerNameSource: r.customer.nameSource,
|
||||||
@@ -356,7 +402,7 @@ export class BillingService {
|
|||||||
MAX(t.transactionDate) AS lastMovement
|
MAX(t.transactionDate) AS lastMovement
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
WHERE t.voidedAt IS NULL ${nameFilter} ${txFilter}
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
||||||
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||||
${having}
|
${having}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
@@ -368,7 +414,10 @@ export class BillingService {
|
|||||||
SELECT c.id
|
SELECT c.id
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
WHERE 1 = 1 ${nameFilter} ${txFilter}
|
-- Must match the page query's filters exactly, or the total disagrees
|
||||||
|
-- with the rows. (The void exclusion was missing here before the
|
||||||
|
-- outstanding work; a voided-only customer inflated the count.)
|
||||||
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
||||||
GROUP BY c.id
|
GROUP BY c.id
|
||||||
${having}
|
${having}
|
||||||
) x
|
) x
|
||||||
@@ -601,7 +650,20 @@ export class BillingService {
|
|||||||
const rows = await this.prisma.transaction.findMany({
|
const rows = await this.prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
customerId,
|
customerId,
|
||||||
legacySourceTable: { notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[] },
|
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
||||||
|
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
||||||
|
// (which has no legacySourceTable) silently vanished from the
|
||||||
|
// statement while still showing in the movement browser. Rows the app
|
||||||
|
// books must appear on the customer's statement, so the null case is
|
||||||
|
// spelled out.
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{
|
||||||
|
legacySourceTable: {
|
||||||
|
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
select: {
|
select: {
|
||||||
@@ -616,6 +678,7 @@ export class BillingService {
|
|||||||
message: true,
|
message: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
voidedAt: true,
|
voidedAt: true,
|
||||||
|
outstanding: true,
|
||||||
type: { select: { nameEn: true, nameEs: true } },
|
type: { select: { nameEn: true, nameEs: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -624,9 +687,10 @@ export class BillingService {
|
|||||||
const movements = rows.map((r) => {
|
const movements = rows.map((r) => {
|
||||||
const voided = r.voidedAt != null;
|
const voided = r.voidedAt != null;
|
||||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||||
// A voided row does not move the running balance — it shows struck-through
|
// Neither a voided row nor an outstanding (unpaid) one moves the running
|
||||||
// with the balance unchanged from the previous live movement.
|
// balance — both show tagged, with the balance unchanged from the previous
|
||||||
const next = voided ? prev : prev.plus(r.amount);
|
// live movement. Outstanding rows start counting once resolved.
|
||||||
|
const next = voided || r.outstanding ? prev : prev.plus(r.amount);
|
||||||
running.set(r.currency, next);
|
running.set(r.currency, next);
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -642,6 +706,7 @@ export class BillingService {
|
|||||||
source: r.legacySourceTable,
|
source: r.legacySourceTable,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
voided,
|
voided,
|
||||||
|
outstanding: r.outstanding,
|
||||||
/** Balance in this row's currency after applying it. */
|
/** Balance in this row's currency after applying it. */
|
||||||
balanceAfter: next.toFixed(2),
|
balanceAfter: next.toFixed(2),
|
||||||
};
|
};
|
||||||
@@ -675,7 +740,9 @@ export class BillingService {
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.voidedAt != null) continue; // voided rows never enter a total
|
// Voided rows never enter a total; outstanding rows don't either until
|
||||||
|
// they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`).
|
||||||
|
if (r.voidedAt != null || r.outstanding) continue;
|
||||||
const c =
|
const c =
|
||||||
perCurrency.get(r.currency) ??
|
perCurrency.get(r.currency) ??
|
||||||
{
|
{
|
||||||
@@ -723,7 +790,7 @@ export class BillingService {
|
|||||||
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
||||||
>();
|
>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.voidedAt != null) continue;
|
if (r.voidedAt != null || r.outstanding) continue;
|
||||||
if (!r.amount.lessThan(0)) continue;
|
if (!r.amount.lessThan(0)) continue;
|
||||||
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
||||||
const key = `${name}|${r.currency}`;
|
const key = `${name}|${r.currency}`;
|
||||||
@@ -795,10 +862,220 @@ export class BillingService {
|
|||||||
reference: dto.reference,
|
reference: dto.reference,
|
||||||
checkNumber: dto.checkNumber,
|
checkNumber: dto.checkNumber,
|
||||||
message: dto.message,
|
message: dto.message,
|
||||||
|
outstanding: dto.outstanding ?? false,
|
||||||
|
captureSource: "MANUAL",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch capture by check — many customers' receipts against one physical
|
||||||
|
* check. One `$transaction`, so a bad line rejects the whole batch rather
|
||||||
|
* than leaving a half-captured check that reconciles against nothing.
|
||||||
|
*
|
||||||
|
* Returns the check-level total alongside the rows so the UI can show it
|
||||||
|
* against the physical check amount, which is the entire point of the legacy
|
||||||
|
* flow this replaces (`CAPTURA *` feeding `EDITA CHEQUE COUNT`).
|
||||||
|
*
|
||||||
|
* ── Integration seam for OCR auto-capture (RECEIPT_CAPTURE_SPEC §2) ────────
|
||||||
|
* This method is the SINGLE write path for multi-row capture, and the OCR
|
||||||
|
* pipeline is required to post through it rather than writing `Transaction`
|
||||||
|
* rows itself — one validation path, one audit trail. Three guarantees exist
|
||||||
|
* for that caller specifically, and must not be broken:
|
||||||
|
*
|
||||||
|
* 1. `items[i]` corresponds to `dto.lines[i]`. Prisma's array
|
||||||
|
* `$transaction` preserves order, so the caller can zip the result back
|
||||||
|
* onto its own records — which is how `StatementDocument.postedTransactionId`
|
||||||
|
* gets set after a confirmed batch posts.
|
||||||
|
* 2. `opts.refs[i]` stamps `captureRef` on row `i` (a `StatementDocument.id`).
|
||||||
|
* Re-posting a ref that already has a live row is rejected, so a
|
||||||
|
* double-clicked "confirm" or a retried job cannot double-charge a
|
||||||
|
* customer. Voided rows don't block a re-post — a corrected statement
|
||||||
|
* must be re-postable after its bad row is voided.
|
||||||
|
* 3. `opts.source` records the capture path; it is NOT accepted over HTTP,
|
||||||
|
* so a client cannot label its hand-keyed rows as machine-captured.
|
||||||
|
*
|
||||||
|
* Everything the OCR module adds on top (batches, per-document status, the
|
||||||
|
* review queue) lives in its own module; nothing about it needs to change
|
||||||
|
* this signature.
|
||||||
|
*/
|
||||||
|
async createBatch(dto: BatchCreateDto, opts: CaptureOptions = {}) {
|
||||||
|
const date = new Date(dto.transactionDate);
|
||||||
|
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
||||||
|
|
||||||
|
// Validate every customer up front, in one query — a per-line lookup inside
|
||||||
|
// the transaction would be N round-trips and would fail halfway through.
|
||||||
|
const ids = [...new Set(dto.lines.map((l) => l.customerId))];
|
||||||
|
const found = await this.prisma.customer.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (found.length !== ids.length) {
|
||||||
|
const known = new Set(found.map((c) => c.id));
|
||||||
|
const missing = ids.filter((id) => !known.has(id));
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cliente(s) no encontrado(s): ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate-post guard (seam guarantee 2). Only live rows block: a voided
|
||||||
|
// row means the earlier post was reversed, so the corrected statement must
|
||||||
|
// be allowed through.
|
||||||
|
const refs = (opts.refs ?? []).filter((r): r is string => !!r);
|
||||||
|
if (refs.length) {
|
||||||
|
const clash = await this.prisma.transaction.findMany({
|
||||||
|
where: { captureRef: { in: refs }, voidedAt: null },
|
||||||
|
select: { captureRef: true },
|
||||||
|
});
|
||||||
|
if (clash.length) {
|
||||||
|
const dupes = [...new Set(clash.map((c) => c.captureRef))];
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Ya existen movimientos para: ${dupes.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = dto.currency ?? "MXN";
|
||||||
|
const source = opts.source ?? "BATCH";
|
||||||
|
const created = await this.prisma.$transaction(
|
||||||
|
dto.lines.map((line, i) =>
|
||||||
|
this.prisma.transaction.create({
|
||||||
|
data: {
|
||||||
|
customerId: line.customerId,
|
||||||
|
domain: dto.domain,
|
||||||
|
amount: line.amount,
|
||||||
|
transactionDate: date,
|
||||||
|
currency,
|
||||||
|
typeId: dto.typeId,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
period: line.period,
|
||||||
|
reference: line.reference,
|
||||||
|
message: line.message,
|
||||||
|
outstanding: line.outstanding ?? false,
|
||||||
|
captureSource: source,
|
||||||
|
captureRef: opts.refs?.[i],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Outstanding lines are captured but unfunded, so they don't belong in the
|
||||||
|
// figure staff reconcile against the physical check.
|
||||||
|
const total = created.reduce(
|
||||||
|
(sum, t) => (t.outstanding ? sum : sum.plus(t.amount)),
|
||||||
|
new Prisma.Decimal(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Parallel to `dto.lines` — see seam guarantee 1. */
|
||||||
|
items: created,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
currency,
|
||||||
|
source,
|
||||||
|
count: created.length,
|
||||||
|
outstandingCount: created.filter((t) => t.outstanding).length,
|
||||||
|
total: total.toFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an outstanding row: the check was finally cut. Takes the resolution
|
||||||
|
* date and check number and clears the flag, so the amount starts counting
|
||||||
|
* toward the balance. Legacy: "se actualiza registro con fecha del día y el
|
||||||
|
* cheque a pagar y quitas outstanding".
|
||||||
|
*/
|
||||||
|
async resolveOutstanding(id: string, dto: ResolveOutstandingDto) {
|
||||||
|
const tx = await this.prisma.transaction.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, voidedAt: true, outstanding: true },
|
||||||
|
});
|
||||||
|
if (!tx) throw new NotFoundException(`Transaction ${id} not found`);
|
||||||
|
if (tx.voidedAt) {
|
||||||
|
throw new BadRequestException("El movimiento está anulado");
|
||||||
|
}
|
||||||
|
if (!tx.outstanding) {
|
||||||
|
throw new BadRequestException("El movimiento no está pendiente de pago");
|
||||||
|
}
|
||||||
|
const date = new Date(dto.resolvedDate);
|
||||||
|
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
||||||
|
|
||||||
|
return this.prisma.transaction.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
outstanding: false,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
transactionDate: date,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every live movement cut against one check, plus its total — the
|
||||||
|
* reconciliation view replacing `EDITA CHEQUE ALF/COUNT/NUM` and
|
||||||
|
* `REPORTE POR CHEQUE`. Voided rows are dropped entirely (they reconcile
|
||||||
|
* against nothing); outstanding rows are listed but excluded from the total,
|
||||||
|
* since the check didn't fund them.
|
||||||
|
*/
|
||||||
|
async byCheck(checkNumber: string) {
|
||||||
|
const rows = await this.prisma.transaction.findMany({
|
||||||
|
where: { checkNumber, voidedAt: null },
|
||||||
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
transactionDate: true,
|
||||||
|
domain: true,
|
||||||
|
amount: true,
|
||||||
|
currency: true,
|
||||||
|
reference: true,
|
||||||
|
period: true,
|
||||||
|
message: true,
|
||||||
|
outstanding: true,
|
||||||
|
type: { select: { nameEn: true, nameEs: true } },
|
||||||
|
customer: { select: { id: true, name: true, nameSource: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per currency: a check is one currency in practice, but the ledger has
|
||||||
|
// both and this module never sums across them.
|
||||||
|
const totals = new Map<string, { currency: string; total: Prisma.Decimal; count: number }>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.outstanding) continue;
|
||||||
|
const e =
|
||||||
|
totals.get(r.currency) ??
|
||||||
|
{ currency: r.currency, total: new Prisma.Decimal(0), count: 0 };
|
||||||
|
e.total = e.total.plus(r.amount);
|
||||||
|
e.count += 1;
|
||||||
|
totals.set(r.currency, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkNumber,
|
||||||
|
items: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
transactionDate: r.transactionDate,
|
||||||
|
domain: r.domain,
|
||||||
|
amount: r.amount,
|
||||||
|
currency: r.currency,
|
||||||
|
direction: r.amount.lessThan(0) ? "charge" : "credit",
|
||||||
|
reference: r.reference,
|
||||||
|
period: r.period,
|
||||||
|
message: r.message,
|
||||||
|
outstanding: r.outstanding,
|
||||||
|
type: r.type,
|
||||||
|
customerId: r.customer.id,
|
||||||
|
customerName: r.customer.name,
|
||||||
|
customerNameSource: r.customer.nameSource,
|
||||||
|
})),
|
||||||
|
count: rows.length,
|
||||||
|
outstandingCount: rows.filter((r) => r.outstanding).length,
|
||||||
|
totals: [...totals.values()].map((t) => ({
|
||||||
|
currency: t.currency,
|
||||||
|
total: t.total.toFixed(2),
|
||||||
|
count: t.count,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Reverse a movement by marking it voided; it stops counting toward totals. */
|
/** Reverse a movement by marking it voided; it stops counting toward totals. */
|
||||||
async voidMovement(id: string, userId: string) {
|
async voidMovement(id: string, userId: string) {
|
||||||
const tx = await this.prisma.transaction.findUnique({
|
const tx = await this.prisma.transaction.findUnique({
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
import { Currency, TransactionDomain } from "@jorgecuadros/database";
|
import { Currency, TransactionDomain } from "@jorgecuadros/database";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,4 +30,56 @@ export class CreateMovementDto {
|
|||||||
@IsOptional() @IsString() reference?: string;
|
@IsOptional() @IsString() reference?: string;
|
||||||
@IsOptional() @IsString() checkNumber?: string;
|
@IsOptional() @IsString() checkNumber?: string;
|
||||||
@IsOptional() @IsString() message?: string;
|
@IsOptional() @IsString() message?: string;
|
||||||
|
/**
|
||||||
|
* Legacy "NOPAGO": the bill was captured but not actually paid (no funds).
|
||||||
|
* The row posts normally and stays visible, but is kept out of every balance
|
||||||
|
* aggregate until resolved — see BillingService's NOT_OUTSTANDING.
|
||||||
|
*/
|
||||||
|
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolving an outstanding row: the check finally got cut, so the movement
|
||||||
|
* takes the resolution date and check number and starts counting toward the
|
||||||
|
* balance. Legacy behavior: "se actualiza registro con fecha del día y el
|
||||||
|
* cheque a pagar y quitas outstanding".
|
||||||
|
*/
|
||||||
|
export class ResolveOutstandingDto {
|
||||||
|
@IsString() @MinLength(1) checkNumber!: string;
|
||||||
|
@IsString() @MinLength(1) resolvedDate!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One customer's line within a batch; check-level fields live on the parent. */
|
||||||
|
export class BatchLineDto {
|
||||||
|
@IsString() @MinLength(1) customerId!: string;
|
||||||
|
@IsNumber() amount!: number;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() reference?: string;
|
||||||
|
@IsOptional() @IsString() period?: string;
|
||||||
|
@IsOptional() @IsString() message?: string;
|
||||||
|
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch capture by check — the legacy "Editor" flow: key many customers'
|
||||||
|
* receipts against one check, then reconcile the captured total against the
|
||||||
|
* physical check. Deliberately NOT a persisted batch entity: `checkNumber` is
|
||||||
|
* already a column, and grouping by it answers every legacy by-check query.
|
||||||
|
*/
|
||||||
|
export class BatchCreateDto {
|
||||||
|
@IsEnum(TransactionDomain) domain!: TransactionDomain;
|
||||||
|
@IsString() @MinLength(1) transactionDate!: string;
|
||||||
|
@IsString() @MinLength(1) checkNumber!: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
|
@IsOptional() @IsString() typeId?: string;
|
||||||
|
|
||||||
|
// Capped so one request can't open a transaction over an unbounded row set;
|
||||||
|
// a physical check batch is tens of lines, not thousands.
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(500)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BatchLineDto)
|
||||||
|
lines!: BatchLineDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -949,6 +949,111 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REPORTE CHEQUE COUNT — everything captured against one check.
|
||||||
|
*
|
||||||
|
* The reconciliation half of the batch-capture flow (docs/RECEIPT_CAPTURE_SPEC
|
||||||
|
* §1.3): staff key many customers' receipts against one physical check, then
|
||||||
|
* check that what was captured adds up to what the check was cut for. Replaces
|
||||||
|
* `EDITA CHEQUE ALF/COUNT/NUM`, `REPORTE POR CHEQUE` and
|
||||||
|
* `REPORTE POR CHEQUE PARA ALFA` — four legacy objects, one parameterized
|
||||||
|
* report.
|
||||||
|
*
|
||||||
|
* Deliberately mirrors `BillingService.byCheck`'s rules rather than inventing
|
||||||
|
* its own: voided rows are dropped entirely, and outstanding (NOPAGO) rows are
|
||||||
|
* listed but excluded from the total, because the check never funded them.
|
||||||
|
*/
|
||||||
|
const chequeCount: ReportDef = {
|
||||||
|
slug: "cheque-count",
|
||||||
|
title: "Reporte por cheque",
|
||||||
|
description:
|
||||||
|
"Todos los movimientos capturados contra un mismo cheque, con el total " +
|
||||||
|
"para conciliar contra el importe físico del cheque. Los movimientos " +
|
||||||
|
"pendientes de pago (sin fondos) se listan pero no suman al total.",
|
||||||
|
domain: "estado-cuenta",
|
||||||
|
legacyName: "REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE COUNT",
|
||||||
|
format: "tabular",
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: "checkNumber",
|
||||||
|
label: "Número de cheque",
|
||||||
|
kind: "text",
|
||||||
|
placeholder: "Ej. 10432",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
|
{ key: "reference", label: "Referencia", type: "text" },
|
||||||
|
{ key: "period", label: "Periodo", type: "text" },
|
||||||
|
{ key: "concept", label: "Concepto", type: "text" },
|
||||||
|
{ key: "transactionDate", label: "Fecha", type: "date" },
|
||||||
|
{ key: "status", label: "Estado", type: "text" },
|
||||||
|
{ key: "amount", label: "Importe", type: "money", align: "right" },
|
||||||
|
],
|
||||||
|
async run(prisma, p) {
|
||||||
|
const checkNumber = p.checkNumber?.trim();
|
||||||
|
if (!checkNumber) {
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
totals: { movimientos: 0 },
|
||||||
|
subtitle: "Indique un número de cheque",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.transaction.findMany({
|
||||||
|
where: { checkNumber, ...NOT_VOIDED },
|
||||||
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
|
select: {
|
||||||
|
transactionDate: true,
|
||||||
|
amount: true,
|
||||||
|
currency: true,
|
||||||
|
reference: true,
|
||||||
|
period: true,
|
||||||
|
outstanding: true,
|
||||||
|
type: { select: { nameEn: true, nameEs: true } },
|
||||||
|
customer: { select: { name: true, nameMissing: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per currency, and never collapsed — same rule as the rest of the ledger.
|
||||||
|
const totals = new Map<string, Prisma.Decimal>();
|
||||||
|
let outstandingCount = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.outstanding) {
|
||||||
|
outstandingCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totals.set(
|
||||||
|
r.currency,
|
||||||
|
(totals.get(r.currency) ?? new Prisma.Decimal(0)).plus(r.amount),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalsOut: Record<string, string | number> = {
|
||||||
|
movimientos: rows.length,
|
||||||
|
};
|
||||||
|
for (const [currency, sum] of totals) {
|
||||||
|
totalsOut[`total ${currency}`] = sum.toFixed(2);
|
||||||
|
}
|
||||||
|
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rows.map((r) => ({
|
||||||
|
customerName: nameOf(r.customer),
|
||||||
|
reference: r.reference ?? "—",
|
||||||
|
period: r.period ?? "—",
|
||||||
|
concept: r.type?.nameEs || r.type?.nameEn || "Sin clasificar",
|
||||||
|
transactionDate: r.transactionDate.toISOString().slice(0, 10),
|
||||||
|
status: r.outstanding ? "Sin fondos" : "Pagado",
|
||||||
|
amount: r.amount.toFixed(2),
|
||||||
|
currency: r.currency,
|
||||||
|
})),
|
||||||
|
totals: totalsOut,
|
||||||
|
subtitle: `Cheque ${checkNumber} · ${rows.length} movimientos`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ export */
|
/* ------------------------------------------------------------------ export */
|
||||||
|
|
||||||
export const REPORTS: ReportDef[] = [
|
export const REPORTS: ReportDef[] = [
|
||||||
@@ -959,6 +1064,7 @@ export const REPORTS: ReportDef[] = [
|
|||||||
vigente,
|
vigente,
|
||||||
avisoRenovacion,
|
avisoRenovacion,
|
||||||
edoCuentaDatos,
|
edoCuentaDatos,
|
||||||
|
chequeCount,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function findReport(slug: string): ReportDef | undefined {
|
export function findReport(slug: string): ReportDef | undefined {
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
|
import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
BatchCreateInput,
|
||||||
|
BillingFacets,
|
||||||
|
ByCheckResponse,
|
||||||
|
Currency,
|
||||||
|
LedgerCurrency,
|
||||||
|
TransactionDomain,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch capture by check — the "Editor" screen from the legacy system
|
||||||
|
* (docs/RECEIPT_CAPTURE_SPEC.md §1.2).
|
||||||
|
*
|
||||||
|
* Staff key many customers' receipts against ONE physical check before cutting
|
||||||
|
* it, then check that the captured total matches the check's amount. That
|
||||||
|
* reconciliation is the whole point, so the running total is the most prominent
|
||||||
|
* thing on the page and an optional "importe del cheque" field turns it into a
|
||||||
|
* live difference.
|
||||||
|
*
|
||||||
|
* No batch entity is persisted: `checkNumber` is a plain column, and grouping
|
||||||
|
* by it answers every by-check question (see the "Reporte por cheque" report).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DOMAINS: { key: TransactionDomain; label: string }[] = [
|
||||||
|
{ key: "UTILITY", label: "Servicios" },
|
||||||
|
{ key: "INSURANCE", label: "Seguros" },
|
||||||
|
{ key: "TRUST", label: "Fideicomiso" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Line {
|
||||||
|
/** Local row key — lines have no server identity until the batch posts. */
|
||||||
|
key: number;
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
amount: string;
|
||||||
|
reference: string;
|
||||||
|
period: string;
|
||||||
|
outstanding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankLine(key: number): Line {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
customerId: "",
|
||||||
|
customerName: "",
|
||||||
|
amount: "",
|
||||||
|
reference: "",
|
||||||
|
period: "",
|
||||||
|
outstanding: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BatchCapturePage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<BatchCapture />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatchCapture() {
|
||||||
|
const canCapture = useCan("ledger:create");
|
||||||
|
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
||||||
|
|
||||||
|
// Check-level fields — shared by every line.
|
||||||
|
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
|
||||||
|
const [currency, setCurrency] = useState<LedgerCurrency>("MXN");
|
||||||
|
const [typeId, setTypeId] = useState("");
|
||||||
|
const [checkNumber, setCheckNumber] = useState("");
|
||||||
|
const [transactionDate, setTransactionDate] = useState(
|
||||||
|
new Date().toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
/** The physical check's amount, for reconciliation only — never submitted. */
|
||||||
|
const [checkAmount, setCheckAmount] = useState("");
|
||||||
|
|
||||||
|
const [lines, setLines] = useState<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
|
||||||
|
const [nextKey, setNextKey] = useState(4);
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [posted, setPosted] = useState<ByCheckResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filled = lines.filter(
|
||||||
|
(l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Charges are captured as positive numbers and signed on submit, matching
|
||||||
|
// MovementForm — staff type what's on the bill, not a negative.
|
||||||
|
const total = useMemo(
|
||||||
|
() =>
|
||||||
|
filled
|
||||||
|
.filter((l) => !l.outstanding)
|
||||||
|
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
|
||||||
|
[filled],
|
||||||
|
);
|
||||||
|
const outstandingTotal = useMemo(
|
||||||
|
() =>
|
||||||
|
filled
|
||||||
|
.filter((l) => l.outstanding)
|
||||||
|
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
|
||||||
|
[filled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkAmt = Number(checkAmount);
|
||||||
|
const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt);
|
||||||
|
const diff = hasCheckAmt ? checkAmt - total : 0;
|
||||||
|
const reconciled = hasCheckAmt && Math.abs(diff) < 0.005;
|
||||||
|
|
||||||
|
function update(key: number, patch: Partial<Line>) {
|
||||||
|
setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((ls) => [...ls, blankLine(nextKey)]);
|
||||||
|
setNextKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: number) {
|
||||||
|
setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!checkNumber.trim()) {
|
||||||
|
setError("Indica el número de cheque.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (filled.length === 0) {
|
||||||
|
setError("Captura al menos una línea con cliente y monto.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dupes = filled
|
||||||
|
.map((l) => l.customerId)
|
||||||
|
.filter((id, i, arr) => arr.indexOf(id) !== i);
|
||||||
|
if (dupes.length) {
|
||||||
|
const names = filled
|
||||||
|
.filter((l) => dupes.includes(l.customerId))
|
||||||
|
.map((l) => l.customerName);
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Hay más de una línea para el mismo cliente (${[...new Set(names)].join(
|
||||||
|
", ",
|
||||||
|
)}). ¿Continuar?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: BatchCreateInput = {
|
||||||
|
domain,
|
||||||
|
transactionDate,
|
||||||
|
checkNumber: checkNumber.trim(),
|
||||||
|
currency: currency as Currency,
|
||||||
|
typeId: typeId || undefined,
|
||||||
|
lines: filled.map((l) => ({
|
||||||
|
customerId: l.customerId,
|
||||||
|
// Every line of a check batch is a charge the office paid out.
|
||||||
|
amount: -Math.abs(Number(l.amount)),
|
||||||
|
reference: l.reference.trim() || undefined,
|
||||||
|
period: l.period.trim() || undefined,
|
||||||
|
outstanding: l.outstanding || undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await createMovementBatch(payload);
|
||||||
|
// Re-read through the by-check view so the confirmation shows what's
|
||||||
|
// actually stored (including anything captured against this check
|
||||||
|
// earlier), not just what this request sent.
|
||||||
|
setPosted(await getByCheck(payload.checkNumber));
|
||||||
|
} catch (e2) {
|
||||||
|
setError((e2 as Error)?.message ?? "No se pudo guardar el lote.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setPosted(null);
|
||||||
|
setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]);
|
||||||
|
setNextKey((k) => k + 3);
|
||||||
|
setCheckNumber("");
|
||||||
|
setCheckAmount("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canCapture) {
|
||||||
|
return (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tienes permiso para capturar movimientos.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posted) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Lote capturado</h1>
|
||||||
|
<p className="eyebrow">
|
||||||
|
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
|
||||||
|
{posted.count === 1 ? "movimiento" : "movimientos"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10 }}>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={reset}>
|
||||||
|
Capturar otro cheque
|
||||||
|
</button>
|
||||||
|
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||||
|
Volver a estado de cuenta
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filtered-totals" style={{ marginBottom: 16 }}>
|
||||||
|
{posted.totals.map((t) => (
|
||||||
|
<div className="filtered-total" key={t.currency}>
|
||||||
|
<span className="filtered-total-cur">{t.currency}</span>
|
||||||
|
<span className="filtered-total-net">
|
||||||
|
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
|
||||||
|
</span>
|
||||||
|
<span>{formatNumber(t.count)} movimientos</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{posted.outstandingCount > 0 && (
|
||||||
|
<div className="filtered-total">
|
||||||
|
<span>
|
||||||
|
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
|
||||||
|
total)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Cliente</th>
|
||||||
|
<th>Referencia</th>
|
||||||
|
<th>Periodo</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th className="num">Monto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{posted.items.map((i) => (
|
||||||
|
<tr key={i.id}>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
href={`/estado-cuenta/${i.customerId}`}
|
||||||
|
className="inline-link"
|
||||||
|
>
|
||||||
|
{i.customerName}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td>{i.reference || "—"}</td>
|
||||||
|
<td>{i.period || "—"}</td>
|
||||||
|
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className="tx-amount neg">
|
||||||
|
{formatMoney(i.amount, i.currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="muted" style={{ marginTop: 14 }}>
|
||||||
|
Para imprimir la conciliación, usa el reporte{" "}
|
||||||
|
<Link
|
||||||
|
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
|
||||||
|
posted.checkNumber,
|
||||||
|
)}`}
|
||||||
|
className="inline-link"
|
||||||
|
>
|
||||||
|
Reporte por cheque
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Captura por cheque</h1>
|
||||||
|
<p className="eyebrow">
|
||||||
|
Captura los recibos de varios clientes contra un mismo cheque y
|
||||||
|
concilia el total antes de guardar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||||
|
Cancelar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
||||||
|
Datos del cheque
|
||||||
|
</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Número de cheque *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={checkNumber}
|
||||||
|
onChange={(e) => setCheckNumber(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Fecha *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={transactionDate}
|
||||||
|
onChange={(e) => setTransactionDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Línea de negocio *</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
|
||||||
|
>
|
||||||
|
{DOMAINS.map((d) => (
|
||||||
|
<option key={d.key} value={d.key}>
|
||||||
|
{d.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Moneda *</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
||||||
|
>
|
||||||
|
<option value="MXN">Pesos (MXN)</option>
|
||||||
|
<option value="USD">Dólares (USD)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Concepto</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={typeId}
|
||||||
|
onChange={(e) => setTypeId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">(sin concepto)</option>
|
||||||
|
{facets?.types.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{txTypeLabel({ nameEn: t.name })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Importe del cheque</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={checkAmount}
|
||||||
|
onChange={(e) => setCheckAmount(e.target.value)}
|
||||||
|
placeholder="Para conciliar"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 className="section-title" style={{ margin: 0 }}>
|
||||||
|
Recibos ({formatNumber(filled.length)})
|
||||||
|
</h2>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={addLine}>
|
||||||
|
Agregar línea
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ minWidth: 240 }}>Cliente *</th>
|
||||||
|
<th style={{ minWidth: 120 }}>Referencia</th>
|
||||||
|
<th style={{ minWidth: 100 }}>Periodo</th>
|
||||||
|
<th style={{ minWidth: 110 }} className="num">
|
||||||
|
Monto *
|
||||||
|
</th>
|
||||||
|
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
|
||||||
|
<th style={{ width: 1 }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((l) => (
|
||||||
|
<tr key={l.key}>
|
||||||
|
<td>
|
||||||
|
<CustomerPicker
|
||||||
|
value={l.customerId}
|
||||||
|
valueName={l.customerId ? l.customerName : undefined}
|
||||||
|
onPick={(id, name) =>
|
||||||
|
update(l.key, { customerId: id, customerName: name })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={l.reference}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(l.key, { reference: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={l.period}
|
||||||
|
onChange={(e) => update(l.key, { period: e.target.value })}
|
||||||
|
placeholder="2026-07"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="input num"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={l.amount}
|
||||||
|
onChange={(e) => update(l.key, { amount: e.target.value })}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: "center" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={l.outstanding}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(l.key, { outstanding: e.target.checked })
|
||||||
|
}
|
||||||
|
aria-label="Sin fondos"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ padding: "4px 10px", fontSize: 12 }}
|
||||||
|
onClick={() => removeLine(l.key)}
|
||||||
|
disabled={lines.length === 1}
|
||||||
|
>
|
||||||
|
Quitar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
||||||
|
Conciliación
|
||||||
|
</h2>
|
||||||
|
<div className="filtered-totals">
|
||||||
|
<div className="filtered-total">
|
||||||
|
<span className="filtered-total-cur">{currency}</span>
|
||||||
|
<span className="filtered-total-net">
|
||||||
|
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
|
||||||
|
</span>
|
||||||
|
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
|
||||||
|
</div>
|
||||||
|
{outstandingTotal > 0 && (
|
||||||
|
<div className="filtered-total">
|
||||||
|
<span>
|
||||||
|
Sin fondos{" "}
|
||||||
|
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
|
||||||
|
(no suma al cheque)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasCheckAmt && (
|
||||||
|
<div className="filtered-total">
|
||||||
|
<span className="filtered-total-net">
|
||||||
|
{reconciled ? (
|
||||||
|
<strong className="tx-amount pos">Cuadra con el cheque</strong>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Diferencia{" "}
|
||||||
|
<strong className="tx-amount neg">
|
||||||
|
{formatMoney(String(diff), currency)}
|
||||||
|
</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={saving || filled.length === 0}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? "Guardando…"
|
||||||
|
: `Capturar ${formatNumber(filled.length)} ${
|
||||||
|
filled.length === 1 ? "recibo" : "recibos"
|
||||||
|
}`}
|
||||||
|
</button>
|
||||||
|
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||||
|
Cancelar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getBillingStats,
|
getBillingStats,
|
||||||
listBalances,
|
listBalances,
|
||||||
listMovements,
|
listMovements,
|
||||||
|
resolveOutstanding,
|
||||||
voidMovement,
|
voidMovement,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useCan } from "@/lib/abilities";
|
import { useCan } from "@/lib/abilities";
|
||||||
@@ -117,6 +118,8 @@ function BillingBrowser() {
|
|||||||
const [direction, setDirection] = useState<LedgerDirection | "">("");
|
const [direction, setDirection] = useState<LedgerDirection | "">("");
|
||||||
const [typeId, setTypeId] = useState("");
|
const [typeId, setTypeId] = useState("");
|
||||||
const [source, setSource] = useState("");
|
const [source, setSource] = useState("");
|
||||||
|
// "" = no filter, "true" = only NOPAGO rows, "false" = only settled ones.
|
||||||
|
const [outstanding, setOutstanding] = useState<"" | "true" | "false">("");
|
||||||
const [from, setFrom] = useState("");
|
const [from, setFrom] = useState("");
|
||||||
const [to, setTo] = useState("");
|
const [to, setTo] = useState("");
|
||||||
const [movementSort, setMovementSort] = useState<MovementSort>("date_desc");
|
const [movementSort, setMovementSort] = useState<MovementSort>("date_desc");
|
||||||
@@ -126,6 +129,7 @@ function BillingBrowser() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [captureOpen, setCaptureOpen] = useState(false);
|
const [captureOpen, setCaptureOpen] = useState(false);
|
||||||
|
const [resolving, setResolving] = useState<MovementListItem | null>(null);
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
@@ -165,6 +169,7 @@ function BillingBrowser() {
|
|||||||
direction: direction || undefined,
|
direction: direction || undefined,
|
||||||
typeId: typeId || undefined,
|
typeId: typeId || undefined,
|
||||||
source: source || undefined,
|
source: source || undefined,
|
||||||
|
outstanding: outstanding === "" ? undefined : outstanding === "true",
|
||||||
from: from || undefined,
|
from: from || undefined,
|
||||||
to: to || undefined,
|
to: to || undefined,
|
||||||
sort: movementSort,
|
sort: movementSort,
|
||||||
@@ -188,6 +193,7 @@ function BillingBrowser() {
|
|||||||
direction,
|
direction,
|
||||||
typeId,
|
typeId,
|
||||||
source,
|
source,
|
||||||
|
outstanding,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
movementSort,
|
movementSort,
|
||||||
@@ -304,16 +310,35 @@ function BillingBrowser() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{view === "movimientos" && canCapture && (
|
{view === "movimientos" && canCapture && (
|
||||||
<button
|
<div style={{ display: "flex", gap: 10 }}>
|
||||||
type="button"
|
<Link href="/estado-cuenta/lote" className="btn btn-outline">
|
||||||
className="btn btn-primary"
|
Captura por cheque
|
||||||
onClick={() => setCaptureOpen((v) => !v)}
|
</Link>
|
||||||
>
|
<button
|
||||||
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
type="button"
|
||||||
</button>
|
className="btn btn-primary"
|
||||||
|
onClick={() => setCaptureOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{view === "movimientos" && resolving && (
|
||||||
|
<ResolveDialog
|
||||||
|
movement={resolving}
|
||||||
|
onCancel={() => setResolving(null)}
|
||||||
|
onDone={() => {
|
||||||
|
setResolving(null);
|
||||||
|
runSearch(movements?.page ?? 1);
|
||||||
|
getBillingStats()
|
||||||
|
.then(setStats)
|
||||||
|
.catch(() => setStats(null));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{view === "movimientos" && captureOpen && (
|
{view === "movimientos" && captureOpen && (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
@@ -447,6 +472,21 @@ function BillingBrowser() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Estado de pago</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={outstanding}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOutstanding(e.target.value as "" | "true" | "false")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="true">Sin fondos (pendientes)</option>
|
||||||
|
<option value="false">Pagados</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="filter-field">
|
<label className="filter-field">
|
||||||
<span className="filter-label">Desde</span>
|
<span className="filter-label">Desde</span>
|
||||||
<input
|
<input
|
||||||
@@ -552,7 +592,9 @@ function BillingBrowser() {
|
|||||||
<th>Concepto</th>
|
<th>Concepto</th>
|
||||||
<th>Referencia</th>
|
<th>Referencia</th>
|
||||||
<th className="num">Monto</th>
|
<th className="num">Monto</th>
|
||||||
{canVoid && <th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>}
|
{(canVoid || canCapture) && (
|
||||||
|
<th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -561,6 +603,8 @@ function BillingBrowser() {
|
|||||||
key={m.id}
|
key={m.id}
|
||||||
m={m}
|
m={m}
|
||||||
canVoid={canVoid}
|
canVoid={canVoid}
|
||||||
|
canCapture={canCapture}
|
||||||
|
onResolve={setResolving}
|
||||||
onVoided={() => {
|
onVoided={() => {
|
||||||
runSearch(movements?.page ?? 1);
|
runSearch(movements?.page ?? 1);
|
||||||
getBillingStats()
|
getBillingStats()
|
||||||
@@ -799,11 +843,15 @@ function BalanceRow({
|
|||||||
function MovementRow({
|
function MovementRow({
|
||||||
m,
|
m,
|
||||||
canVoid,
|
canVoid,
|
||||||
|
canCapture,
|
||||||
onVoided,
|
onVoided,
|
||||||
|
onResolve,
|
||||||
}: {
|
}: {
|
||||||
m: MovementListItem;
|
m: MovementListItem;
|
||||||
canVoid: boolean;
|
canVoid: boolean;
|
||||||
|
canCapture: boolean;
|
||||||
onVoided: () => void;
|
onVoided: () => void;
|
||||||
|
onResolve: (m: MovementListItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -854,11 +902,29 @@ function MovementRow({
|
|||||||
</span>
|
</span>
|
||||||
<div className="tx-cur">
|
<div className="tx-cur">
|
||||||
{m.currency} · {directionLabel(m.direction)}
|
{m.currency} · {directionLabel(m.direction)}
|
||||||
|
{m.outstanding && !m.voided && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<span className="tx-outstanding">sin fondos</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{canVoid && (
|
{(canVoid || canCapture) && (
|
||||||
<td style={{ whiteSpace: "nowrap" }}>
|
<td style={{ whiteSpace: "nowrap" }}>
|
||||||
{!m.voided && (
|
{/* Resolver only makes sense on a live outstanding row, and it's a
|
||||||
|
capture action (completing one), not a void. */}
|
||||||
|
{!m.voided && m.outstanding && canCapture && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ padding: "4px 10px", fontSize: 12 }}
|
||||||
|
onClick={() => onResolve(m)}
|
||||||
|
>
|
||||||
|
Resolver
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!m.voided && canVoid && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
@@ -875,6 +941,97 @@ function MovementRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an outstanding row: the check finally got cut. Takes the check number
|
||||||
|
* and the date it was paid, which also becomes the movement's date — the legacy
|
||||||
|
* behavior, since the ledger date is when money actually moved.
|
||||||
|
*/
|
||||||
|
function ResolveDialog({
|
||||||
|
movement,
|
||||||
|
onDone,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
movement: MovementListItem;
|
||||||
|
onDone: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [checkNumber, setCheckNumber] = useState("");
|
||||||
|
const [resolvedDate, setResolvedDate] = useState(
|
||||||
|
new Date().toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!checkNumber.trim()) {
|
||||||
|
setError("Indica el número de cheque.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await resolveOutstanding(movement.id, {
|
||||||
|
checkNumber: checkNumber.trim(),
|
||||||
|
resolvedDate,
|
||||||
|
});
|
||||||
|
onDone();
|
||||||
|
} catch (e2) {
|
||||||
|
setError((e2 as Error)?.message ?? "No se pudo resolver el movimiento.");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 6 }}>
|
||||||
|
Resolver movimiento sin fondos
|
||||||
|
</h2>
|
||||||
|
<p className="muted" style={{ marginBottom: 14 }}>
|
||||||
|
{movement.customerName} · {formatMoney(movement.amount, movement.currency)}{" "}
|
||||||
|
{movement.currency}
|
||||||
|
{movement.reference ? ` · ${movement.reference}` : ""}
|
||||||
|
</p>
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Número de cheque *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={checkNumber}
|
||||||
|
onChange={(e) => setCheckNumber(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Fecha de pago *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={resolvedDate}
|
||||||
|
onChange={(e) => setResolvedDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="muted" style={{ fontSize: 13, marginTop: 10 }}>
|
||||||
|
El movimiento tomará esta fecha y empezará a contar en el saldo del
|
||||||
|
cliente.
|
||||||
|
</p>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? "Resolviendo…" : "Resolver"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={onCancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Pager({
|
function Pager({
|
||||||
page,
|
page,
|
||||||
pageCount,
|
pageCount,
|
||||||
|
|||||||
@@ -2102,6 +2102,14 @@ button {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Captured-but-unpaid (legacy NOPAGO). Deliberately not the same treatment as
|
||||||
|
a voided row: the movement is real and still pending, it just doesn't count
|
||||||
|
toward the balance until a check resolves it. */
|
||||||
|
.tx-outstanding {
|
||||||
|
color: var(--warn, #b45309);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Charges broken out by concept, with a proportional bar. `.card` carries no
|
/* Charges broken out by concept, with a proportional bar. `.card` carries no
|
||||||
padding, so the list pads itself — otherwise the total sits on the border. */
|
padding, so the list pads itself — otherwise the total sits on the border. */
|
||||||
.concept-list {
|
.concept-list {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[]
|
|||||||
{ href: "/servicios", label: "Propiedades" },
|
{ href: "/servicios", label: "Propiedades" },
|
||||||
{ href: "/polizas", label: "Pólizas" },
|
{ href: "/polizas", label: "Pólizas" },
|
||||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||||
|
// Daily data-entry screen (the legacy "Editor"), so it earns a top-level
|
||||||
|
// entry rather than living one click inside the Movimientos tab. Hidden from
|
||||||
|
// VIEWER, who can't capture anyway — the page itself also refuses.
|
||||||
|
{ href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
|
||||||
{ href: "/banco", label: "Chequera" },
|
{ href: "/banco", label: "Chequera" },
|
||||||
{ href: "/reportes", label: "Reportes" },
|
{ href: "/reportes", label: "Reportes" },
|
||||||
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||||
@@ -27,12 +31,33 @@ const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[]
|
|||||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which nav entry is highlighted for a path. Longest matching href wins, so a
|
||||||
|
* nested route (`/estado-cuenta/lote`) highlights its own entry instead of also
|
||||||
|
* lighting up its parent (`/estado-cuenta`) — while `/estado-cuenta/<id>`, which
|
||||||
|
* has no entry of its own, still correctly highlights the parent.
|
||||||
|
*/
|
||||||
|
function activeHref(pathname: string | null): string | null {
|
||||||
|
if (!pathname) return null;
|
||||||
|
let best: string | null = null;
|
||||||
|
for (const item of NAV) {
|
||||||
|
const match = item.exact
|
||||||
|
? pathname === item.href
|
||||||
|
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
if (match && (best === null || item.href.length > best.length)) {
|
||||||
|
best = item.href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [checking, setChecking] = useState(true);
|
const [checking, setChecking] = useState(true);
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const current = activeHref(pathname);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -94,9 +119,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<nav className="appbar-nav" aria-label="Principal">
|
<nav className="appbar-nav" aria-label="Principal">
|
||||||
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
||||||
(item) => {
|
(item) => {
|
||||||
const active = item.exact
|
const active = current === item.href;
|
||||||
? pathname === item.href
|
|
||||||
: pathname?.startsWith(item.href) ?? false;
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
|
|||||||
@@ -62,9 +62,15 @@ export function MovementForm({
|
|||||||
const [reference, setReference] = useState("");
|
const [reference, setReference] = useState("");
|
||||||
const [checkNumber, setCheckNumber] = useState("");
|
const [checkNumber, setCheckNumber] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
const [outstanding, setOutstanding] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// "Sin fondos" is a per-service *charge* concept: the office recorded a
|
||||||
|
// utility bill it couldn't cover. It never applies to a credit (a payment
|
||||||
|
// that arrived is, by definition, funded) or to the insurance/trust lines.
|
||||||
|
const canBeOutstanding = domain === "UTILITY" && direction === "charge";
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
@@ -88,6 +94,9 @@ export function MovementForm({
|
|||||||
reference: s(reference),
|
reference: s(reference),
|
||||||
checkNumber: s(checkNumber),
|
checkNumber: s(checkNumber),
|
||||||
message: s(message),
|
message: s(message),
|
||||||
|
// Guarded by canBeOutstanding so a stale checkbox can't ride along after
|
||||||
|
// the user switches the row to a credit or another business line.
|
||||||
|
outstanding: canBeOutstanding && outstanding ? true : undefined,
|
||||||
};
|
};
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -225,6 +234,28 @@ export function MovementForm({
|
|||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{canBeOutstanding && (
|
||||||
|
<label
|
||||||
|
className="field"
|
||||||
|
style={{ marginTop: 16, flexDirection: "row", alignItems: "center", gap: 10 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={outstanding}
|
||||||
|
onChange={(e) => setOutstanding(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="field-label" style={{ display: "block" }}>
|
||||||
|
Sin fondos (pendiente de pago)
|
||||||
|
</span>
|
||||||
|
<span className="muted" style={{ fontSize: 13 }}>
|
||||||
|
El cargo se registra pero no afecta el saldo del cliente hasta
|
||||||
|
que se resuelva con un cheque.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ import type {
|
|||||||
BankSort,
|
BankSort,
|
||||||
BankStats,
|
BankStats,
|
||||||
BankSummary,
|
BankSummary,
|
||||||
|
BatchCreateInput,
|
||||||
|
BatchCreateResponse,
|
||||||
BillingFacets,
|
BillingFacets,
|
||||||
BillingStats,
|
BillingStats,
|
||||||
BusinessLine,
|
BusinessLine,
|
||||||
|
ByCheckResponse,
|
||||||
CreateBankMovementInput,
|
CreateBankMovementInput,
|
||||||
CreateMovementInput,
|
CreateMovementInput,
|
||||||
|
ResolveOutstandingInput,
|
||||||
CustomerDetail,
|
CustomerDetail,
|
||||||
CustomerInput,
|
CustomerInput,
|
||||||
CustomerListResponse,
|
CustomerListResponse,
|
||||||
@@ -484,6 +488,10 @@ export interface MovementQuery {
|
|||||||
typeId?: string;
|
typeId?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
/** Restrict to captured-but-unpaid rows (the NOPAGO worklist). */
|
||||||
|
outstanding?: boolean;
|
||||||
|
/** Exact check number — the by-check reconciliation lookup. */
|
||||||
|
checkNumber?: string;
|
||||||
/** `YYYY-MM-DD`, inclusive on both ends. */
|
/** `YYYY-MM-DD`, inclusive on both ends. */
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -501,6 +509,8 @@ export function listMovements(q: MovementQuery): Promise<MovementListResponse> {
|
|||||||
if (q.typeId) params.set("typeId", q.typeId);
|
if (q.typeId) params.set("typeId", q.typeId);
|
||||||
if (q.source) params.set("source", q.source);
|
if (q.source) params.set("source", q.source);
|
||||||
if (q.customerId) params.set("customerId", q.customerId);
|
if (q.customerId) params.set("customerId", q.customerId);
|
||||||
|
if (q.outstanding !== undefined) params.set("outstanding", String(q.outstanding));
|
||||||
|
if (q.checkNumber) params.set("checkNumber", q.checkNumber);
|
||||||
if (q.from) params.set("from", q.from);
|
if (q.from) params.set("from", q.from);
|
||||||
if (q.to) params.set("to", q.to);
|
if (q.to) params.set("to", q.to);
|
||||||
if (q.sort) params.set("sort", q.sort);
|
if (q.sort) params.set("sort", q.sort);
|
||||||
@@ -559,6 +569,36 @@ export function voidMovement(id: string): Promise<Transaction> {
|
|||||||
return apiFetch<Transaction>(`/billing/${id}/void`, { method: "POST" });
|
return apiFetch<Transaction>(`/billing/${id}/void`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Capture many customers' receipts against one check, in one transaction. The
|
||||||
|
* returned `items` are positionally parallel to `input.lines`. */
|
||||||
|
export function createMovementBatch(
|
||||||
|
input: BatchCreateInput,
|
||||||
|
): Promise<BatchCreateResponse> {
|
||||||
|
return apiFetch<BatchCreateResponse>("/billing/batch", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear an outstanding (NOPAGO) row: stamps the check number + resolution date
|
||||||
|
* and starts counting it toward the balance. 400 if not outstanding or voided. */
|
||||||
|
export function resolveOutstanding(
|
||||||
|
id: string,
|
||||||
|
input: ResolveOutstandingInput,
|
||||||
|
): Promise<Transaction> {
|
||||||
|
return apiFetch<Transaction>(`/billing/${id}/resolve-outstanding`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything captured against one check, with its reconciliation total. */
|
||||||
|
export function getByCheck(checkNumber: string): Promise<ByCheckResponse> {
|
||||||
|
return apiFetch<ByCheckResponse>(
|
||||||
|
`/billing/by-check?checkNumber=${encodeURIComponent(checkNumber)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------- Bank register (chequera) */
|
/* ------------------------------------------------- Bank register (chequera) */
|
||||||
|
|
||||||
export interface BankQuery {
|
export interface BankQuery {
|
||||||
|
|||||||
@@ -718,6 +718,9 @@ export interface Movement {
|
|||||||
type: TransactionType | null;
|
type: TransactionType | null;
|
||||||
/** App-voided (`voidedAt` set). UI strikes; totals exclude. */
|
/** App-voided (`voidedAt` set). UI strikes; totals exclude. */
|
||||||
voided: boolean;
|
voided: boolean;
|
||||||
|
/** Legacy "NOPAGO": captured but unpaid (no funds). Shown tagged, and kept
|
||||||
|
* out of every balance until resolved via resolveOutstanding(). */
|
||||||
|
outstanding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /billing — a new ledger movement. Sign convention: negative
|
/** Payload for POST /billing — a new ledger movement. Sign convention: negative
|
||||||
@@ -733,6 +736,73 @@ export interface CreateMovementInput {
|
|||||||
reference?: string;
|
reference?: string;
|
||||||
checkNumber?: string;
|
checkNumber?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
/** Legacy NOPAGO — captured but unpaid; excluded from balances until resolved. */
|
||||||
|
outstanding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One customer's line inside a check batch; check-level fields sit on the parent. */
|
||||||
|
export interface BatchLineInput {
|
||||||
|
customerId: string;
|
||||||
|
amount: number;
|
||||||
|
reference?: string;
|
||||||
|
period?: string;
|
||||||
|
message?: string;
|
||||||
|
outstanding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload for POST /billing/batch — many receipts cut against one check. */
|
||||||
|
export interface BatchCreateInput {
|
||||||
|
domain: TransactionDomain;
|
||||||
|
transactionDate: string;
|
||||||
|
checkNumber: string;
|
||||||
|
currency?: Currency;
|
||||||
|
typeId?: string;
|
||||||
|
lines: BatchLineInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchCreateResponse {
|
||||||
|
/** Positionally parallel to the submitted `lines`. */
|
||||||
|
items: Transaction[];
|
||||||
|
checkNumber: string;
|
||||||
|
currency: LedgerCurrency;
|
||||||
|
source: "MANUAL" | "BATCH" | "OCR";
|
||||||
|
count: number;
|
||||||
|
outstandingCount: number;
|
||||||
|
/** Excludes outstanding lines — this is the figure to reconcile against the
|
||||||
|
* physical check. */
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload for POST /billing/:id/resolve-outstanding. */
|
||||||
|
export interface ResolveOutstandingInput {
|
||||||
|
checkNumber: string;
|
||||||
|
resolvedDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ByCheckItem {
|
||||||
|
id: string;
|
||||||
|
transactionDate: string | null;
|
||||||
|
domain: TransactionDomain;
|
||||||
|
amount: string;
|
||||||
|
currency: LedgerCurrency;
|
||||||
|
direction: LedgerDirection;
|
||||||
|
reference: string | null;
|
||||||
|
period: string | null;
|
||||||
|
message: string | null;
|
||||||
|
outstanding: boolean;
|
||||||
|
type: TransactionType | null;
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
customerNameSource: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /billing/by-check — everything cut against one check, for reconciliation. */
|
||||||
|
export interface ByCheckResponse {
|
||||||
|
checkNumber: string;
|
||||||
|
items: ByCheckItem[];
|
||||||
|
count: number;
|
||||||
|
outstandingCount: number;
|
||||||
|
totals: { currency: LedgerCurrency; total: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MovementListItem extends Movement {
|
export interface MovementListItem extends Movement {
|
||||||
|
|||||||
@@ -0,0 +1,786 @@
|
|||||||
|
# Insurance Features — Implementation Spec
|
||||||
|
|
||||||
|
Source: Jorge Cuadros meeting notes, 2026-07-25/26 (`Seguros` section), plus a
|
||||||
|
read-through of the current `policies/`, `reports/`, `storage/` and `auth/`
|
||||||
|
code and a live query of the dev database. This is a forward spec for work
|
||||||
|
**not yet built** — contrast with [`RENEWAL_NOTICES.md`](RENEWAL_NOTICES.md),
|
||||||
|
which documents the legacy renewal-report chain that has *already* been
|
||||||
|
migrated into the `aviso-renovacion` report.
|
||||||
|
|
||||||
|
Companion doc: [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) covers the
|
||||||
|
Utility Management half of the same meeting (PLAN.md step 11). This doc is the
|
||||||
|
insurance half (PLAN.md step 12).
|
||||||
|
|
||||||
|
## Why these four features are one spec
|
||||||
|
|
||||||
|
The meeting produced four insurance asks. They are specified together because
|
||||||
|
they share a spine — the `Policy` record and its expiry/settlement lifecycle:
|
||||||
|
|
||||||
|
1. **Renewal notification emails** — automates the *outbound* half of a
|
||||||
|
policy's expiry (30 days before, 15 days before, 7 days after). The report
|
||||||
|
that produces the letter text already exists; nothing sends it.
|
||||||
|
2. **Liquidación batch workflow** — the *settlement* half of the same
|
||||||
|
lifecycle. The per-policy fields are wired end to end; only the batch
|
||||||
|
print-and-mark step is missing.
|
||||||
|
3. **Certificate / "Solicitud Atlas"** — a customer-facing artifact rendered
|
||||||
|
from the same policy record, delivered through the existing PHP portal.
|
||||||
|
4. **Carrier API integration** — an *inbound* path that would populate the
|
||||||
|
same `Policy` rows automatically instead of by hand.
|
||||||
|
|
||||||
|
1 and 2 are small additions on top of shipped code. 3 is half-buildable and
|
||||||
|
half-blocked on infrastructure. 4 is fully blocked on vendor information.
|
||||||
|
|
||||||
|
**Two of the four are much smaller than they sound**, and the spec says so up
|
||||||
|
front so nobody re-estimates them as greenfield work: §1 needs a scheduler, a
|
||||||
|
mail client and one mutation — the notice table, its idempotency key, and the
|
||||||
|
letter body all exist. §2 needs one report and one endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ground truth (verified 2026-07-27, do not re-derive)
|
||||||
|
|
||||||
|
Everything below was checked against the code and the dev DB
|
||||||
|
(`192.168.4.212:3307`), not inferred from the meeting notes.
|
||||||
|
|
||||||
|
### What exists
|
||||||
|
|
||||||
|
| Thing | Where | State |
|
||||||
|
|---|---|---|
|
||||||
|
| `Policy.liquidated` / `liquidationNumber` / `liquidationDate` | `schema.prisma:165-167` | wired end to end (DTOs, `?liquidated=` filter, stats, form checkbox, detail label) |
|
||||||
|
| `RenewalNotice` model + `@@unique([policyId, generation])` | `schema.prisma:201-217` | **0 rows** — never written by anything |
|
||||||
|
| `aviso-renovacion` letter report | `reports.registry.ts:623-799` | shipped; read-only. Its `enviadas`/`pendientes` totals are permanently 0 because nothing writes `RenewalNotice` |
|
||||||
|
| Letter render + PDF/CSV/XLSX/print outputs | `reports.types.ts:32`, `outputs.ts`, `ReportRunner.tsx:502` (`LetterLayout`) | shipped, reusable as-is |
|
||||||
|
| S3-style optional-client service pattern | `storage.service.ts:28-57` | the pattern the mail client should copy |
|
||||||
|
| Single-running-job guard | `ops.service.ts:171-176` | the pattern the cron sweep should copy |
|
||||||
|
| Ability matrix (17 abilities) | `auth/abilities.ts` | single source of truth; web consumes the server-resolved map |
|
||||||
|
|
||||||
|
### What does not exist
|
||||||
|
|
||||||
|
- **No scheduler.** No `@nestjs/schedule`, bull/bullmq, node-cron or
|
||||||
|
`setInterval` in `apps/api`. `ops/` spawns detached child processes on user
|
||||||
|
request only.
|
||||||
|
- **No mail code or dependency.** Nothing in any `package.json`, `.env.example`
|
||||||
|
or `docker-compose.yml`.
|
||||||
|
- **`EmailTemplate` / `EmailCampaign` / `EmailLog`** (`schema.prisma:540-571`)
|
||||||
|
are dead migrated legacy tables — no FKs, no code touches them. **Leave them
|
||||||
|
alone**; `RenewalNotice` is the send log.
|
||||||
|
- `express-session` uses the in-memory default store (`main.ts:28-39`), so
|
||||||
|
sessions die on API restart. Relevant to any customer-identity idea in §3.
|
||||||
|
- Reports are gated by `AuthenticatedGuard` alone (`reports.controller.ts:28`)
|
||||||
|
— any logged-in user, including VIEWER, can run any report. Adding a
|
||||||
|
*mutation* to the reports area (§2) means it cannot live on that controller.
|
||||||
|
|
||||||
|
### Live data shape
|
||||||
|
|
||||||
|
| Measure | Value |
|
||||||
|
|---|---|
|
||||||
|
| Customers | 1,536 — **1,304 (85%) have a non-blank email** |
|
||||||
|
| Customers holding ≥1 policy | 893 — **815 (91%) have an email** |
|
||||||
|
| Policies | 2,396 (0 archived); 1,865 have `policyTo` |
|
||||||
|
| Policies expiring in the next 12 months | 1,045 (≈87/month) |
|
||||||
|
| Liquidated | 2,170; **pending 226** |
|
||||||
|
| `liquidationNumber` / `liquidationDate` populated | 2,245 / 2,239 |
|
||||||
|
| Installments | 4,724 — 1,849 with `paidDate`, 1,651 with `checkNumber` |
|
||||||
|
| `renewal_notices` rows | 0 |
|
||||||
|
|
||||||
|
Email volume for §1 sizing: ≈87 policies/month × 3 notices ≈ **260
|
||||||
|
emails/month**, and 91% of policyholders are reachable. This is an email
|
||||||
|
channel, not a print-fallback channel — but see §1's open question on the
|
||||||
|
remaining 9%.
|
||||||
|
|
||||||
|
### Two meeting terms have no referent in the data
|
||||||
|
|
||||||
|
Do not guess at these. Negative greps re-run 2026-07-27 across `docs/`,
|
||||||
|
`migration/` and `apps/`. (A third — "GDMX" — turned out to be a typo for
|
||||||
|
`GMX`, confirmed with the user; see §4.)
|
||||||
|
|
||||||
|
- **"Solicitud"** — 0 hits. Not a legacy report, form or table. ("Atlas" is a
|
||||||
|
carrier — `COMP = "ATLAS, S.A."` — not a report; see
|
||||||
|
[`RENEWAL_NOTICES.md`](RENEWAL_NOTICES.md).) The closest legacy artifact to a
|
||||||
|
certificate is the `* MENS`/`*MENSAJE` blob letter templates, one per line of
|
||||||
|
business, deliberately excluded from migration
|
||||||
|
(`LEGACY_DATABASES.md` → excluded tables).
|
||||||
|
- **"Garantías"** — 0 hits for `garant`/`warranty`. No table, no column.
|
||||||
|
|
||||||
|
For reference, both carriers named in the meeting *do* appear in the data:
|
||||||
|
`GMX` in `mult.comp`, `m_empr.comp` and `gen1.comp`, and `ANA SEGUROS`
|
||||||
|
verbatim (with an inconsistent `ANA` variant in `licencias.comp`) — which
|
||||||
|
matches the ANA-autos / GMX-daños split described in §4.
|
||||||
|
|
||||||
|
The only hit for `transferencia` anywhere is a bank-register UI label
|
||||||
|
(`apps/web/src/app/banco/page.tsx:948`, a SCOTHIA movement type) — unrelated to
|
||||||
|
policy settlement. "Número de transferencia" is therefore a **new** requirement
|
||||||
|
mapping onto the existing `liquidationNumber` field, not a missed migration.
|
||||||
|
|
||||||
|
### Two defects found while verifying this spec
|
||||||
|
|
||||||
|
Both are pre-existing, both affect the features below, and both should be fixed
|
||||||
|
as part of §2 rather than filed separately.
|
||||||
|
|
||||||
|
**(a) `INCENDIO` and `M_EMPR` have no `policy_types` row, and 5 policies lost
|
||||||
|
their ramo.** `policy_types` currently holds only `AUTO`, `LICENCIAS`, `MULT`.
|
||||||
|
`transform_policies.py:111-115` configures `INCENDIO` and `M_EMPR` too, so the
|
||||||
|
migration creates all five — but `policies_policyTypeId_fkey` is **`ON DELETE
|
||||||
|
SET NULL`**, so deleting an (apparently unused) lookup row silently blanked the
|
||||||
|
ramo on every policy pointing at it. The 5 `m_empr` policies now have
|
||||||
|
`policyTypeId = NULL`:
|
||||||
|
|
||||||
|
```
|
||||||
|
3249481 / 3249872 vence 2014-03-26 pendiente
|
||||||
|
3673 / 1200003673 vence 2013-03-30 pendiente
|
||||||
|
7000017 sin vigencia liquidada
|
||||||
|
```
|
||||||
|
|
||||||
|
Consequence: every ramo-parameterized query filters on
|
||||||
|
`policyType: { name: … }` (`reports.registry.ts:703-707`), so these 5 are invisible
|
||||||
|
to `aviso-renovacion` *and* would be invisible to §2's pending-liquidación
|
||||||
|
report — including 4 that are genuinely pending. `INCENDIO` is a different
|
||||||
|
story: the legacy `INCENDIO` table has exactly **1 row**, and it did not
|
||||||
|
migrate (customer unresolved), so the ramo is legitimately empty — but the
|
||||||
|
`aviso-renovacion` "Incendio" dropdown option still promises a report that can
|
||||||
|
only ever return zero rows.
|
||||||
|
|
||||||
|
Fix as part of §2: re-seed the two missing `policy_types` rows, re-point the 5
|
||||||
|
orphans, and change the FK to `ON DELETE RESTRICT` so a lookup delete fails
|
||||||
|
loudly instead of silently blanking data.
|
||||||
|
|
||||||
|
**(b) The legacy settlement slots do not match the plan's assumption.**
|
||||||
|
`MULT` and `INCENDIO` carry **two** slots (`LIQUIDADA`/`LIQUIDADA 2`,
|
||||||
|
`NUM LIQUIDACION`/`NUM LIQUIDACION2`, `F LIQUIDA1`/`F LIQUIDA2`) — but
|
||||||
|
`M EMPR` carries **four** (`liquidada` … `liquidada_4`,
|
||||||
|
`num_liquidacion` … `num_liquidacion4`, `f_liquida1` … `f_liquida4`).
|
||||||
|
Actual usage in the staged data:
|
||||||
|
|
||||||
|
| Table | rows | slot 2 number | slot 2 date | slots 3-4 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `mult` | 773 | 41 (5.3%) | 39 | n/a |
|
||||||
|
| `m_empr` | 5 | 0 | 0 | 0 |
|
||||||
|
| `incendio` | 1 | 0 | 0 | n/a |
|
||||||
|
|
||||||
|
So the second slot was used on ~5% of MULT policies and never anywhere else,
|
||||||
|
and slots 3–4 were never used at all. `Policy` collapses this to one set, which
|
||||||
|
means **≤41 rows lost a second settlement record** in migration. Design
|
||||||
|
decision in §2.
|
||||||
|
|
||||||
|
### Utilities ↔ Seguros reconciliation — resolved, not open
|
||||||
|
|
||||||
|
The plan carried this as "two competing sources." It is not competitive; one of
|
||||||
|
them is unusable.
|
||||||
|
|
||||||
|
- **`SEGUROS 16_be.mdb: DATGRAL.[NUM UTIL]`** — 563 complete
|
||||||
|
`(num_id, num_util)` pairs. Validated by comparing the insurance customer's
|
||||||
|
own `NOMBRE` against the utilities customer it points at: **298/563 (53%)
|
||||||
|
match exactly**, the remainder being ordinary name variants (spouses,
|
||||||
|
married names, entity vs. person). This is a real link, and it is the key
|
||||||
|
`transform_customers.py` already uses.
|
||||||
|
- **`UTILSEG`** (1,582 rows) — 379 rows carry both a `seguros` and a `util`
|
||||||
|
number. Under the obvious reading (`seguros` → seguros `DATGRAL.num_id`,
|
||||||
|
`util` → utilities `DATGRAL.num_id`) the row's own `NOMBRE` matches the
|
||||||
|
target master's name **58/1,024** and **70/932** of the time respectively —
|
||||||
|
i.e. essentially never. Spot-checking makes it plain:
|
||||||
|
|
||||||
|
```
|
||||||
|
UTILSEG 'STEWART, KENNETH' seguros=220 → 'ZEPEDA, JAIME RAUL' util=441 → 'MENDOZA, SERGIO'
|
||||||
|
UTILSEG 'HANCOCK, STEVENS' seguros=225 → 'RODRIGUEZ, MIKE' util=403 → 'JOW, LILY/EVANS, LARRY'
|
||||||
|
UTILSEG 'HUDSON, RICHARD L.' seguros=227 → 'WELLES, ROBERT' util=218 → 'ARTER, KAREN'
|
||||||
|
```
|
||||||
|
|
||||||
|
And where the two sources overlap they contradict each other: of 218
|
||||||
|
`seguros` ids present in both, **170 (78%) point at a different utilities
|
||||||
|
customer**; only 48 pairs agree outright.
|
||||||
|
|
||||||
|
**Rule: `DATGRAL.[NUM UTIL]` is authoritative. `UTILSEG` is a stale artifact of
|
||||||
|
an older numbering and must not be used to reconcile customers.** This matters
|
||||||
|
directly to [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) §4
|
||||||
|
(customer-number recycling), which touches the same identity space — a
|
||||||
|
recycling backfill that consulted `UTILSEG` would merge unrelated people.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Renewal notification emails
|
||||||
|
|
||||||
|
### What Jorge asked for
|
||||||
|
|
||||||
|
Automatic notice to the customer at **30 days before expiry, 15 days before,
|
||||||
|
and 7 days after** — replacing the manual monthly run of the legacy
|
||||||
|
`RENEW`/`RENEW2`/`RENEW3` report batch.
|
||||||
|
|
||||||
|
### What's already built (do not re-build)
|
||||||
|
|
||||||
|
- The letter itself: `aviso-renovacion` (`reports.registry.ts:623-799`) already
|
||||||
|
resolves customer, carrier, `policyTo`, premium, vehicle and the ramo-specific
|
||||||
|
`coveragesJson` keys (`cov.cobertura`, `cov.csl_limite`, `cov.gastos_medico`,
|
||||||
|
`cov.propiedades`, `cov.personas`, `cov.servicio_adicional`) into a
|
||||||
|
`__kind: "letter"` row. **Do not fork this copy** — one letter definition,
|
||||||
|
two render targets.
|
||||||
|
- The send log: `RenewalNotice`, with `@@unique([policyId, generation])`
|
||||||
|
(`schema.prisma:216`) — **this is the idempotency mechanism and it is already
|
||||||
|
in place.** A sweep that upserts on that key cannot double-send, even on
|
||||||
|
re-run, redeploy or double-fire. No new dedup design is needed.
|
||||||
|
- The cadence maps onto the existing `generation Int` with **no schema
|
||||||
|
change**: 30d-before = 1, 15d-before = 2, 7d-after = 3 — exactly the legacy
|
||||||
|
1st/2nd/3rd notice model.
|
||||||
|
|
||||||
|
### 1.1 The scheduler
|
||||||
|
|
||||||
|
Add `@nestjs/schedule`. One `@Cron` job, daily, early morning local time.
|
||||||
|
|
||||||
|
```
|
||||||
|
@Cron("0 6 * * *", { timeZone: "America/Tijuana" })
|
||||||
|
async sweepRenewals()
|
||||||
|
```
|
||||||
|
|
||||||
|
Guard multi-replica double-fire the same way `ops.service.ts:171-176` guards
|
||||||
|
concurrent jobs — a DB row, not an in-process flag. Reuse `OpsJob` with a new
|
||||||
|
kind, or add a minimal `ScheduledRun` row; either way the guard must be a
|
||||||
|
database write, because the API is deployed as a Swarm service and may run more
|
||||||
|
than one replica.
|
||||||
|
|
||||||
|
The sweep must also be **manually runnable** (an admin endpoint that invokes the
|
||||||
|
same service method), so a missed day can be caught up without waiting 24h and
|
||||||
|
so the job is testable without clock manipulation.
|
||||||
|
|
||||||
|
### 1.2 The sweep query
|
||||||
|
|
||||||
|
For each of the three offsets, select non-archived policies whose `policyTo`
|
||||||
|
falls on the target date:
|
||||||
|
|
||||||
|
| Generation | Target date | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `today + 30d` | primer aviso |
|
||||||
|
| 2 | `today + 15d` | segundo aviso |
|
||||||
|
| 3 | `today - 7d` | tercer aviso (vencida) |
|
||||||
|
|
||||||
|
`archivedAt: null`, `policyTo` non-null. **Date comparison must be on the UTC
|
||||||
|
date, not the timestamp** — `policyTo` is stored midnight-UTC (see the existing
|
||||||
|
report's `Date.UTC(year, month - 1, 1)` bounds at `reports.registry.ts:700`),
|
||||||
|
and a naive local-time comparison shifts the whole sweep by a day for
|
||||||
|
`America/Tijuana`.
|
||||||
|
|
||||||
|
For each hit: render the letter, send, then upsert `RenewalNotice` on
|
||||||
|
`[policyId, generation]` with `sentAt`, `channel: EMAIL`, and the provider
|
||||||
|
message id. **Upsert after a successful send, not before** — a failed send must
|
||||||
|
leave the row absent so the next day's sweep retries it. A row that already has
|
||||||
|
`sentAt` is skipped.
|
||||||
|
|
||||||
|
Catch-up behaviour: because the query is date-*equality*, a day the job doesn't
|
||||||
|
run is a day of notices silently skipped. Either make the sweep look at a
|
||||||
|
window (`policyTo` between the target date and the last successful run's target
|
||||||
|
date) or record the last successful sweep date and re-run the gap. **Recommend
|
||||||
|
the window** — it needs no extra state beyond a `lastSweptAt` and it degrades
|
||||||
|
correctly if the API is down for a week.
|
||||||
|
|
||||||
|
### 1.3 The mail client
|
||||||
|
|
||||||
|
`MailProvider` interface:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
send(msg: { to: string; subject: string; html: string; attachments?: … })
|
||||||
|
=> Promise<{ providerId: string }>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Amazon SES is the first and intended implementation** — the user already runs
|
||||||
|
SES for mass notification, so this reuses an established sending reputation
|
||||||
|
rather than warming a new channel. Provider choice and budget are **settled,
|
||||||
|
not open questions**; ≈260 emails/month is negligible against existing usage.
|
||||||
|
|
||||||
|
Implement it with `@aws-sdk/client-sesv2`, mirroring `StorageService`
|
||||||
|
(`storage.service.ts:28-57`) exactly:
|
||||||
|
|
||||||
|
- env-driven config (`SES_REGION`, `SES_FROM`, `SES_ACCESS_KEY`,
|
||||||
|
`SES_SECRET_KEY`, optional `SES_CONFIGURATION_SET`), added to `.env.example`;
|
||||||
|
- **null client when unconfigured, `ServiceUnavailableException` on use** — an
|
||||||
|
unconfigured mail setup must never crash API boot, same degradation as
|
||||||
|
document storage today;
|
||||||
|
- a no-op/log implementation for dev, selected when SES env vars are absent.
|
||||||
|
|
||||||
|
The interface stays swappable for testability, not for vendor escape.
|
||||||
|
|
||||||
|
Persist the SES message id — add `providerMessageId String?` to `RenewalNotice`
|
||||||
|
rather than overloading `notes`, so a bounce or complaint notification can be
|
||||||
|
traced back to the notice that caused it. (`notes` stays free-text for staff.)
|
||||||
|
|
||||||
|
### 1.4 Manual mark-as-sent
|
||||||
|
|
||||||
|
The `aviso-renovacion` doc comment (`reports.registry.ts:617-621`) already
|
||||||
|
anticipates this: staff who *mail* a paper notice need to record it.
|
||||||
|
`RenewalNoticeChannel` (`MAIL` | `EMAIL`) exists for exactly this distinction.
|
||||||
|
|
||||||
|
`POST /policies/:id/renewal-notices` — body `{ generation, channel, sentAt?,
|
||||||
|
notes? }`, upserting on the same unique key. This closes the loop that makes
|
||||||
|
the report's `enviadas`/`pendientes` totals meaningful for the first time.
|
||||||
|
|
||||||
|
### 1.5 Bounces and unsubscribes
|
||||||
|
|
||||||
|
Not in the meeting notes, but sending 260 mails/month to a 1,304-address list
|
||||||
|
built from decades-old Access data will produce bounces. Minimum viable:
|
||||||
|
record `providerMessageId`, and add a `Customer.emailOptOut Boolean @default(false)`
|
||||||
|
checked by the sweep. Full SNS bounce-webhook handling is out of scope for the
|
||||||
|
first build — but the opt-out flag is not, because there is no other way for a
|
||||||
|
customer to stop the mail.
|
||||||
|
|
||||||
|
### API surface
|
||||||
|
|
||||||
|
| Method | Route | Ability |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/policies/:id/renewal-notices` | `renewal:send` |
|
||||||
|
| `POST` | `/renewals/sweep` (manual trigger of the cron body) | `renewal:send` |
|
||||||
|
| `GET` | `/renewals/pending?days=` (what the next sweep would send) | read (AuthenticatedGuard) |
|
||||||
|
|
||||||
|
### Abilities (new)
|
||||||
|
|
||||||
|
| Ability | Min role | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `renewal:send` | MANAGER | sends mail to customers on the office's behalf — a higher trust tier than ordinary data entry |
|
||||||
|
|
||||||
|
Add to both the `Ability` union and `ABILITY_MIN` in `auth/abilities.ts` — that
|
||||||
|
file is the single source of truth; `apps/web/src/lib/abilities.ts` only
|
||||||
|
consumes the server-resolved map.
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- Which SES region + verified identity/configuration set this sends under, and
|
||||||
|
whether it reuses existing IAM credentials or gets its own scoped
|
||||||
|
`ses:SendEmail` user.
|
||||||
|
- The 9% of policyholders with no email (78 of 893) — silently skipped, or
|
||||||
|
surfaced as a "print these" worklist? Recommend the worklist: the existing
|
||||||
|
`aviso-renovacion` report already produces exactly those letters, so it costs
|
||||||
|
one filter parameter.
|
||||||
|
- Spanish or English body? The legacy letters were Spanish; the customer base
|
||||||
|
is substantially US-resident. `Customer` has no language preference field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Liquidación batch workflow
|
||||||
|
|
||||||
|
### What Jorge asked for
|
||||||
|
|
||||||
|
Print the pending set, then mark many policies settled at once with one
|
||||||
|
transfer number — "liquidación de pólizas MULT", garantías excluded.
|
||||||
|
|
||||||
|
### What's already built (do not re-build)
|
||||||
|
|
||||||
|
`liquidated` / `liquidationNumber` / `liquidationDate` are wired end to end:
|
||||||
|
`schema.prisma:165-167`, create+update DTOs (`policy.dto.ts:35-37,59-61`),
|
||||||
|
the `?liquidated=` list filter (`policies.service.ts:148`), liquidada/pendiente
|
||||||
|
counts in `stats()` (`:219,:237`), `headerData()` pass-through (`:316`), the
|
||||||
|
"Liquidada" checkbox in `PolicyForm.tsx:250`, and the detail-page label
|
||||||
|
(`polizas/[id]/page.tsx:323`).
|
||||||
|
|
||||||
|
**Only the batch layer is missing.** 2,170 of 2,396 policies are already
|
||||||
|
marked liquidated from migration; the live pending set is 226.
|
||||||
|
|
||||||
|
### 2.1 Pending-liquidación report
|
||||||
|
|
||||||
|
New entry in `reports.registry.ts`, `format: "tabular"` — gets print/PDF/CSV/XLSX
|
||||||
|
free via the existing `/reportes/:slug` machinery.
|
||||||
|
|
||||||
|
Parameterized **by ramo**, mirroring how `vigente` and `aviso-renovacion` already
|
||||||
|
take a `policyType` select param. The workflow is *not* MULT-only: the legacy
|
||||||
|
`TABLA LIQUIDA MF` scratch table served `MULT`, `INCENDIO` **and** `M EMPR`
|
||||||
|
(`LEGACY_DATABASES_OBJECTS.md:4887-5017`).
|
||||||
|
|
||||||
|
Params: ramo (with an "todos" option), aseguradora, date range on `policyFrom`.
|
||||||
|
Columns: póliza, cliente, ramo, aseguradora, vigencia, prima neta, forma de pago.
|
||||||
|
Totals: count + prima neta sum per currency (**never collapse MXN and USD** —
|
||||||
|
same constraint as the billing module).
|
||||||
|
|
||||||
|
⚠️ Fix defect (a) above before building this, or the report inherits the same
|
||||||
|
blind spot: 4 of the 226 pending policies carry `policyTypeId = NULL` and would
|
||||||
|
be missing from every ramo-filtered run *and* from the "todos" run if that is
|
||||||
|
implemented as a union over known types rather than as "no filter."
|
||||||
|
|
||||||
|
### 2.2 Batch settle endpoint
|
||||||
|
|
||||||
|
`POST /policies/liquidate-batch` — body:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ policyIds: string[], liquidationNumber: string, liquidationDate: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
One `prisma.$transaction`. Rejects ids that are already `liquidated` (return
|
||||||
|
them in the response rather than silently skipping, so the UI can say which).
|
||||||
|
Writes an `ActivityLog` row per policy — this is a financial settlement marker
|
||||||
|
being set across many records at once, and it is the one place in the app where
|
||||||
|
a single click changes dozens of rows.
|
||||||
|
|
||||||
|
**Ability: new `policy:liquidate` at MANAGER**, not the existing `policy:update`
|
||||||
|
(STAFF). Reason: a STAFF user editing one policy's checkbox is data entry; a
|
||||||
|
STAFF user settling 200 policies against one transfer number is a financial
|
||||||
|
control. Recommend the new ability; note it as a question for Jorge only if he
|
||||||
|
wants STAFF to keep doing it.
|
||||||
|
|
||||||
|
### 2.3 Un-settle path
|
||||||
|
|
||||||
|
The legacy had one (`MULT FAM X POLIZA Consulta`,
|
||||||
|
`LEGACY_DATABASES_OBJECTS.md:5570-5573`). `POST /policies/liquidate-batch/undo`
|
||||||
|
with the same shape, or `{ liquidationNumber }` to reverse a whole batch.
|
||||||
|
Gated at MANAGER via the same `policy:liquidate`. Also logs.
|
||||||
|
|
||||||
|
### 2.4 The two-slot decision (defect (b))
|
||||||
|
|
||||||
|
`Policy` has one settlement slot; `MULT`/`INCENDIO` had two and `M EMPR` had
|
||||||
|
four, with real usage on ≤41 MULT rows and nowhere else.
|
||||||
|
|
||||||
|
**Recommendation: move settlement onto `PolicyPaymentInstallment`, do not add a
|
||||||
|
second slot to `Policy`.** Reasons:
|
||||||
|
|
||||||
|
- `PolicyPaymentInstallment` already exists, already has `paidDate` and
|
||||||
|
`checkNumber`, and already models "the *n*-th payment of this policy" — which
|
||||||
|
is exactly what the second settlement slot meant. 4,724 rows, 1,849 with a
|
||||||
|
paid date.
|
||||||
|
- Adding `liquidated2`/`liquidationNumber2`/`liquidationDate2` reproduces the
|
||||||
|
legacy's hardcoded-repeated-columns mistake that this whole migration exists
|
||||||
|
to undo — and `M EMPR` proves it doesn't stop at two.
|
||||||
|
- The `Policy`-level fields stay as the *rollup* ("this policy is fully
|
||||||
|
settled"), which is what the existing UI and `?liquidated=` filter already
|
||||||
|
mean. No breaking change.
|
||||||
|
|
||||||
|
Concretely: add `liquidationNumber String?` + `liquidatedAt DateTime?` to
|
||||||
|
`PolicyPaymentInstallment`; batch-settle writes the installment rows and sets
|
||||||
|
`Policy.liquidated = true` when all installments are settled. Backfill the ≤41
|
||||||
|
lost slot-2 values from `mult.num_liquidacion2` / `f_liquida2` in
|
||||||
|
`transform_policies.py` at the same time.
|
||||||
|
|
||||||
|
If Jorge wants the simpler thing instead, say so explicitly and accept that
|
||||||
|
those 41 second settlements stay unmigrated.
|
||||||
|
|
||||||
|
### 2.5 "Garantías excluded"
|
||||||
|
|
||||||
|
Blocked — the term has no referent anywhere in the data (0 hits). Do not guess
|
||||||
|
at a filter. Spec'd as: the batch report takes an explicit exclusion list or a
|
||||||
|
flag once Jorge identifies what a "garantía" is in his data. Most likely
|
||||||
|
candidates to ask about: a `forma_pago` value, an aseguradora, or a
|
||||||
|
`coveragesJson` key.
|
||||||
|
|
||||||
|
### API surface
|
||||||
|
|
||||||
|
| Method | Route | Ability |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/reports/liquidacion-pendiente?policyType=&provider=` | read |
|
||||||
|
| `POST` | `/policies/liquidate-batch` | `policy:liquidate` |
|
||||||
|
| `POST` | `/policies/liquidate-batch/undo` | `policy:liquidate` |
|
||||||
|
|
||||||
|
Note the mutation lives on `PoliciesController`, **not** `ReportsController` —
|
||||||
|
that controller is deliberately read-only and guarded by `AuthenticatedGuard`
|
||||||
|
alone (`reports.controller.ts:28`), so any logged-in VIEWER reaches it.
|
||||||
|
|
||||||
|
### Web
|
||||||
|
|
||||||
|
Extend `/polizas` with a "Liquidación" tab: the pending list with checkboxes, a
|
||||||
|
select-all-filtered action, and one dialog collecting número de transferencia +
|
||||||
|
fecha. Print goes through the existing `/reportes/liquidacion-pendiente` runner
|
||||||
|
rather than a bespoke print view.
|
||||||
|
|
||||||
|
### Abilities (new)
|
||||||
|
|
||||||
|
| Ability | Min role | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `policy:liquidate` | MANAGER | batch settlement across many rows; distinct from `policy:update` (STAFF) |
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- What "garantías" refers to (blocks the exclusion filter).
|
||||||
|
- Two-slot settlement: installment-level (recommended) or a second `Policy` slot.
|
||||||
|
- Should `policy:liquidate` be a new MANAGER ability, or is reusing
|
||||||
|
`policy:update` (STAFF) what the office actually wants?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Certificate / "Solicitud Atlas" + portal delivery
|
||||||
|
|
||||||
|
### What Jorge asked for
|
||||||
|
|
||||||
|
A "Solicitud Atlas" / insurance certificate, visible to customers on the
|
||||||
|
website.
|
||||||
|
|
||||||
|
### The blocked half
|
||||||
|
|
||||||
|
**"Solicitud" has no referent** — 0 hits across 212 SEGUROS reports and 96
|
||||||
|
UTILITIES reports; "Atlas" is a carrier, not a report. A *solicitud* is
|
||||||
|
normally an **application form** (pre-policy, filled in by the applicant),
|
||||||
|
which is a materially different artifact from a **certificate** (post-policy,
|
||||||
|
proof of coverage issued to the insured). These need different data, different
|
||||||
|
timing and different delivery.
|
||||||
|
|
||||||
|
Do not build until Jorge confirms which one he means. The spec below covers the
|
||||||
|
**certificate** reading, because that is what "visible to customers on the
|
||||||
|
website" implies.
|
||||||
|
|
||||||
|
### The buildable half — certificate rendering
|
||||||
|
|
||||||
|
Reuse the letter machinery, exactly as `aviso-renovacion` does:
|
||||||
|
|
||||||
|
- `format: "letter"` report (`reports.types.ts:32`), rendered by `LetterLayout`
|
||||||
|
(`ReportRunner.tsx:502`) on screen and by `outputs.ts` `renderPdf` for the
|
||||||
|
file.
|
||||||
|
- Data needed, all already on `Policy` and its relations: customer name +
|
||||||
|
address, policy number, carrier, `policyFrom`/`policyTo`, and the
|
||||||
|
ramo-specific coverage keys already mapped in
|
||||||
|
[`RENEWAL_NOTICES.md`](RENEWAL_NOTICES.md) — plus `vehicles[0]` for auto and
|
||||||
|
the property address for MULT/INCENDIO/M_EMPR.
|
||||||
|
- Parameter is a single policy, not a month — `/reports/certificado?policyId=`.
|
||||||
|
Staff-facing route: a "Certificado" button on `/polizas/[id]`.
|
||||||
|
|
||||||
|
### The infrastructure half — portal delivery
|
||||||
|
|
||||||
|
[`PLAN.md:16,20-24`](../PLAN.md) locks the customer portal
|
||||||
|
(`my-jorgecuadros-web`, PHP/`mysqli`, its own `utility_dbo` DB) as **out of
|
||||||
|
scope and unchanged**. This repo has no public route and no `CUSTOMER` role
|
||||||
|
(`UserRole` = ADMIN/MANAGER/STAFF/VIEWER, `schema.prisma:43-48`), and its
|
||||||
|
sessions are in-memory. Insurance therefore reaches customers as an **extension
|
||||||
|
of the already-planned replication** (PLAN.md steps 8/9), not as a new public
|
||||||
|
surface here.
|
||||||
|
|
||||||
|
What this spec adds to that design, to be finalized when step 8 runs:
|
||||||
|
|
||||||
|
- **Which policy fields join the replicated set** — recommend the certificate's
|
||||||
|
own field list and nothing more (policy number, carrier, ramo, vigencia,
|
||||||
|
customer link), explicitly excluding premiums, commissions, liquidation
|
||||||
|
status, `observations` and `notes`. The replicated side is the
|
||||||
|
internet-exposed one; it should never carry the office's margin data.
|
||||||
|
- **Certificate as generated PDF, not portal-side rendering.** Render here,
|
||||||
|
upload to the existing S3/MinIO bucket via `StorageService`, replicate the
|
||||||
|
pointer. The portal is PHP and is not being modified; giving it a URL is
|
||||||
|
cheaper than giving it a template. This also means the certificate the
|
||||||
|
customer sees is byte-identical to the one staff printed.
|
||||||
|
- Where in `utility_dbo` the pointer lands — depends on the portal's existing
|
||||||
|
policy-facing views (`fm2`/`fm3`/`fmt`, `full_coverage`, `mx_liability`,
|
||||||
|
`usa_liability`), and needs a read of the portal's PHP before it can be
|
||||||
|
stated.
|
||||||
|
|
||||||
|
### Abilities
|
||||||
|
|
||||||
|
None new. Certificate generation is a read; delivery is a replication concern.
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- **What "Solicitud Atlas" actually is** — application form or certificate.
|
||||||
|
Blocks the whole section.
|
||||||
|
- If it's an application form: who fills it in (staff on the customer's behalf,
|
||||||
|
or the customer on the portal), and does it need to exist as a record before
|
||||||
|
a `Policy` does? That would be a new model, not a report.
|
||||||
|
- Does the certificate need a carrier logo/letterhead? The legacy `* MENS`
|
||||||
|
templates were per-carrier blobs; `outputs.ts` `renderPdf` has no image
|
||||||
|
support today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Carrier API integration
|
||||||
|
|
||||||
|
### What Jorge asked for
|
||||||
|
|
||||||
|
Integration with **ANA Seguros** and **GMX**. ("GDMX" in the meeting notes was
|
||||||
|
a typo — confirmed with the user 2026-07-27. The data's `GMX` is correct, and
|
||||||
|
this is no longer an open question.)
|
||||||
|
|
||||||
|
### Carrier research (2026-07-27) — what actually exists
|
||||||
|
|
||||||
|
**The two carriers are one company.** ANA and GMX are both members of **Grupo
|
||||||
|
Valore**, alongside Seguros Argos (vida) and Prevem Seguros (gastos médicos).
|
||||||
|
ANA writes **autos**; GMX writes **daños** — which maps exactly onto the split
|
||||||
|
in this database: ANA covers the `AUTO`/`LICENCIAS` book, GMX covers
|
||||||
|
`MULT`/`INCENDIO`/`M_EMPR`. Practical consequence: **this is one commercial
|
||||||
|
conversation, not two.** The group also shares infrastructure — GMX's own
|
||||||
|
quoting micrositio is served from ANA's host
|
||||||
|
(`server.anaseguros.com.mx/Micrositios/GRUPOVALOREGMXCOR/`), so one technical
|
||||||
|
contact plausibly covers both.
|
||||||
|
|
||||||
|
**ANA has a real, live web service.** `https://server.anaseguros.com.mx/ananetws/service.asmx`
|
||||||
|
— a classic ASP.NET `.asmx` endpoint speaking SOAP 1.1 and 1.2, with its
|
||||||
|
operation list published on the standard help page:
|
||||||
|
|
||||||
|
| Purpose | Operations |
|
||||||
|
|---|---|
|
||||||
|
| Catálogos | `Marca`, `SubMarca`, `Modelo`, `MarcaMoto`, `SubMarcaMoto`, `Color`, `Categoria`, `CatVeh`, `CodigoPostal`, `Colonia`, `ColxCP`, `DelMun`, `EDOS`, `Bancos`, `FormaPago`, `TipoPersona`, `TipoIndem`, `RegimenFiscal`, `Nacionalidad`, `Ocupacion`, `Identificacion`, `GiroEmpresa`, `PropositoMotos`, `Vigencia` |
|
||||||
|
| Cotización | `CalculaValor`, `CalculaMSI` |
|
||||||
|
| Vehículo | `Vehiculo`, `VehiculoMoto`, `ValidaSerie` |
|
||||||
|
| Recuperación / validación | `RecuperaCotizacion`, `ValidaAsegurado` |
|
||||||
|
| Transacción | `Transaccion` |
|
||||||
|
|
||||||
|
**GMX publishes no machine interface.** Its agent area
|
||||||
|
(`gmx.com.mx/soy-agente/herramientas/`) lists only human portals — reporte de
|
||||||
|
agentes, cobranzas, envío/descarga de facturas, documentos emitidos, reporte de
|
||||||
|
siniestros, artículo 492. No API, no WSDL, no developer contact. The only
|
||||||
|
number published is **(55) 5480-4000**.
|
||||||
|
|
||||||
|
Neither carrier has a public developer portal or published documentation.
|
||||||
|
Across this market, web service credentials are granted **by the carrier, at
|
||||||
|
its discretion, to appointed agents on written request** — expect a lead time
|
||||||
|
measured in weeks, not a signup form.
|
||||||
|
|
||||||
|
### ⚠️ The critical mismatch — read before estimating this
|
||||||
|
|
||||||
|
**The ANA service is a new-business quoting/issuance API. What this platform
|
||||||
|
needs is an inbound feed of the office's *existing* book.** Every operation
|
||||||
|
above serves "price and issue a policy that does not exist yet." Not one of
|
||||||
|
them is "list the policies where I am the agent of record," which is what
|
||||||
|
would populate `Policy` rows and keep them current.
|
||||||
|
|
||||||
|
So the honest reading of the research is:
|
||||||
|
|
||||||
|
- If Jorge's ask means **"stop re-typing new policies into two systems"** —
|
||||||
|
the ANA service can do that for autos, and it is genuinely buildable once
|
||||||
|
credentials arrive. GMX/daños would stay manual.
|
||||||
|
- If Jorge's ask means **"keep our policy data in sync with the carrier
|
||||||
|
automatically"** — no evidence exists that either carrier offers it, and the
|
||||||
|
question to ask is specifically whether a *portfolio/cartera download*
|
||||||
|
service exists for an agent's own book. That question has not been asked yet.
|
||||||
|
|
||||||
|
**Do not commit to this section until Jorge says which of the two he means.**
|
||||||
|
The first is a moderate feature; the second may not be purchasable at all.
|
||||||
|
|
||||||
|
Note also that nothing in this spec authorizes calling those endpoints. The
|
||||||
|
operation list above comes from a published help page; actually invoking
|
||||||
|
`CalculaValor` or `Transaccion` requires the agent credentials Jorge would
|
||||||
|
obtain, and should not be attempted before then.
|
||||||
|
|
||||||
|
### Legacy precedent
|
||||||
|
|
||||||
|
Carrier config that exists in the legacy system: `gen1`/`gen2`
|
||||||
|
(`LEGACY_DATABASES.md:1872-1892`) — 9 rows keyed by carrier with `RFC`,
|
||||||
|
`CLAVE`, `FPAGO`, `MONED`, plus a 14-row agent list. It is the only
|
||||||
|
carrier-keyed table anywhere, and it carries **no API metadata** — no endpoint,
|
||||||
|
no credential, no identifier that looks like one. In the new schema the
|
||||||
|
equivalent is `InsuranceProvider`, which today holds only a name.
|
||||||
|
|
||||||
|
### Shape
|
||||||
|
|
||||||
|
- `CarrierConnector` interface — `fetchPolicies(since: Date)`,
|
||||||
|
`fetchPolicy(number: string)`, returning a normalized DTO, one implementation
|
||||||
|
per carrier. **The ANA implementation cannot satisfy `fetchPolicies` from the
|
||||||
|
operations known today** (see the mismatch above); if the ask turns out to be
|
||||||
|
outbound issuance instead, the interface is the wrong shape and should become
|
||||||
|
`quote(...)` / `issue(...)` against `CalculaValor` / `Transaccion`.
|
||||||
|
- SOAP, not REST, for ANA — `.asmx` with a WSDL. Node has no first-class SOAP
|
||||||
|
client in this stack; budget for `strong-soap`/`soap` plus the schema work,
|
||||||
|
and generate types from the WSDL rather than hand-writing envelopes.
|
||||||
|
- Credentials and endpoint config per carrier: extend `InsuranceProvider` with
|
||||||
|
the connector's identifier and store secrets in env, keyed by that identifier
|
||||||
|
— never in the database row.
|
||||||
|
- The catalog operations (`Marca`/`SubMarca`/`Modelo`/`CodigoPostal`/`Colonia`)
|
||||||
|
are useful **independently of any policy sync** — they would let the policy
|
||||||
|
form validate vehicle and address data against the carrier's own catalogs
|
||||||
|
instead of free text. That is the cheapest possible first use of these
|
||||||
|
credentials and a sensible pilot: read-only, no issuance risk, immediately
|
||||||
|
visible in `PolicyForm`.
|
||||||
|
- **An import-staging + review step, never a direct write to `Policy`.** Same
|
||||||
|
principle as [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) §2, which
|
||||||
|
routes OCR results through a review queue instead of writing ledger rows: one
|
||||||
|
write path, one audit trail, and a human confirms anything a machine
|
||||||
|
proposed. A carrier feed that wrote `Policy` rows directly would also fight
|
||||||
|
the Access sync (`run_all.py --sync`), which owns every row carrying
|
||||||
|
provenance columns — an imported policy needs its own provenance
|
||||||
|
(`legacySourceDb = 'carrier:<name>'`) or the next sync will delete it as a
|
||||||
|
row that vanished from source.
|
||||||
|
|
||||||
|
### Abilities (new)
|
||||||
|
|
||||||
|
| Ability | Min role | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `carrier:import` | MANAGER | trigger a fetch and approve imported policies |
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- ~~Does "GDMX" mean `GMX`?~~ **Resolved 2026-07-27** — yes, a typo in the
|
||||||
|
meeting notes.
|
||||||
|
- **Direction — the one that decides whether this is buildable.** Does Jorge
|
||||||
|
want to *stop re-typing new policies* (outbound quote/issue, which the ANA
|
||||||
|
service supports), or *keep existing policies in sync* (inbound portfolio
|
||||||
|
download, which nothing found suggests either carrier offers)?
|
||||||
|
- What to ask Grupo Valore, in one call to **(55) 5480-4000** or the ANA agent
|
||||||
|
channel:
|
||||||
|
1. WSDL + test/production credentials for `server.anaseguros.com.mx/ananetws/service.asmx`,
|
||||||
|
and whether an agent appointment is a prerequisite.
|
||||||
|
2. Whether a **cartera / portfolio download** service exists for an agent's
|
||||||
|
own book — the question that decides the direction above.
|
||||||
|
3. Whether **GMX daños** has any machine interface at all, or whether its
|
||||||
|
agent portals are the only access. This is the more valuable half for this
|
||||||
|
office: GMX writes the `MULT`/`INCENDIO`/`M_EMPR` book.
|
||||||
|
4. Whether one set of Grupo Valore credentials spans both carriers, given the
|
||||||
|
shared hosting.
|
||||||
|
- Does the office hold agent appointments with both ANA and GMX in good
|
||||||
|
standing? Credential grants are discretionary and appointment-gated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build sequencing
|
||||||
|
|
||||||
|
1. **§1 renewal emails** — highest value, schema already ready, no blocker
|
||||||
|
beyond the SES sending account. ≈260 mails/month against a 91%-reachable
|
||||||
|
policyholder base.
|
||||||
|
2. **§2 liquidación batch** — small, builds on fields already wired. Do the two
|
||||||
|
defect fixes (missing `policy_types` rows + FK `ON DELETE RESTRICT`) as part
|
||||||
|
of it, since both distort its own report.
|
||||||
|
3. **§3 certificate** — the report half is buildable now; portal delivery waits
|
||||||
|
on PLAN.md steps 8/9 infrastructure, and the whole section waits on what
|
||||||
|
"Solicitud" means.
|
||||||
|
4. **§4 carrier APIs** — blocked on a single phone call, not on research.
|
||||||
|
ANA's SOAP service is real and its operation list is known; what is missing
|
||||||
|
is credentials and an answer on direction (§4's open questions). GMX appears
|
||||||
|
to have nothing machine-readable, which matters because GMX writes the
|
||||||
|
larger half of this office's book. Build last, and consider the catalog-only
|
||||||
|
pilot before anything else.
|
||||||
|
|
||||||
|
§1 and §2 are independent of each other and can be built in parallel; both are
|
||||||
|
independent of everything in `RECEIPT_CAPTURE_SPEC.md`.
|
||||||
|
|
||||||
|
## New abilities across this spec
|
||||||
|
|
||||||
|
| Ability | Min role | Section |
|
||||||
|
|---|---|---|
|
||||||
|
| `renewal:send` | MANAGER | §1 |
|
||||||
|
| `policy:liquidate` | MANAGER | §2 |
|
||||||
|
| `carrier:import` | MANAGER | §4 |
|
||||||
|
|
||||||
|
No collision with the abilities proposed in `RECEIPT_CAPTURE_SPEC.md`
|
||||||
|
(`statement:ingest`, `statement:review`, `bank:manage-accounts`,
|
||||||
|
`customer:recycle`, `customer:purge`).
|
||||||
|
|
||||||
|
## Open questions to take back to Jorge (collected)
|
||||||
|
|
||||||
|
**§1 — renewal emails**
|
||||||
|
- Which SES region + verified identity/configuration set, and whether to reuse
|
||||||
|
existing IAM credentials or create a scoped `ses:SendEmail` user.
|
||||||
|
- The 78 policyholders with no email: skip silently, or produce a print
|
||||||
|
worklist? (Recommend the worklist.)
|
||||||
|
- Spanish or English notice body?
|
||||||
|
|
||||||
|
**§2 — liquidación**
|
||||||
|
- What "garantías" refers to — blocks the exclusion filter.
|
||||||
|
- Settlement on `PolicyPaymentInstallment` (recommended) vs. a second slot on
|
||||||
|
`Policy`; and whether to backfill the ≤41 lost MULT second settlements.
|
||||||
|
- New `policy:liquidate` (MANAGER) vs. reusing `policy:update` (STAFF).
|
||||||
|
|
||||||
|
**§3 — certificate**
|
||||||
|
- What "Solicitud Atlas" is: application form or certificate. Blocks the section.
|
||||||
|
- If application form: who fills it in, and does it precede the `Policy` record?
|
||||||
|
- Does the certificate need carrier letterhead/logo?
|
||||||
|
|
||||||
|
**§4 — carrier APIs** (all four go in one call to Grupo Valore, (55) 5480-4000)
|
||||||
|
- Direction: outbound quote/issue (supported by ANA today) or inbound portfolio
|
||||||
|
sync (no evidence either carrier offers it)? This decides whether the feature
|
||||||
|
is buildable at all.
|
||||||
|
- WSDL + credentials for `server.anaseguros.com.mx/ananetws/service.asmx`.
|
||||||
|
- Does a cartera/portfolio download exist for an agent's own book?
|
||||||
|
- Does GMX daños have any machine interface, or portals only? GMX writes the
|
||||||
|
`MULT`/`INCENDIO`/`M_EMPR` book — the bigger half for this office.
|
||||||
|
- Does one Grupo Valore credential span both carriers?
|
||||||
|
|
||||||
|
**Resolved — no longer open**
|
||||||
|
- ~~Which of `UTILSEG` / `DATGRAL.[NUM UTIL]` is authoritative~~ → `NUM UTIL`;
|
||||||
|
`UTILSEG` is stale and must not be used (see Ground truth).
|
||||||
|
- ~~OCR/mail provider and budget~~ → SES, settled before this spec was written.
|
||||||
|
- ~~Does "GDMX" mean `GMX`~~ → yes, a typo in the meeting notes (2026-07-27).
|
||||||
|
- ~~Do the carriers' APIs exist~~ → ANA: yes, a live SOAP service with a known
|
||||||
|
operation list. GMX: no published machine interface. Both are Grupo Valore,
|
||||||
|
so it is one relationship. See §4.
|
||||||
|
|
||||||
|
## Sources (§4 carrier research, 2026-07-27)
|
||||||
|
|
||||||
|
- [ANA Seguros web service (`ananetws/service.asmx`)](https://server.anaseguros.com.mx/ananetws/service.asmx)
|
||||||
|
- [ANA Seguros — quiénes somos / Grupo Valore](https://anaseguros.com.mx/anaweb/ana_seguros.html)
|
||||||
|
- [GMX Seguros — herramientas para agentes](https://www.gmx.com.mx/soy-agente/herramientas/)
|
||||||
|
- [GMX quoting micrositio hosted on ANA's server](https://server.anaseguros.com.mx/Micrositios/GRUPOVALOREGMXCOR/cotizador.html)
|
||||||
|
- [Agentemotor — how carriers grant web service credentials](https://www.agentemotor.com/blog/noticias-agentemotor/como-integrarte-a-las-aseguradoras-via-web-service-utilizando-agentemotor/)
|
||||||
|
(Colombian market, cited only for the credential-request pattern)
|
||||||
@@ -26,6 +26,18 @@ enum TransactionDomain {
|
|||||||
TRUST
|
TRUST
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a ledger row entered the system. Every capture path funnels through
|
||||||
|
/// BillingService (single write path, single audit trail); this records which
|
||||||
|
/// one, so an auto-captured receipt is auditable without joining the statement
|
||||||
|
/// tables. `OCR` is reserved for the statement auto-capture pipeline
|
||||||
|
/// (docs/RECEIPT_CAPTURE_SPEC.md §2), which posts through the same batch path
|
||||||
|
/// as hand-keyed check batches.
|
||||||
|
enum TransactionCaptureSource {
|
||||||
|
MANUAL
|
||||||
|
BATCH
|
||||||
|
OCR
|
||||||
|
}
|
||||||
|
|
||||||
enum ServiceKind {
|
enum ServiceKind {
|
||||||
WATER
|
WATER
|
||||||
ELECTRIC
|
ELECTRIC
|
||||||
@@ -443,6 +455,17 @@ model Transaction {
|
|||||||
checkNumber String?
|
checkNumber String?
|
||||||
message String? @db.Text
|
message String? @db.Text
|
||||||
outstanding Boolean @default(false)
|
outstanding Boolean @default(false)
|
||||||
|
/// How this row was captured. NULL = migrated from Access (the legacy*
|
||||||
|
/// columns below say which table). Set explicitly on everything the app
|
||||||
|
/// books, so an OCR-posted receipt is distinguishable from a hand-keyed one
|
||||||
|
/// without joining the statement tables.
|
||||||
|
captureSource TransactionCaptureSource?
|
||||||
|
/// Back-pointer to the artifact that produced this row — a
|
||||||
|
/// `StatementDocument.id` for OCR captures (see RECEIPT_CAPTURE_SPEC §2).
|
||||||
|
/// Unique among live rows via the app's duplicate guard, not a DB constraint,
|
||||||
|
/// because a voided row must not block a corrected re-post of the same
|
||||||
|
/// document.
|
||||||
|
captureRef String?
|
||||||
// Append + void: booked rows are never edited or hard-deleted. A non-null
|
// Append + void: booked rows are never edited or hard-deleted. A non-null
|
||||||
// voidedAt reverses the movement — it MUST be excluded from every balance
|
// voidedAt reverses the movement — it MUST be excluded from every balance
|
||||||
// and total (SUM/count) so a voided amount stops affecting the books.
|
// and total (SUM/count) so a voided amount stops affecting the books.
|
||||||
@@ -454,6 +477,11 @@ model Transaction {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([customerId, transactionDate])
|
@@index([customerId, transactionDate])
|
||||||
|
// By-check reconciliation (billing.byCheck / the cheque-count report) looks
|
||||||
|
// rows up by check number alone — the legacy EDITA CHEQUE COUNT lookup.
|
||||||
|
@@index([checkNumber])
|
||||||
|
// Drives the duplicate-post guard in BillingService.createBatch.
|
||||||
|
@@index([captureRef])
|
||||||
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
||||||
@@map("transactions")
|
@@map("transactions")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user