Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5261c644 | ||
|
|
c239c1a866 | ||
|
|
6468811955 | ||
|
|
ca76f4d57a | ||
|
|
fd5df240bc | ||
|
|
f655fcbb82 | ||
|
|
b016e53893 | ||
|
|
031b13f8c8 | ||
|
|
2cf07ed5ee | ||
|
|
36158ae761 | ||
|
|
fa38ff581e | ||
|
|
c4213aa697 | ||
|
|
f7507f2370 | ||
|
|
2f9a9afc0d | ||
|
|
7e71a993d0 | ||
|
|
aa5867c8ea | ||
|
|
5549a1e0cf | ||
|
|
c6feae9522 | ||
|
|
93f817158e | ||
|
|
29ae9fa5bc | ||
|
|
9973488330 | ||
|
|
75dcbc11b8 | ||
|
|
9929a9a3ac | ||
|
|
8b8de0fdca | ||
|
|
bc749055e7 | ||
|
|
75e9f582b4 | ||
|
|
48e01ddd21 | ||
|
|
8c144fe8c4 | ||
|
|
4f2f064955 | ||
|
|
2f99bd5f98 | ||
|
|
458b2b272d | ||
|
|
81938877ed |
No files matched your search
+2
-1
@@ -22,12 +22,13 @@ OPS_DB_ADMIN_USER=
|
||||
OPS_DB_ADMIN_PASSWORD=
|
||||
|
||||
# Company info — printed in the header of every report (PDF + browser
|
||||
# print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is
|
||||
# print). Leave blank to use the defaults in company.ts. COMPANY_LOGO_PATH is
|
||||
# optional; when unset the API falls back to apps/api/assets/company_logo.png.
|
||||
COMPANY_NAME=Jorge Cuadros & Asociados
|
||||
COMPANY_ADDRESS_LINE1=
|
||||
COMPANY_ADDRESS_LINE2=
|
||||
COMPANY_CITY_STATE=
|
||||
COMPANY_MAILING_ADDRESS=
|
||||
COMPANY_PHONE=
|
||||
COMPANY_EMAIL=
|
||||
COMPANY_TAX_ID=
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
# through this workflow at all.
|
||||
# 4. app (api + web) the new images.
|
||||
# 5. verify ask the running API what it actually is.
|
||||
# 6. prune images reclaim the superseded api/web images. LAST, and
|
||||
# after verify: Docker will not prune an image a
|
||||
# container references, so the running stack is what
|
||||
# protects the release we just shipped.
|
||||
#
|
||||
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
|
||||
# schema stays forward. This is exactly why every schema change must be
|
||||
@@ -386,3 +390,24 @@ jobs:
|
||||
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- housekeeping ------------------------------------------------------
|
||||
# Runs LAST, and only after the verify step proved the new containers are
|
||||
# up. See deploy/scripts/prune-images.mjs: Docker refuses to prune an
|
||||
# image a container references, so "the stack is running" is what makes
|
||||
# the current images safe. Pruning earlier would have nothing holding
|
||||
# them.
|
||||
#
|
||||
# continue-on-error: reclaiming disk is not what the deploy is for. A
|
||||
# prune that fails leaves a fat host, not a broken release.
|
||||
- name: Prune unused images
|
||||
continue-on-error: true
|
||||
env:
|
||||
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||
# Grace window. Keeps the previous few releases on disk so a rollback
|
||||
# dispatch is a stack swap instead of a re-pull.
|
||||
KEEP_HOURS: "168"
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||
run: node deploy/scripts/prune-images.mjs
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/api",
|
||||
"version": "1.0.20",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PoliciesModule } from "./policies/policies.module";
|
||||
import { PropertiesModule } from "./properties/properties.module";
|
||||
import { BillingModule } from "./billing/billing.module";
|
||||
import { StatementsModule } from "./statements/statements.module";
|
||||
import { CashReceiptsModule } from "./cash-receipts/cash-receipts.module";
|
||||
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
|
||||
import { BankModule } from "./bank/bank.module";
|
||||
import { OpsModule } from "./ops/ops.module";
|
||||
@@ -35,6 +36,7 @@ import { AppController } from "./app.controller";
|
||||
PropertiesModule,
|
||||
BillingModule,
|
||||
StatementsModule,
|
||||
CashReceiptsModule,
|
||||
PolicyOcrModule,
|
||||
BankModule,
|
||||
OpsModule,
|
||||
|
||||
@@ -115,13 +115,26 @@ describe("balance floor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* statement() issues two findMany calls: first the period discovery (which
|
||||
* years this customer has an archive for), then the statement rows. Select
|
||||
* the rows query by its shape so adding another lookup later moves nothing
|
||||
* here — the previous version indexed call 0 and broke the moment period
|
||||
* support landed.
|
||||
*/
|
||||
function rowsQuery(findMany: jest.Mock) {
|
||||
const call = findMany.mock.calls.find((c) => c[0]?.orderBy);
|
||||
if (!call) throw new Error("statement() issued no ordered rows query");
|
||||
return call[0];
|
||||
}
|
||||
|
||||
it("bounds the statement at the floor, inclusive", async () => {
|
||||
const floor = new Date("2026-01-01T00:00:00Z");
|
||||
const { service, findMany } = serviceWith(floor);
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
expect(findMany.mock.calls[0][0].where).toMatchObject({
|
||||
expect(rowsQuery(findMany).where).toMatchObject({
|
||||
customerId: "c1",
|
||||
transactionDate: { gte: floor },
|
||||
});
|
||||
@@ -132,23 +145,44 @@ describe("balance floor", () => {
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
expect(findMany.mock.calls[0][0].where).not.toHaveProperty(
|
||||
"transactionDate",
|
||||
);
|
||||
expect(rowsQuery(findMany).where).not.toHaveProperty("transactionDate");
|
||||
});
|
||||
|
||||
it("keeps the source-table exclusion alongside the floor", async () => {
|
||||
// The two guards answer different questions — one reproduces legacy's
|
||||
// DATOS2-only materialization, the other drops superseded history — and
|
||||
// dropping either one changes the customer's balance.
|
||||
// The three guards answer different questions — one windows imported
|
||||
// periods, one drops the cash receipt book the ledger already posts, the
|
||||
// floor drops superseded history — and dropping any one of them changes
|
||||
// the customer's balance.
|
||||
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
const where = findMany.mock.calls[0][0].where;
|
||||
expect(where.OR).toEqual([
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
|
||||
const where = rowsQuery(findMany).where;
|
||||
// Imported periods are windowed rather than excluded: history below the
|
||||
// year start (the only carry a floored-by-archive customer has), never
|
||||
// at or above it (those rows sit inside the next BALANCE FORWARD).
|
||||
expect(where.AND).toEqual([
|
||||
{
|
||||
OR: [
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||
{ transactionDate: { lt: expect.any(Date) } },
|
||||
],
|
||||
},
|
||||
// The cash journal, qualified by the database it came from — the
|
||||
// insurance line has its own EFECTIVO and that one is a real ledger.
|
||||
{
|
||||
OR: [
|
||||
{ legacySourceDb: null },
|
||||
{ legacySourceDb: { not: "UTILITIES" } },
|
||||
{ legacySourceTable: null },
|
||||
{
|
||||
legacySourceTable: {
|
||||
notIn: expect.arrayContaining(["EFECTIVO"]),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,10 +119,23 @@ export class BillingController {
|
||||
return this.billing.byCheck(n);
|
||||
}
|
||||
|
||||
/** One customer's full statement across both business lines. */
|
||||
/**
|
||||
* One customer's statement across both business lines, for one period.
|
||||
*
|
||||
* `year` omitted means the current one. Any earlier year is served from its
|
||||
* imported archive; the response carries `availableYears` so the caller can
|
||||
* offer only the periods this customer actually has.
|
||||
*/
|
||||
@Get("customers/:id")
|
||||
statement(@Param("id") id: string) {
|
||||
return this.billing.statement(id);
|
||||
statement(@Param("id") id: string, @Query("year") year?: string) {
|
||||
let parsed: number | undefined;
|
||||
if (year !== undefined && year !== "") {
|
||||
parsed = Number(year);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new BadRequestException("year debe ser un año de cuatro dígitos");
|
||||
}
|
||||
}
|
||||
return this.billing.statement(id, parsed);
|
||||
}
|
||||
|
||||
/** Cross-customer movement browser. */
|
||||
|
||||
@@ -210,18 +210,47 @@ export const BALANCE_FLOOR_JOIN = Prisma.sql`
|
||||
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
|
||||
|
||||
/**
|
||||
* Source tables excluded from the customer-facing statement.
|
||||
* `legacySourceTable` of an imported prior period.
|
||||
*
|
||||
* The legacy portal's `datosfreak` table was materialized from DATOS2 only
|
||||
* (`objects.json:1358`), so the customer's "current balance" never saw
|
||||
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor
|
||||
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so
|
||||
* the statement must drop them to match the legacy number the customer has
|
||||
* been quoted for years. The staff-facing balances worklist and movement
|
||||
* browser keep them — they're real money, just tracked separately
|
||||
* (FM3 = visa fee stream, EFECTIVO = cash receipt stream).
|
||||
* A closed year arrives as its own Access snapshot and is tagged rather than
|
||||
* dated (see migration/transform_transactions.py). The tag is what a period
|
||||
* view filters on: the archives are not cleanly date-bounded — 2025 carries
|
||||
* rows dated into 2026 — and legacy did not filter by date either, it selected
|
||||
* `FROM \`2025\``. Filtering on provenance reproduces the legacy period exactly.
|
||||
*/
|
||||
const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
||||
export const periodSourceTable = (year: number) => `datos2@${year}`;
|
||||
|
||||
/** Matches any imported period tag, for discovering which years a customer has. */
|
||||
export const PERIOD_TABLE_PREFIX = "datos2@";
|
||||
|
||||
/**
|
||||
* The legacy cash receipt book — a journal, not a ledger.
|
||||
*
|
||||
* `EFECTIVO` is the office's numbered receipt pad: money is handed over the
|
||||
* counter, a folio is written, and the same receipt is then *posted* to the
|
||||
* utilities ledger (`DATOS2`) as reference `C<folio>`. Legacy summed the ledger
|
||||
* alone — `ws/v2/lib/ledger_repository.php` reads `datosfreak`, which is
|
||||
* materialized from DATOS2 only (`objects.json:1358`). The migration flattened
|
||||
* both tables into one `transactions` table, so anything summing a customer's
|
||||
* rows counts every cash receipt twice.
|
||||
*
|
||||
* Verified against the live legacy database on 2026-08-20: of the 297 receipts
|
||||
* written in 2026, 296 carry a matching DATOS2 posting. Only folio 13536 (CL
|
||||
* 717, $400 USD) has no posting anywhere, and it wants a human's eyes rather
|
||||
* than a code change. Six receipts post converted to pesos under a mistyped
|
||||
* folio, which is why matching on folio and amount found fewer duplicate pairs
|
||||
* than actually exist — a reason to exclude the whole journal rather than to
|
||||
* exclude a list of confirmed pairs.
|
||||
*
|
||||
* THE DATABASE QUALIFIER IS LOAD-BEARING. `SEGUROS 16_be` keeps its own table
|
||||
* also called `EFECTIVO`, and that one is the insurance line's *only* ledger —
|
||||
* nothing posts it anywhere else. Excluding by table name alone erases the
|
||||
* whole insurance balance: 55,444.95 USD and 63,957.78 MXN across 102
|
||||
* customers, 99 of whom have no other rows at all.
|
||||
*/
|
||||
export const CASH_JOURNAL_SOURCE_DB = "UTILITIES";
|
||||
|
||||
export const CASH_JOURNAL_SOURCE_TABLES: readonly string[] = [
|
||||
"EFECTIVO",
|
||||
"EFECTIVO_BACKUP",
|
||||
"EFECTIVO FM3",
|
||||
@@ -229,6 +258,62 @@ const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
||||
"IVA 2015",
|
||||
];
|
||||
|
||||
/**
|
||||
* Prisma form of the cash-journal exclusion.
|
||||
*
|
||||
* Spelled as a positive OR on purpose. `notIn` alone compiles to SQL `NOT IN`,
|
||||
* and `NULL NOT IN (...)` is NULL rather than true, so every app-captured row
|
||||
* (no `legacySourceTable`) would silently vanish. Same for the database test.
|
||||
*/
|
||||
export const notCashJournal = (): Prisma.TransactionWhereInput => ({
|
||||
OR: [
|
||||
{ legacySourceDb: null },
|
||||
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
|
||||
],
|
||||
});
|
||||
|
||||
/** Raw-SQL form, for the aggregate queries that cannot use Prisma's builder. */
|
||||
export const NOT_CASH_JOURNAL = Prisma.sql`(
|
||||
t.legacySourceDb IS NULL
|
||||
OR t.legacySourceDb <> ${CASH_JOURNAL_SOURCE_DB}
|
||||
OR t.legacySourceTable IS NULL
|
||||
OR t.legacySourceTable NOT IN (${Prisma.join([
|
||||
...CASH_JOURNAL_SOURCE_TABLES,
|
||||
])}))`;
|
||||
|
||||
/**
|
||||
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||
*
|
||||
* The balance floor does not settle the archives on its own, in both
|
||||
* directions. A customer whose newest BALANCE FORWARD lives *inside* an archive
|
||||
* floors at that archive's own January 1st, so every row of it clears the floor
|
||||
* — and that is correct, because below the year start the archive is the only
|
||||
* carry there is. At or above the year start it must go: the archives spill a
|
||||
* couple of rows into the following January and those already sit inside the
|
||||
* next year's BALANCE FORWARD, which is the sum of the whole archive.
|
||||
*
|
||||
* Spelled as a positive OR for the same NULL reason as above.
|
||||
*/
|
||||
export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`(
|
||||
t.legacySourceTable IS NULL
|
||||
OR t.legacySourceTable NOT LIKE ${`${PERIOD_TABLE_PREFIX}%`}
|
||||
OR t.transactionDate < ${yearStart})`;
|
||||
|
||||
/** January 1st of the running year, UTC — the current period's lower bound. */
|
||||
export const currentYearStart = () =>
|
||||
new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||
|
||||
/**
|
||||
* What a balance sums over beyond "not voided": the cash journal out, the
|
||||
* archives kept to history. Shared by `balances()`, `stats()` and the reports,
|
||||
* so a customer's balance reads the same on every screen that quotes one.
|
||||
* Pair it with BALANCE_FLOOR_JOIN + NOT_SUPERSEDED.
|
||||
*/
|
||||
export const balanceScopeSql = () =>
|
||||
Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(currentYearStart())}`;
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -398,6 +483,14 @@ export class BillingService {
|
||||
async balances(params: BalanceParams) {
|
||||
const { query, page, pageSize, currency, balance, domain, sort } = params;
|
||||
|
||||
// A balance is what the customer owes, so it takes the same rules the
|
||||
// statement takes: the floor, the cash journal, and the archive window.
|
||||
// Without the last two the worklist quoted a different number than the
|
||||
// customer's own statement — NUMid 295 read 14,377.46 against a statement
|
||||
// of 4,377.46, and NUMid 10 read 2,362.20 against -1,137.80, the gap in
|
||||
// each case being a cash receipt already posted to the ledger.
|
||||
const scope = balanceScopeSql();
|
||||
|
||||
const filters: Prisma.Sql[] = [];
|
||||
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
||||
const txFilter = filters.length
|
||||
@@ -460,7 +553,7 @@ export class BillingService {
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter}
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
|
||||
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||
${having}
|
||||
${orderBy}
|
||||
@@ -476,7 +569,7 @@ export class BillingService {
|
||||
-- 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 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter}
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
|
||||
GROUP BY c.id
|
||||
${having}
|
||||
) x
|
||||
@@ -523,11 +616,20 @@ export class BillingService {
|
||||
* Two different questions live here and they use different row sets.
|
||||
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
|
||||
* INVENTORY — what is stored — and count everything not voided. Everything
|
||||
* under `byCurrency` / `byDomain` is a BALANCE, so it applies NOT_SUPERSEDED
|
||||
* and drops rows an opening balance already accounts for. The four aggregates
|
||||
* moved from Prisma groupBy to raw SQL to express that join; groupBy cannot.
|
||||
* under `byCurrency` / `byDomain` is a BALANCE, so it takes the same scope
|
||||
* `balances()` takes — the opening-balance floor, the cash journal and the
|
||||
* archive window — and the book has to agree with the worklist that sits
|
||||
* under it. The four aggregates moved from Prisma groupBy to raw SQL to
|
||||
* express that join; groupBy cannot.
|
||||
*
|
||||
* KNOWN DIVERGENCE, left deliberately: these three do not drop outstanding
|
||||
* rows, while `balances()` does. Reconciling them moves the book by about
|
||||
* 1.95M MXN and turns on whether an unfunded charge is owed by the customer,
|
||||
* which is the client's call and not settled yet.
|
||||
*/
|
||||
async stats() {
|
||||
const scope = balanceScopeSql();
|
||||
|
||||
const [movements, ledgerCustomers] = await Promise.all([
|
||||
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||
this.prisma.transaction
|
||||
@@ -559,7 +661,7 @@ export class BillingService {
|
||||
SUM(t.amount > 0) AS creditCount
|
||||
FROM transactions t
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||
GROUP BY t.currency
|
||||
`;
|
||||
|
||||
@@ -575,7 +677,7 @@ export class BillingService {
|
||||
SUM(t.amount) AS net, COUNT(*) AS count
|
||||
FROM transactions t
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||
GROUP BY t.domain, t.currency
|
||||
`;
|
||||
|
||||
@@ -596,7 +698,7 @@ export class BillingService {
|
||||
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
|
||||
FROM transactions t
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||
GROUP BY t.customerId, t.currency
|
||||
) x
|
||||
GROUP BY currency
|
||||
@@ -701,13 +803,22 @@ export class BillingService {
|
||||
/**
|
||||
* One customer's statement across both business lines.
|
||||
*
|
||||
* Returns the *whole* ledger rather than a page of it: the heaviest customer
|
||||
* Scoped to one calendar year and listed oldest-first, matching the legacy
|
||||
* EDO CUENTA report the office has printed for years: an opening balance at
|
||||
* the top, then the year's movements in the order they happened.
|
||||
*
|
||||
* `year` selects the period. The current year is read from the live tables;
|
||||
* any earlier year is read from its imported archive, which legacy kept as a
|
||||
* separate table and this reads by its `datos2@YYYY` tag. `availableYears`
|
||||
* reports which periods this customer actually has, so a caller never offers
|
||||
* a year that would render empty.
|
||||
*
|
||||
* Returns the *whole* year rather than a page of it: the heaviest customer
|
||||
* carries 365 movements (mean 26), and a running balance is meaningless if
|
||||
* the client only holds a slice. The running balance is accumulated per
|
||||
* currency in chronological order, then the list is handed back newest-first
|
||||
* with each row's balance-after already attached.
|
||||
* currency in chronological order, with each row's balance-after attached.
|
||||
*/
|
||||
async statement(customerId: string) {
|
||||
async statement(customerId: string, year?: number) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: {
|
||||
@@ -731,6 +842,49 @@ export class BillingService {
|
||||
throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
}
|
||||
|
||||
// Which periods this customer has. The current year is always offered —
|
||||
// it is the live ledger even when empty — and each imported archive adds
|
||||
// the year it holds.
|
||||
const archives = await this.prisma.transaction.findMany({
|
||||
where: {
|
||||
customerId,
|
||||
voidedAt: null,
|
||||
legacySourceTable: { startsWith: PERIOD_TABLE_PREFIX },
|
||||
},
|
||||
distinct: ["legacySourceTable"],
|
||||
select: { legacySourceTable: true },
|
||||
});
|
||||
const thisYear = new Date().getUTCFullYear();
|
||||
const archiveYears = archives
|
||||
.map((a) => Number(a.legacySourceTable?.slice(PERIOD_TABLE_PREFIX.length)))
|
||||
.filter((y) => Number.isInteger(y) && y < thisYear);
|
||||
const availableYears = [...new Set([thisYear, ...archiveYears])].sort(
|
||||
(a, b) => b - a,
|
||||
);
|
||||
|
||||
// An unknown year would silently render as the current one, which reads as
|
||||
// "this customer had no activity in 2019" rather than "there is no 2019".
|
||||
const requested = year ?? thisYear;
|
||||
if (!availableYears.includes(requested)) {
|
||||
throw new NotFoundException(
|
||||
`El cliente no tiene movimientos del periodo ${requested}.`,
|
||||
);
|
||||
}
|
||||
const isArchive = requested !== thisYear;
|
||||
|
||||
//
|
||||
// Deliberately open-ended at the top. A period is a table in legacy, not a
|
||||
// date range, so whatever the office filed in it belongs to it — including
|
||||
// the future-dated rows the current ledger carries (it runs to 2028). An
|
||||
// upper bound would hide them from every view, which is not what legacy did
|
||||
// and not what the office has been reading.
|
||||
//
|
||||
// An archive needs no fold at all: it *is* the period, and its own Jan-1
|
||||
// BALANCE FORWARD row is the carry, listed exactly as legacy listed it.
|
||||
const yearStart = isArchive
|
||||
? new Date(0)
|
||||
: new Date(Date.UTC(requested, 0, 1));
|
||||
|
||||
// One customer, so the balance floor is a single date rather than the
|
||||
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
||||
// the opening balance are already inside it, and showing them would both
|
||||
@@ -756,21 +910,48 @@ export class BillingService {
|
||||
const rows = await this.prisma.transaction.findMany({
|
||||
where: {
|
||||
customerId,
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// 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[],
|
||||
},
|
||||
},
|
||||
],
|
||||
...(isArchive
|
||||
? // An archive is already exactly one period's ledger, so the tag is
|
||||
// the whole filter. The balance floor is deliberately NOT applied:
|
||||
// it exists to stop a later opening balance double-counting the
|
||||
// history it summarizes, and here that history is the thing being
|
||||
// asked for. The exclusion list is moot too — an archive holds only
|
||||
// DATOS2 rows, which is what legacy's year table held.
|
||||
{ legacySourceTable: periodSourceTable(requested) }
|
||||
: {
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// An archive row belongs to this period only as history. Below
|
||||
// the year start it is exactly what `opening` is for, and for the
|
||||
// one customer whose newest BALANCE FORWARD lives *inside* an
|
||||
// archive it is the only carry there is — dropping it outright
|
||||
// understated NUMid 295 by his whole 2025 closing balance, 785.46.
|
||||
//
|
||||
// At or above the year start it must go. The archives spill a
|
||||
// couple of rows into the following January, and those are
|
||||
// already inside the next year's BALANCE FORWARD (which is the
|
||||
// sum of the whole archive), so listing them here would both
|
||||
// double-count and file a closed year's row as current.
|
||||
//
|
||||
// Spelled as a positive OR because `NOT (col LIKE ... AND ...)`
|
||||
// is NULL for an app-captured row, which would drop every one.
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ legacySourceTable: null },
|
||||
{
|
||||
legacySourceTable: {
|
||||
not: { startsWith: PERIOD_TABLE_PREFIX },
|
||||
},
|
||||
},
|
||||
{ transactionDate: { lt: yearStart } },
|
||||
],
|
||||
},
|
||||
// The cash receipt book is the ledger's own postings written a
|
||||
// second time, so listing it here would show every counter
|
||||
// payment twice and double the credit side.
|
||||
notCashJournal(),
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
@@ -790,15 +971,50 @@ export class BillingService {
|
||||
},
|
||||
});
|
||||
|
||||
// The statement covers one calendar year. The floor above normally lands on
|
||||
// January 1st of it already — the legacy publish writes one BALANCE FORWARD
|
||||
// per customer per year — in which case nothing extra is dropped here. When
|
||||
// it doesn't (a customer the last publish skipped, or one that never had an
|
||||
// opening balance), the earlier rows still have to be *counted* or every
|
||||
// balance below is wrong, so they are folded into `opening` rather than
|
||||
// listed. That is the same thing a BALANCE FORWARD row does, just computed.
|
||||
const running = new Map<string, Prisma.Decimal>();
|
||||
const movements = rows.map((r) => {
|
||||
/** Balance carried into `yearStart`, per currency. */
|
||||
const opening = new Map<string, Prisma.Decimal>();
|
||||
/** The same carried balance split by business line, keyed `domain|currency`. */
|
||||
const openingByDomain = new Map<
|
||||
string,
|
||||
{ domain: TransactionDomain; currency: string; amount: Prisma.Decimal }
|
||||
>();
|
||||
/** The rows the statement lists — this year's. Totals are built from these. */
|
||||
const visible: typeof rows = [];
|
||||
|
||||
const movements = rows.flatMap((r) => {
|
||||
const voided = r.voidedAt != null;
|
||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||
// Neither a voided row nor an outstanding (unpaid) one moves the running
|
||||
// balance — both show tagged, with the balance unchanged from the previous
|
||||
// live movement. Outstanding rows start counting once resolved.
|
||||
const next = voided || r.outstanding ? prev : prev.plus(r.amount);
|
||||
const counted = !voided && !r.outstanding;
|
||||
const next = counted ? prev.plus(r.amount) : prev;
|
||||
running.set(r.currency, next);
|
||||
|
||||
if (r.transactionDate < yearStart) {
|
||||
if (counted) {
|
||||
opening.set(r.currency, next);
|
||||
const dk = `${r.domain}|${r.currency}`;
|
||||
const od = openingByDomain.get(dk) ?? {
|
||||
domain: r.domain,
|
||||
currency: r.currency,
|
||||
amount: new Prisma.Decimal(0),
|
||||
};
|
||||
od.amount = od.amount.plus(r.amount);
|
||||
openingByDomain.set(dk, od);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
visible.push(r);
|
||||
return {
|
||||
id: r.id,
|
||||
transactionDate: r.transactionDate,
|
||||
@@ -818,7 +1034,6 @@ export class BillingService {
|
||||
balanceAfter: next.toFixed(2),
|
||||
};
|
||||
});
|
||||
movements.reverse();
|
||||
|
||||
// Per-currency summary, and the same split by business line so the two
|
||||
// ledgers are visibly one statement without being illegally added up.
|
||||
@@ -846,7 +1061,29 @@ export class BillingService {
|
||||
}
|
||||
>();
|
||||
|
||||
for (const r of rows) {
|
||||
for (const [currency] of opening) {
|
||||
perCurrency.set(currency, {
|
||||
currency,
|
||||
charges: new Prisma.Decimal(0),
|
||||
credits: new Prisma.Decimal(0),
|
||||
chargeCount: 0,
|
||||
creditCount: 0,
|
||||
count: 0,
|
||||
first: null,
|
||||
last: null,
|
||||
});
|
||||
}
|
||||
for (const [key, o] of openingByDomain) {
|
||||
perDomain.set(key, {
|
||||
domain: o.domain,
|
||||
currency: o.currency,
|
||||
charges: new Prisma.Decimal(0),
|
||||
credits: new Prisma.Decimal(0),
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const r of visible) {
|
||||
// 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;
|
||||
@@ -896,7 +1133,7 @@ export class BillingService {
|
||||
string,
|
||||
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
for (const r of visible) {
|
||||
if (r.voidedAt != null || r.outstanding) continue;
|
||||
if (!r.amount.lessThan(0)) continue;
|
||||
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
||||
@@ -915,25 +1152,38 @@ export class BillingService {
|
||||
propertyCount: customer._count.properties,
|
||||
policyCount: customer._count.policies,
|
||||
},
|
||||
summary: [...perCurrency.values()].map((c) => ({
|
||||
currency: c.currency,
|
||||
charges: c.charges.toFixed(2),
|
||||
credits: c.credits.toFixed(2),
|
||||
balance: c.charges.plus(c.credits).toFixed(2),
|
||||
chargeCount: c.chargeCount,
|
||||
creditCount: c.creditCount,
|
||||
count: c.count,
|
||||
firstMovement: c.first,
|
||||
lastMovement: c.last,
|
||||
})),
|
||||
byDomain: [...perDomain.values()].map((d) => ({
|
||||
domain: d.domain,
|
||||
currency: d.currency,
|
||||
charges: d.charges.toFixed(2),
|
||||
credits: d.credits.toFixed(2),
|
||||
balance: d.charges.plus(d.credits).toFixed(2),
|
||||
count: d.count,
|
||||
})),
|
||||
year: requested,
|
||||
availableYears,
|
||||
summary: [...perCurrency.values()].map((c) => {
|
||||
const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
|
||||
return {
|
||||
currency: c.currency,
|
||||
/** Balance carried in from before this year — legacy's BALANCE FORWARD. */
|
||||
opening: open.toFixed(2),
|
||||
charges: c.charges.toFixed(2),
|
||||
credits: c.credits.toFixed(2),
|
||||
balance: open.plus(c.charges).plus(c.credits).toFixed(2),
|
||||
chargeCount: c.chargeCount,
|
||||
creditCount: c.creditCount,
|
||||
count: c.count,
|
||||
firstMovement: c.first,
|
||||
lastMovement: c.last,
|
||||
};
|
||||
}),
|
||||
byDomain: [...perDomain.values()].map((d) => {
|
||||
const open =
|
||||
openingByDomain.get(`${d.domain}|${d.currency}`)?.amount ??
|
||||
new Prisma.Decimal(0);
|
||||
return {
|
||||
domain: d.domain,
|
||||
currency: d.currency,
|
||||
opening: open.toFixed(2),
|
||||
charges: d.charges.toFixed(2),
|
||||
credits: d.credits.toFixed(2),
|
||||
balance: open.plus(d.charges).plus(d.credits).toFixed(2),
|
||||
count: d.count,
|
||||
};
|
||||
}),
|
||||
byType: [...byType.values()]
|
||||
.map((t) => ({
|
||||
name: t.name,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import {
|
||||
CASH_JOURNAL_SOURCE_DB,
|
||||
CASH_JOURNAL_SOURCE_TABLES,
|
||||
NOT_CASH_JOURNAL,
|
||||
notCashJournal,
|
||||
} from "./billing.service";
|
||||
|
||||
/**
|
||||
* `EFECTIVO` is the office's paper receipt book, and every receipt in it is
|
||||
* also posted to the utilities ledger as `C<folio>`. Both copies were imported
|
||||
* into one `transactions` table, so a balance that reads the journal counts
|
||||
* each counter payment twice — 1,094,347.78 MXN of phantom credit book-wide,
|
||||
* and 3,500.00 of it on NUMid 10 alone.
|
||||
*
|
||||
* These fail silently in the worst way: the numbers stay plausible, they are
|
||||
* just too generous to the customer. Two shapes of mistake are easy to make
|
||||
* here and both are covered below — dropping the database qualifier (which
|
||||
* erases the insurance line's only ledger) and writing the exclusion as a bare
|
||||
* `NOT IN` (which erases every app-captured row).
|
||||
*/
|
||||
describe("cash journal exclusion", () => {
|
||||
describe("raw SQL form", () => {
|
||||
it("binds the source database rather than interpolating it", () => {
|
||||
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
|
||||
});
|
||||
|
||||
it("qualifies the table names with the database they came from", () => {
|
||||
// `SEGUROS 16_be` has its own EFECTIVO and it is the insurance line's
|
||||
// ONLY ledger — nothing posts it anywhere else. Matching on the table
|
||||
// name alone erases 55,444.95 USD and 63,957.78 MXN across 102 customers.
|
||||
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb <>");
|
||||
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
|
||||
});
|
||||
|
||||
it("spells both null cases out instead of relying on NOT IN", () => {
|
||||
// `NULL NOT IN (...)` is NULL, not true. Without these branches every
|
||||
// app-captured row — the ones staff key in by hand — drops out of the
|
||||
// balance while still showing in the movement browser.
|
||||
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb IS NULL");
|
||||
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceTable IS NULL");
|
||||
});
|
||||
|
||||
it("covers the whole cash family, not just EFECTIVO", () => {
|
||||
for (const table of CASH_JOURNAL_SOURCE_TABLES) {
|
||||
expect(NOT_CASH_JOURNAL.values).toContain(table);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a single parenthesised term, safe to AND into a WHERE clause", () => {
|
||||
// It is composed as `... AND ${NOT_CASH_JOURNAL} AND ...`. An unbracketed
|
||||
// OR chain would swallow every condition after it and silently widen the
|
||||
// whole query to the entire table.
|
||||
const sql = NOT_CASH_JOURNAL.sql.trim();
|
||||
expect(sql.startsWith("(")).toBe(true);
|
||||
expect(sql.endsWith(")")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prisma form", () => {
|
||||
it("matches the raw form's terms so the two cannot drift apart", () => {
|
||||
const branches = notCashJournal().OR as Prisma.TransactionWhereInput[];
|
||||
expect(branches).toEqual([
|
||||
{ legacySourceDb: null },
|
||||
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a fresh object each call", () => {
|
||||
// It is spread into `AND: [...]` arrays that Prisma may mutate; a shared
|
||||
// singleton would leak one query's filters into the next.
|
||||
expect(notCashJournal()).not.toBe(notCashJournal());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { periodSourceTable } from "./billing.service";
|
||||
|
||||
/**
|
||||
* A closed year is imported as its own tagged set of rows rather than being
|
||||
* identified by date. The tag is written by migration/transform_transactions.py
|
||||
* and read by BillingService.statement, the edo-cuenta-datos report, and the
|
||||
* PHP portal — three places that must agree on the exact string.
|
||||
*/
|
||||
describe("periodSourceTable", () => {
|
||||
it("names the archive the migration writes", () => {
|
||||
expect(periodSourceTable(2025)).toBe("datos2@2025");
|
||||
expect(periodSourceTable(2024)).toBe("datos2@2024");
|
||||
});
|
||||
|
||||
it("stays distinct from the live ledger's own table", () => {
|
||||
// The live table is plain `datos2`. legacyId is a positional ordinal that
|
||||
// restarts at 0 in every archive, so a shared name would collide with the
|
||||
// current year row-for-row on the unique key.
|
||||
expect(periodSourceTable(2025)).not.toBe("datos2");
|
||||
expect(periodSourceTable(2025).startsWith("datos2@")).toBe(true);
|
||||
});
|
||||
|
||||
it("is not matched by the statement's cash-source exclusion list", () => {
|
||||
// STATEMENT_EXCLUDED_SOURCE_TABLES drops the EFECTIVO family to reproduce
|
||||
// legacy's DATOS2-only datosfreak. An archive holds DATOS2 rows, so it must
|
||||
// survive that filter or a prior year renders empty.
|
||||
const excluded = [
|
||||
"EFECTIVO",
|
||||
"EFECTIVO_BACKUP",
|
||||
"EFECTIVO FM3",
|
||||
"CHEQUE FM3",
|
||||
"IVA 2015",
|
||||
];
|
||||
expect(excluded).not.toContain(periodSourceTable(2025));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
/**
|
||||
* The statement is a *year* statement, like the EDO CUENTA report the office
|
||||
* prints: this year's movements, oldest-first, opening on the balance carried
|
||||
* in from before it.
|
||||
*
|
||||
* The carrying is the part worth testing. Dropping earlier rows from the list
|
||||
* is easy; dropping them from the arithmetic too would restart every balance at
|
||||
* zero on January 1st, and nothing would throw — the numbers would just be
|
||||
* wrong, which is exactly how the double-counting bug lived for years.
|
||||
*/
|
||||
describe("statement year scoping", () => {
|
||||
const YEAR = new Date().getUTCFullYear();
|
||||
|
||||
function d(iso: string) {
|
||||
return new Date(`${iso}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
type RowSpec = {
|
||||
id: string;
|
||||
date: Date;
|
||||
amount: string;
|
||||
currency?: string;
|
||||
domain?: string;
|
||||
voidedAt?: Date | null;
|
||||
outstanding?: boolean;
|
||||
};
|
||||
|
||||
function row(r: RowSpec) {
|
||||
return {
|
||||
id: r.id,
|
||||
transactionDate: r.date,
|
||||
domain: r.domain ?? "UTILITY",
|
||||
amount: new Prisma.Decimal(r.amount),
|
||||
currency: r.currency ?? "MXN",
|
||||
reference: null,
|
||||
period: null,
|
||||
checkNumber: null,
|
||||
message: null,
|
||||
legacySourceTable: null,
|
||||
voidedAt: r.voidedAt ?? null,
|
||||
outstanding: r.outstanding ?? false,
|
||||
type: { nameEn: "WATER", nameEs: "AGUA" },
|
||||
};
|
||||
}
|
||||
|
||||
/** No BALANCE FORWARD row, so the floor is null and every row is fetched. */
|
||||
function serviceWith(rows: RowSpec[]) {
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: "c1",
|
||||
name: "CUADROS, JORGE H.",
|
||||
preferredCurrency: "MXN",
|
||||
_count: { properties: 0, policies: 0 },
|
||||
}),
|
||||
},
|
||||
transaction: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue(rows.map(row)),
|
||||
},
|
||||
};
|
||||
return new BillingService(prisma as never);
|
||||
}
|
||||
|
||||
it("lists the year's movements oldest-first", async () => {
|
||||
const s = await serviceWith([
|
||||
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
|
||||
{ id: "b", date: d(`${YEAR}-03-04`), amount: "250" },
|
||||
{ id: "c", date: d(`${YEAR}-07-16`), amount: "-40" },
|
||||
]).statement("c1");
|
||||
|
||||
expect(s.movements.map((m) => m.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("leaves earlier years off the list", async () => {
|
||||
const s = await serviceWith([
|
||||
{ id: "old", date: d(`${YEAR - 1}-11-30`), amount: "-500" },
|
||||
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-100" },
|
||||
]).statement("c1");
|
||||
|
||||
expect(s.movements.map((m) => m.id)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("carries the earlier years' balance instead of discarding it", async () => {
|
||||
// 1,000 credit left over from last year, 300 charged this year: the
|
||||
// customer is 700 in credit, not 300 in debt.
|
||||
const s = await serviceWith([
|
||||
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000" },
|
||||
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||
]).statement("c1");
|
||||
|
||||
const mxn = s.summary.find((x) => x.currency === "MXN");
|
||||
expect(mxn?.opening).toBe("1000.00");
|
||||
expect(mxn?.charges).toBe("-300.00");
|
||||
expect(mxn?.balance).toBe("700.00");
|
||||
// The running balance on the listed row picks up where last year left off.
|
||||
expect(s.movements[0].balanceAfter).toBe("700.00");
|
||||
});
|
||||
|
||||
it("carries it per business line as well", async () => {
|
||||
const s = await serviceWith([
|
||||
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", domain: "INSURANCE" },
|
||||
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300", domain: "INSURANCE" },
|
||||
]).statement("c1");
|
||||
|
||||
const line = s.byDomain.find((x) => x.domain === "INSURANCE");
|
||||
expect(line?.opening).toBe("1000.00");
|
||||
expect(line?.balance).toBe("700.00");
|
||||
});
|
||||
|
||||
it("still reports a currency that only moved in earlier years", async () => {
|
||||
// Otherwise a customer sitting on a dollar credit they haven't touched all
|
||||
// year would appear to have no dollar balance at all.
|
||||
const s = await serviceWith([
|
||||
{ id: "old", date: d(`${YEAR - 2}-05-01`), amount: "180.83", currency: "USD" },
|
||||
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||
]).statement("c1");
|
||||
|
||||
const usd = s.summary.find((x) => x.currency === "USD");
|
||||
expect(usd?.balance).toBe("180.83");
|
||||
expect(usd?.count).toBe(0);
|
||||
});
|
||||
|
||||
it("does not carry a voided earlier row", async () => {
|
||||
const s = await serviceWith([
|
||||
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", voidedAt: d(`${YEAR - 1}-12-16`) },
|
||||
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||
]).statement("c1");
|
||||
|
||||
const mxn = s.summary.find((x) => x.currency === "MXN");
|
||||
expect(mxn?.opening).toBe("0.00");
|
||||
expect(mxn?.balance).toBe("-300.00");
|
||||
});
|
||||
|
||||
it("reports the year it covers", async () => {
|
||||
const s = await serviceWith([
|
||||
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
|
||||
]).statement("c1");
|
||||
|
||||
expect(s.year).toBe(YEAR);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
|
||||
/** The legacy EFE form: cliente, fecha, monto + moneda, concepto, depósito. */
|
||||
export class CreateCashReceiptDto {
|
||||
@IsString() @MinLength(1) customerId!: string;
|
||||
/** Defaults to the next folio in the book. */
|
||||
@IsOptional() @IsInt() @Min(1) folio?: number;
|
||||
@IsString() @MinLength(1) receivedAt!: string;
|
||||
@IsNumber() amount!: number;
|
||||
@IsEnum(Currency) currency!: Currency;
|
||||
/** Dollars credited to a pesos ledger: the rate they were taken at. */
|
||||
@IsOptional() @IsNumber() exchangeRate?: number;
|
||||
@IsString() @MinLength(1) concept!: string;
|
||||
@IsOptional() @IsString() depositedAt?: string;
|
||||
}
|
||||
|
||||
export class SetDepositDto {
|
||||
/** Empty clears it. */
|
||||
@IsOptional() @IsString() depositedAt?: string;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { CashReceiptsService } from "./cash-receipts.service";
|
||||
import { CreateCashReceiptDto, SetDepositDto } from "./cash-receipt.dto";
|
||||
|
||||
/** The EFE cash receipt book. Literal routes sit above `:folio`. */
|
||||
@Controller("cash-receipts")
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
export class CashReceiptsController {
|
||||
constructor(
|
||||
private readonly receipts: CashReceiptsService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
@Get("next-folio")
|
||||
async nextFolio() {
|
||||
return { folio: await this.receipts.nextFolio() };
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
) {
|
||||
return this.receipts.list(
|
||||
query,
|
||||
Math.max(1, Number(page) || 1),
|
||||
Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":folio")
|
||||
byFolio(@Param("folio", ParseIntPipe) folio: number) {
|
||||
return this.receipts.byFolio(folio);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireAbility("ledger:create")
|
||||
async create(@Body() dto: CreateCashReceiptDto, @Req() req: Request) {
|
||||
const r = await this.receipts.create(dto, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "cash-receipt.create", {
|
||||
folio: r.folio,
|
||||
customerId: r.customerId,
|
||||
amount: r.amount.toFixed(2),
|
||||
currency: r.currency,
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
@Patch(":id/deposit")
|
||||
@RequireAbility("ledger:create")
|
||||
async setDeposit(@Param("id") id: string, @Body() dto: SetDepositDto, @Req() req: Request) {
|
||||
const r = await this.receipts.setDeposited(id, dto.depositedAt);
|
||||
void this.audit.log(this.actingId(req), "cash-receipt.deposit", {
|
||||
folio: r.folio,
|
||||
depositedAt: r.depositedAt,
|
||||
});
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CashReceiptsController } from "./cash-receipts.controller";
|
||||
import { CashReceiptsService } from "./cash-receipts.service";
|
||||
|
||||
@Module({
|
||||
controllers: [CashReceiptsController],
|
||||
providers: [CashReceiptsService],
|
||||
// Customer onboarding issues the opening cash receipt in its own transaction.
|
||||
exports: [CashReceiptsService],
|
||||
})
|
||||
export class CashReceiptsModule {}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CASH_JOURNAL_SOURCE_DB } from "../billing/billing.service";
|
||||
import { getCompanyInfo } from "../reports/company";
|
||||
import type { CreateCashReceiptDto } from "./cash-receipt.dto";
|
||||
|
||||
/** The ledger concept legacy posted every cash receipt under. */
|
||||
const CASH_CONCEPT = "CASH DEPOSIT";
|
||||
|
||||
const RECEIPT_INCLUDE = {
|
||||
customer: { select: { id: true, name: true, folderNumber: true } },
|
||||
preparedBy: { select: { name: true } },
|
||||
transaction: { select: { id: true, voidedAt: true, amount: true, currency: true } },
|
||||
} satisfies Prisma.CashReceiptInclude;
|
||||
|
||||
/**
|
||||
* Legacy grouped a day's cash under one pseudo check: CASH + MMDD + P(esos) or
|
||||
* D(ólares) — `CASH0714P`. Kept so by-check views put these rows where the
|
||||
* office expects them.
|
||||
*/
|
||||
export function cashCheckNumber(receivedAt: Date, currency: "MXN" | "USD"): string {
|
||||
const mm = String(receivedAt.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(receivedAt.getUTCDate()).padStart(2, "0");
|
||||
return `CASH${mm}${dd}${currency === "USD" ? "D" : "P"}`;
|
||||
}
|
||||
|
||||
function parseDay(v: string | undefined, field: string): Date | undefined {
|
||||
if (!v) return undefined;
|
||||
const d = new Date(v.length === 10 ? `${v}T00:00:00.000Z` : v);
|
||||
if (isNaN(d.getTime())) throw new BadRequestException(`${field} inválida.`);
|
||||
return d;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CashReceiptsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* One past the highest folio in the book — the platform's own receipts or,
|
||||
* before the first one, the legacy EFECTIVO journal imported at cutover.
|
||||
* Only the utilities book: insurance kept its own EFECTIVO with its own
|
||||
* numbers.
|
||||
*/
|
||||
async nextFolio(db: Prisma.TransactionClient = this.prisma): Promise<number> {
|
||||
const native = await db.cashReceipt.aggregate({ _max: { folio: true } });
|
||||
const [legacy] = await db.$queryRaw<{ mx: bigint | number | null }[]>`
|
||||
SELECT MAX(CAST(reference AS UNSIGNED)) AS mx FROM transactions
|
||||
WHERE legacySourceDb = ${CASH_JOURNAL_SOURCE_DB}
|
||||
AND legacySourceTable = 'EFECTIVO'
|
||||
AND reference REGEXP '^[0-9]+$'`;
|
||||
return Math.max(native._max.folio ?? 0, Number(legacy?.mx ?? 0)) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a receipt and post its credit, in one database transaction: a
|
||||
* receipt without its posting is money the customer paid and never sees,
|
||||
* and a posting without its receipt is the folio-13536 kind of hole.
|
||||
*
|
||||
* Pass `db` to run inside a caller's transaction (customer onboarding).
|
||||
*/
|
||||
async create(
|
||||
dto: CreateCashReceiptDto,
|
||||
preparedById: string,
|
||||
db?: Prisma.TransactionClient,
|
||||
) {
|
||||
if (!(dto.amount > 0)) throw new BadRequestException("El monto debe ser mayor a cero.");
|
||||
if (dto.exchangeRate != null) {
|
||||
if (dto.currency !== "USD") {
|
||||
throw new BadRequestException("El tipo de cambio sólo aplica a dólares.");
|
||||
}
|
||||
if (!(dto.exchangeRate > 0)) throw new BadRequestException("Tipo de cambio inválido.");
|
||||
}
|
||||
const receivedAt = parseDay(dto.receivedAt, "Fecha")!;
|
||||
const depositedAt = parseDay(dto.depositedAt, "Fecha de depósito");
|
||||
|
||||
const run = (tx: Prisma.TransactionClient) => this.issue(tx, dto, receivedAt, depositedAt, preparedById);
|
||||
if (db) return run(db);
|
||||
try {
|
||||
return await this.prisma.$transaction(run);
|
||||
} catch (err) {
|
||||
// Two receipts issued in the same instant both computed the same next
|
||||
// folio; the loser simply takes the one after.
|
||||
if (dto.folio == null && isUniqueViolation(err)) return this.prisma.$transaction(run);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async issue(
|
||||
tx: Prisma.TransactionClient,
|
||||
dto: CreateCashReceiptDto,
|
||||
receivedAt: Date,
|
||||
depositedAt: Date | undefined,
|
||||
preparedById: string,
|
||||
) {
|
||||
const customer = await tx.customer.findUnique({
|
||||
where: { id: dto.customerId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!customer) throw new NotFoundException("Cliente no encontrado.");
|
||||
|
||||
const folio = dto.folio ?? (await this.nextFolio(tx));
|
||||
// A typed folio may belong to the legacy book, which has no unique key here.
|
||||
const taken =
|
||||
(await tx.cashReceipt.count({ where: { folio } })) +
|
||||
(await tx.transaction.count({
|
||||
where: {
|
||||
legacySourceDb: CASH_JOURNAL_SOURCE_DB,
|
||||
legacySourceTable: "EFECTIVO",
|
||||
reference: String(folio),
|
||||
},
|
||||
}));
|
||||
if (taken) throw new ConflictException(`El folio ${folio} ya existe.`);
|
||||
|
||||
const type = await tx.typeTransaction.findFirst({
|
||||
where: { nameEn: CASH_CONCEPT },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Dollars taken against a pesos account are credited in pesos at the
|
||||
// rate given, as legacy did (C13830: 1,500 USD posted as 25,200 MXN).
|
||||
const converted = dto.exchangeRate != null;
|
||||
const amount = new Prisma.Decimal(dto.amount.toFixed(2));
|
||||
const posted = converted
|
||||
? amount.times(dto.exchangeRate!).toDecimalPlaces(2)
|
||||
: amount;
|
||||
|
||||
const id = randomUUID();
|
||||
const transaction = await tx.transaction.create({
|
||||
data: {
|
||||
customerId: dto.customerId,
|
||||
domain: "UTILITY",
|
||||
typeId: type?.id,
|
||||
transactionDate: receivedAt,
|
||||
reference: `C${folio}`,
|
||||
amount: posted,
|
||||
currency: converted ? "MXN" : dto.currency,
|
||||
exchangeRate: converted ? new Prisma.Decimal(dto.exchangeRate!) : undefined,
|
||||
checkNumber: cashCheckNumber(receivedAt, dto.currency),
|
||||
message: dto.concept,
|
||||
captureSource: "MANUAL",
|
||||
captureRef: id,
|
||||
},
|
||||
});
|
||||
|
||||
return tx.cashReceipt.create({
|
||||
data: {
|
||||
id,
|
||||
folio,
|
||||
customerId: dto.customerId,
|
||||
receivedAt,
|
||||
amount,
|
||||
currency: dto.currency,
|
||||
exchangeRate: converted ? new Prisma.Decimal(dto.exchangeRate!) : undefined,
|
||||
concept: dto.concept,
|
||||
preparedById,
|
||||
depositedAt,
|
||||
transactionId: transaction.id,
|
||||
},
|
||||
include: RECEIPT_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async list(query: string | undefined, page: number, pageSize: number) {
|
||||
const q = query?.trim();
|
||||
const where: Prisma.CashReceiptWhereInput = q
|
||||
? /^\d+$/.test(q)
|
||||
? { OR: [{ folio: Number(q) }, { customer: { folderNumber: Number(q) } }] }
|
||||
: { customer: { name: { contains: q } } }
|
||||
: {};
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.cashReceipt.count({ where }),
|
||||
this.prisma.cashReceipt.findMany({
|
||||
where,
|
||||
orderBy: { folio: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: RECEIPT_INCLUDE,
|
||||
}),
|
||||
]);
|
||||
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
/** One receipt, with what the printout needs to head it. */
|
||||
async byFolio(folio: number) {
|
||||
const receipt = await this.prisma.cashReceipt.findUnique({
|
||||
where: { folio },
|
||||
include: RECEIPT_INCLUDE,
|
||||
});
|
||||
if (!receipt) throw new NotFoundException(`No existe el folio ${folio}.`);
|
||||
const { logo: _logo, logoPath: _path, ...company } = getCompanyInfo();
|
||||
return { ...receipt, company };
|
||||
}
|
||||
|
||||
async setDeposited(id: string, depositedAt: string | undefined) {
|
||||
const found = await this.prisma.cashReceipt.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!found) throw new NotFoundException("Recibo no encontrado.");
|
||||
return this.prisma.cashReceipt.update({
|
||||
where: { id },
|
||||
data: { depositedAt: parseDay(depositedAt, "Fecha de depósito") ?? null },
|
||||
include: RECEIPT_INCLUDE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ConflictException } from "@nestjs/common";
|
||||
import { CashReceiptsService, cashCheckNumber } from "./cash-receipts.service";
|
||||
|
||||
/**
|
||||
* A cash receipt is two writes that must agree: the receipt and its C<folio>
|
||||
* credit. Checked here: the credit legacy would have posted, and that a folio
|
||||
* the legacy book already used is refused. Folio numbering itself runs against
|
||||
* real data (it reads the imported EFECTIVO journal).
|
||||
*/
|
||||
|
||||
function build(opts: { legacyTaken?: boolean } = {}) {
|
||||
const posted: Record<string, unknown>[] = [];
|
||||
const tx = {
|
||||
customer: { findUnique: jest.fn().mockResolvedValue({ id: "c1" }) },
|
||||
cashReceipt: {
|
||||
aggregate: jest.fn().mockResolvedValue({ _max: { folio: 13934 } }),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
create: jest.fn(({ data }) => Promise.resolve(data)),
|
||||
},
|
||||
transaction: {
|
||||
count: jest.fn().mockResolvedValue(opts.legacyTaken ? 1 : 0),
|
||||
create: jest.fn(({ data }) => {
|
||||
posted.push(data);
|
||||
return Promise.resolve({ id: "tx1", ...data });
|
||||
}),
|
||||
},
|
||||
typeTransaction: { findFirst: jest.fn().mockResolvedValue({ id: "cash-deposit" }) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ mx: 13900 }]),
|
||||
};
|
||||
const prisma = { $transaction: jest.fn((fn) => fn(tx)) };
|
||||
return { service: new CashReceiptsService(prisma as never), posted };
|
||||
}
|
||||
|
||||
const base = {
|
||||
customerId: "c1",
|
||||
receivedAt: "2026-07-14",
|
||||
concept: "RECEIVED $1500 DLLS FOR ACCOUNT.",
|
||||
};
|
||||
|
||||
describe("cash receipts", () => {
|
||||
it("takes the next folio and posts a C<folio> credit", async () => {
|
||||
const { service, posted } = build();
|
||||
const r = await service.create({ ...base, amount: 9000, currency: "MXN" }, "u1");
|
||||
|
||||
expect(r.folio).toBe(13935);
|
||||
expect(posted[0]).toMatchObject({
|
||||
reference: "C13935",
|
||||
checkNumber: "CASH0714P",
|
||||
currency: "MXN",
|
||||
typeId: "cash-deposit",
|
||||
});
|
||||
expect(Number(posted[0].amount)).toBe(9000);
|
||||
});
|
||||
|
||||
it("credits dollars to a pesos ledger at the given rate, as legacy did", async () => {
|
||||
// Legacy C13830: 1,500 USD at 16.80 posted as 25,200 MXN.
|
||||
const { service, posted } = build();
|
||||
await service.create(
|
||||
{ ...base, amount: 1500, currency: "USD", exchangeRate: 16.8 },
|
||||
"u1",
|
||||
);
|
||||
|
||||
expect(posted[0]).toMatchObject({ currency: "MXN", checkNumber: "CASH0714D" });
|
||||
expect(Number(posted[0].amount)).toBe(25200);
|
||||
});
|
||||
|
||||
it("refuses a typed folio the legacy book already used", async () => {
|
||||
const { service, posted } = build({ legacyTaken: true });
|
||||
await expect(
|
||||
service.create({ ...base, folio: 13800, amount: 1, currency: "MXN" }, "u1"),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(posted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("names the pseudo check by day and currency", () => {
|
||||
expect(cashCheckNumber(new Date("2026-01-05T00:00:00Z"), "USD")).toBe("CASH0105D");
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,11 @@ import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
@@ -19,6 +21,8 @@ export class CreateCustomerDto {
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
/** Shelf folder number; null clears it. Unique among non-archived customers. */
|
||||
@IsOptional() @IsInt() @Min(1) folderNumber?: number | null;
|
||||
@IsOptional() @IsString() addressLine1?: string;
|
||||
@IsOptional() @IsString() addressLine2?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ConflictException } from "@nestjs/common";
|
||||
import { CustomersService } from "./customers.service";
|
||||
|
||||
/**
|
||||
* A folder number belongs to one open account at a time, and an archived
|
||||
* customer's number is history, not a claim. The nextFolderNumber SQL is
|
||||
* checked against real data, not here.
|
||||
*/
|
||||
|
||||
function build(opts: { holder?: { name: string } | null; archived?: boolean; folder?: number | null }) {
|
||||
const prisma = {
|
||||
customer: {
|
||||
findFirst: jest.fn().mockResolvedValue(opts.holder ?? null),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: "c1",
|
||||
archivedAt: opts.archived ? new Date() : null,
|
||||
folderNumber: opts.folder ?? null,
|
||||
}),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)),
|
||||
},
|
||||
};
|
||||
return { service: new CustomersService(prisma as never, {} as never), prisma };
|
||||
}
|
||||
|
||||
describe("customer folder numbers", () => {
|
||||
it("refuses a number an open account already holds", async () => {
|
||||
const { service, prisma } = build({ holder: { name: "SWEET, DONALD E." } });
|
||||
|
||||
await expect(service.create({ name: "Nuevo", folderNumber: 214 })).rejects.toThrow(
|
||||
new ConflictException("La carpeta 214 ya la tiene SWEET, DONALD E."),
|
||||
);
|
||||
expect(prisma.customer.create).not.toHaveBeenCalled();
|
||||
// Only open accounts count as holders.
|
||||
expect(prisma.customer.findFirst.mock.calls[0][0].where).toMatchObject({
|
||||
folderNumber: 214,
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not count the customer being edited as the holder", async () => {
|
||||
const { service, prisma } = build({});
|
||||
|
||||
await service.update("c1", { folderNumber: 214 });
|
||||
expect(prisma.customer.findFirst.mock.calls[0][0].where).toMatchObject({
|
||||
id: { not: "c1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("lets an archived customer keep a number someone else now holds", async () => {
|
||||
const { service, prisma } = build({ archived: true, holder: { name: "X" } });
|
||||
|
||||
await expect(service.update("c1", { folderNumber: 214 })).resolves.toBeDefined();
|
||||
expect(prisma.customer.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("will not reopen an account whose folder was handed out meanwhile", async () => {
|
||||
const { service, prisma } = build({ archived: true, folder: 214, holder: { name: "X" } });
|
||||
|
||||
await expect(service.restore("c1")).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.customer.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the check when no number is given", async () => {
|
||||
const { service, prisma } = build({ holder: { name: "X" } });
|
||||
|
||||
await service.create({ name: "Nuevo" });
|
||||
await service.update("c1", { folderNumber: null });
|
||||
expect(prisma.customer.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
|
||||
|
||||
/**
|
||||
* The /clientes/:id ledger card is titled "Estado de cuenta" and links straight
|
||||
* to the statement, so its per-line totals must be the statement's numbers.
|
||||
*
|
||||
* They were a raw lifetime sum — no floor, no source exclusion — which
|
||||
* double-counted the pre-cutover history each BALANCE FORWARD row absorbs.
|
||||
* Importing prior periods made it visibly worse: every closed year is now held
|
||||
* a second time as its own tagged copy, so an unfloored sum adds each one on
|
||||
* top of the opening balance that already contains it.
|
||||
*/
|
||||
describe("customer file ledger card", () => {
|
||||
function serviceWith(floor: Date | null) {
|
||||
const groupBy = jest.fn().mockResolvedValue([]);
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: "c1", transactions: [] }),
|
||||
},
|
||||
transaction: {
|
||||
findFirst: jest
|
||||
.fn()
|
||||
.mockResolvedValue(floor ? { transactionDate: floor } : null),
|
||||
groupBy,
|
||||
},
|
||||
};
|
||||
return {
|
||||
service: new CustomersService(prisma as never, {} as never),
|
||||
prisma,
|
||||
groupBy,
|
||||
};
|
||||
}
|
||||
|
||||
it("takes the same balance floor the statement takes", async () => {
|
||||
const floor = new Date("2026-01-01T00:00:00Z");
|
||||
const { service, prisma, groupBy } = serviceWith(floor);
|
||||
|
||||
await service.detail("c1");
|
||||
|
||||
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(groupBy.mock.calls[0][0].where).toMatchObject({
|
||||
transactionDate: { gte: floor },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies no floor when the customer never had an opening balance", async () => {
|
||||
// 102 customers have no BALANCE FORWARD row at all. Inventing a floor for
|
||||
// them would hide their whole ledger.
|
||||
const { service, groupBy } = serviceWith(null);
|
||||
|
||||
await service.detail("c1");
|
||||
|
||||
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
|
||||
});
|
||||
|
||||
it("counts an archive as history but never as current", async () => {
|
||||
// The floor alone is not enough: a customer floored by an archive clears
|
||||
// it with every row of that archive, and the rows archives spill into the
|
||||
// following January clear any floor. But excluding archives outright is
|
||||
// wrong too — below the year start they are the only carry a
|
||||
// floored-by-archive customer has (NUMid 295, 785.46).
|
||||
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
await service.detail("c1");
|
||||
|
||||
const and = groupBy.mock.calls[0][0].where.AND;
|
||||
const rule = and.find((c: { OR?: unknown[] }) =>
|
||||
JSON.stringify(c).includes("datos2@"),
|
||||
);
|
||||
expect(rule.OR).toEqual([
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||
{ transactionDate: { lt: expect.any(Date) } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the cash-source exclusion so it reads like the statement", async () => {
|
||||
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
await service.detail("c1");
|
||||
|
||||
const and = groupBy.mock.calls[0][0].where.AND;
|
||||
const sourceRule = and.find((c: { OR?: unknown[] }) =>
|
||||
JSON.stringify(c).includes("EFECTIVO"),
|
||||
);
|
||||
expect(sourceRule).toBeDefined();
|
||||
});
|
||||
|
||||
it("drops outstanding rows, as every balance does", async () => {
|
||||
const { service, groupBy } = serviceWith(null);
|
||||
|
||||
await service.detail("c1");
|
||||
|
||||
expect(groupBy.mock.calls[0][0].where).toMatchObject({
|
||||
outstanding: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps archives out of the year's movement list too", async () => {
|
||||
// datos2@2024 carries rows dated into 2026; a date test alone would show
|
||||
// them as current-year movements next to the live ledger's own copy.
|
||||
const { service, prisma } = serviceWith(null);
|
||||
|
||||
await service.detail("c1");
|
||||
|
||||
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
||||
expect(include.transactions.where.OR).toEqual([
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||
// Nothing below yearStart reaches this list, so the third branch never
|
||||
// admits an archive row here — it is carried for one shared rule.
|
||||
{ transactionDate: { lt: expect.any(Date) } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { CustomersService } from "./customers.service";
|
||||
|
||||
/**
|
||||
* Onboarding writes four tables in one transaction; what is checked here is
|
||||
* that a request it can't complete is refused before anything is written, and
|
||||
* that a cash opening balance goes through the receipt book inside that same
|
||||
* transaction. The rollback itself was exercised against the dev database.
|
||||
*/
|
||||
|
||||
function build() {
|
||||
const tx = {
|
||||
customer: { create: jest.fn().mockResolvedValue({ id: "c1" }) },
|
||||
property: { create: jest.fn().mockResolvedValue({ id: "p1" }) },
|
||||
propertyService: { createMany: jest.fn() },
|
||||
typeTransaction: { findFirst: jest.fn() },
|
||||
transaction: { create: jest.fn() },
|
||||
};
|
||||
const prisma = {
|
||||
customer: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
$transaction: jest.fn((fn) => fn(tx)),
|
||||
};
|
||||
const cash = { create: jest.fn().mockResolvedValue({ folio: 13935 }) };
|
||||
return { service: new CustomersService(prisma as never, cash as never), prisma, tx, cash };
|
||||
}
|
||||
|
||||
const pay = { amount: 500, currency: "MXN" as const, receivedAt: "2026-10-12" };
|
||||
|
||||
describe("customer onboarding", () => {
|
||||
it("refuses a check opening balance with no check number, writing nothing", async () => {
|
||||
const { service, prisma } = build();
|
||||
await expect(
|
||||
service.onboard({ customer: { name: "X" }, openingPayment: { ...pay, method: "CHECK" } }, "u1"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses services with no property to hang them on", async () => {
|
||||
const { service, prisma } = build();
|
||||
await expect(
|
||||
service.onboard({ customer: { name: "X" }, services: [{ kind: "WATER" }] }, "u1"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("issues the cash receipt inside the onboarding transaction", async () => {
|
||||
const { service, tx, cash } = build();
|
||||
const r = await service.onboard(
|
||||
{
|
||||
customer: { name: "X" },
|
||||
property: { addressLine1: "Calle 1" },
|
||||
services: [{ kind: "WATER", accountNumber: "3014057" }],
|
||||
openingPayment: { ...pay, method: "CASH" },
|
||||
},
|
||||
"u1",
|
||||
);
|
||||
|
||||
expect(cash.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ customerId: "c1", amount: 500, concept: "SALDO INICIAL" }),
|
||||
"u1",
|
||||
tx,
|
||||
);
|
||||
expect(tx.propertyService.createMany).toHaveBeenCalledWith({
|
||||
data: [{ kind: "WATER", accountNumber: "3014057", propertyId: "p1" }],
|
||||
});
|
||||
expect(r.receipt).toEqual({ folio: 13935 });
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
import { OnboardCustomerDto } from "./onboard-customer.dto";
|
||||
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("customers")
|
||||
@@ -45,6 +46,12 @@ export class CustomersController {
|
||||
return { candidates: await this.numids.emptyCandidates() };
|
||||
}
|
||||
|
||||
/** Suggested folder number for a new customer. Above `:id` like the rest. */
|
||||
@Get("folder/next")
|
||||
async nextFolder() {
|
||||
return { folderNumber: await this.customers.nextFolderNumber() };
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@@ -77,6 +84,26 @@ export class CustomersController {
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alta en una pantalla. Gated on customer:create; the property, service and
|
||||
* ledger abilities it also exercises sit at the same STAFF tier.
|
||||
*/
|
||||
@Post("onboard")
|
||||
@RequireAbility("customer:create")
|
||||
async onboard(@Body() dto: OnboardCustomerDto, @Req() req: Request) {
|
||||
const r = await this.customers.onboard(dto, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "customer.onboard", {
|
||||
customerId: r.customer.id,
|
||||
name: r.customer.name,
|
||||
folderNumber: r.customer.folderNumber,
|
||||
propertyId: r.property?.id,
|
||||
services: dto.services?.length ?? 0,
|
||||
folio: r.receipt?.folio,
|
||||
openingTransactionId: r.receipt?.transactionId ?? r.payment?.id,
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequireAbility("customer:update")
|
||||
async update(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SettingsModule } from "../settings/settings.module";
|
||||
import { CashReceiptsModule } from "../cash-receipts/cash-receipts.module";
|
||||
import { CustomersController } from "./customers.controller";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
|
||||
@Module({
|
||||
imports: [SettingsModule],
|
||||
imports: [SettingsModule, CashReceiptsModule],
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService, NumidService],
|
||||
exports: [NumidService],
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
import type { OnboardCustomerDto } from "./onboard-customer.dto";
|
||||
import { CashReceiptsService } from "../cash-receipts/cash-receipts.service";
|
||||
import {
|
||||
BALANCE_FORWARD_TYPE,
|
||||
notCashJournal,
|
||||
PERIOD_TABLE_PREFIX,
|
||||
} from "../billing/billing.service";
|
||||
|
||||
/**
|
||||
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||
*
|
||||
* A closed year is imported as its own tagged copy (`datos2@2025`). Below the
|
||||
* year start it is history and counts — for the one customer whose newest
|
||||
* BALANCE FORWARD lives inside an archive it is the only carry there is, and
|
||||
* dropping it understated NUMid 295 by his entire 2025 closing balance. At or
|
||||
* above the year start it must go: the archives spill a couple of rows into the
|
||||
* following January, and those already sit inside the next year's BALANCE
|
||||
* FORWARD, which is the sum of the whole archive.
|
||||
*
|
||||
* Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
|
||||
* NULL for an app-captured row (no legacySourceTable), dropping every one.
|
||||
*/
|
||||
const archiveIsHistory = (
|
||||
yearStart: Date,
|
||||
): Prisma.TransactionWhereInput => ({
|
||||
OR: [
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
||||
{ transactionDate: { lt: yearStart } },
|
||||
],
|
||||
});
|
||||
|
||||
export interface ListParams {
|
||||
query?: string;
|
||||
@@ -22,7 +58,10 @@ function toDate(v?: string): Date | null | undefined {
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly cashReceipts: CashReceiptsService,
|
||||
) {}
|
||||
|
||||
/** Unified customer list with search + business-line filter, paginated. */
|
||||
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
||||
@@ -39,6 +78,7 @@ export class CustomersService {
|
||||
{ mobile: { contains: q } },
|
||||
{ city: { contains: q } },
|
||||
{ legacyRefs: { some: { legacyId: { contains: q } } } },
|
||||
...(/^\d+$/.test(q) ? [{ folderNumber: Number(q) }] : []),
|
||||
];
|
||||
}
|
||||
if (line === "utility") where.properties = { some: {} };
|
||||
@@ -61,6 +101,7 @@ export class CustomersService {
|
||||
id: true,
|
||||
name: true,
|
||||
nameSource: true,
|
||||
folderNumber: true,
|
||||
city: true,
|
||||
state: true,
|
||||
email: true,
|
||||
@@ -77,6 +118,7 @@ export class CustomersService {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
nameSource: r.nameSource,
|
||||
folderNumber: r.folderNumber,
|
||||
city: r.city,
|
||||
state: r.state,
|
||||
email: r.email,
|
||||
@@ -96,6 +138,13 @@ export class CustomersService {
|
||||
|
||||
/** Full unified customer view: identity + both business lines + ledger. */
|
||||
async detail(id: string) {
|
||||
// The movement list on the customer file is the same statement the office
|
||||
// prints, so it follows the same rule as BillingService.statement: this
|
||||
// calendar year, oldest-first. No `take` any more — the cap used to hide
|
||||
// the end of a busy customer's year once the order flipped, and a single
|
||||
// year is small (365 rows for the heaviest customer in the book).
|
||||
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -117,8 +166,21 @@ export class CustomersService {
|
||||
},
|
||||
},
|
||||
transactions: {
|
||||
orderBy: { transactionDate: "desc" },
|
||||
take: 100,
|
||||
// Archives are excluded by tag, not by date. They are not cleanly
|
||||
// bounded — datos2@2024 carries rows dated 2022, 2023, 2025 and one
|
||||
// in 2026, datos2@2025 two more — so a date test alone would surface
|
||||
// a closed year's rows in the current year's list, duplicating the
|
||||
// live ledger's own copy of them for three customers.
|
||||
// Archives are kept out by tag, not by date. They are not cleanly
|
||||
// bounded — datos2@2025 carries rows dated into 2026 — so a date test
|
||||
// alone would surface a closed year's rows in the current year's
|
||||
// list. Nothing below yearStart reaches this list anyway, so the
|
||||
// window rule reduces to a plain exclusion here.
|
||||
where: {
|
||||
transactionDate: { gte: yearStart },
|
||||
...archiveIsHistory(yearStart),
|
||||
},
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
include: { type: true },
|
||||
},
|
||||
},
|
||||
@@ -130,16 +192,51 @@ export class CustomersService {
|
||||
|
||||
// Ledger totals per domain + currency (the "one statement across both
|
||||
// business lines" payoff), computed in the DB rather than in JS.
|
||||
//
|
||||
// These have to answer the same question BillingService.statement answers,
|
||||
// because this card is titled "Estado de cuenta" and links straight to it —
|
||||
// two screens quoting one customer two different balances is worse than
|
||||
// either number alone. So it takes the same three rules the statement uses:
|
||||
// the balance floor, the cash-source exclusion, and dropping outstanding
|
||||
// rows the office has not paid yet.
|
||||
//
|
||||
// Without the floor these were a raw lifetime sum, double-counting the
|
||||
// pre-cutover history each BALANCE FORWARD row already absorbs. Importing
|
||||
// prior periods made that visibly worse: for NUMid 501 the tiles read
|
||||
// -7,119.29 before the archives landed and -15,270.59 after, against a true
|
||||
// -10,715.29 — the difference being exactly the 2024 and 2025 closing
|
||||
// balances, added a second time on top of the opening row that contains
|
||||
// them.
|
||||
const floor = await this.prisma.transaction.findFirst({
|
||||
where: {
|
||||
customerId: id,
|
||||
voidedAt: null,
|
||||
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||
},
|
||||
orderBy: { transactionDate: "desc" },
|
||||
select: { transactionDate: true },
|
||||
});
|
||||
|
||||
const summary = await this.prisma.transaction.groupBy({
|
||||
by: ["domain", "currency"],
|
||||
// Exclude voided rows so the per-domain balance matches the statement.
|
||||
where: { customerId: id, voidedAt: null },
|
||||
where: {
|
||||
customerId: id,
|
||||
voidedAt: null,
|
||||
outstanding: false,
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// The floor alone does not settle the archives: a customer floored by
|
||||
// an archive clears it with every row of that archive, and the rows
|
||||
// archives spill into the following January clear any floor.
|
||||
AND: [archiveIsHistory(yearStart), notCashJournal()],
|
||||
},
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
return {
|
||||
...customer,
|
||||
/** Calendar year the movement list covers. */
|
||||
transactionYear: yearStart.getUTCFullYear(),
|
||||
transactionSummary: summary.map((s) => ({
|
||||
domain: s.domain,
|
||||
currency: s.currency,
|
||||
@@ -164,6 +261,7 @@ export class CustomersService {
|
||||
}
|
||||
|
||||
async create(dto: CreateCustomerDto) {
|
||||
await this.assertFolderFree(dto.folderNumber);
|
||||
return this.prisma.customer.create({
|
||||
// App-created rows: nameMissing false (name is required), no legacy
|
||||
// provenance — those columns stay null, marking a native record.
|
||||
@@ -171,8 +269,85 @@ export class CustomersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an account from one screen: customer, property, services and the
|
||||
* opening balance taken as the first payment. One database transaction — a
|
||||
* half-created account (a customer with no property, a payment with no
|
||||
* customer) is worse than none, because the office would key it again.
|
||||
*/
|
||||
async onboard(dto: OnboardCustomerDto, userId: string) {
|
||||
const pay = dto.openingPayment;
|
||||
if (pay) {
|
||||
if (!(pay.amount > 0)) throw new BadRequestException("El saldo inicial debe ser mayor a cero.");
|
||||
if (pay.method === "CHECK" && !pay.checkNumber?.trim()) {
|
||||
throw new BadRequestException("Falta el número de cheque del saldo inicial.");
|
||||
}
|
||||
}
|
||||
if (dto.services?.length && !dto.property) {
|
||||
throw new BadRequestException("Los servicios necesitan una propiedad.");
|
||||
}
|
||||
await this.assertFolderFree(dto.customer.folderNumber);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const customer = await tx.customer.create({
|
||||
data: { ...this.toData(dto.customer), name: dto.customer.name, nameMissing: false },
|
||||
});
|
||||
|
||||
const property = dto.property
|
||||
? await tx.property.create({ data: { ...dto.property, customerId: customer.id } })
|
||||
: null;
|
||||
if (property && dto.services?.length) {
|
||||
await tx.propertyService.createMany({
|
||||
data: dto.services.map((s) => ({ ...s, propertyId: property.id })),
|
||||
});
|
||||
}
|
||||
|
||||
const concept = pay?.concept?.trim() || "SALDO INICIAL";
|
||||
let receipt = null;
|
||||
let payment = null;
|
||||
if (pay?.method === "CASH") {
|
||||
receipt = await this.cashReceipts.create(
|
||||
{
|
||||
customerId: customer.id,
|
||||
receivedAt: pay.receivedAt,
|
||||
amount: pay.amount,
|
||||
currency: pay.currency,
|
||||
exchangeRate: pay.exchangeRate,
|
||||
concept,
|
||||
},
|
||||
userId,
|
||||
tx,
|
||||
);
|
||||
} else if (pay?.method === "CHECK") {
|
||||
const date = new Date(pay.receivedAt);
|
||||
if (isNaN(date.getTime())) throw new BadRequestException("Fecha del saldo inicial inválida.");
|
||||
const type = await tx.typeTransaction.findFirst({
|
||||
where: { nameEn: "CHECK DEPOSIT" },
|
||||
select: { id: true },
|
||||
});
|
||||
payment = await tx.transaction.create({
|
||||
data: {
|
||||
customerId: customer.id,
|
||||
domain: "UTILITY",
|
||||
typeId: type?.id,
|
||||
transactionDate: date,
|
||||
amount: new Prisma.Decimal(pay.amount.toFixed(2)),
|
||||
currency: pay.currency,
|
||||
checkNumber: pay.checkNumber!.trim(),
|
||||
message: concept,
|
||||
captureSource: "MANUAL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { customer, property, receipt, payment };
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCustomerDto) {
|
||||
await this.ensureExists(id);
|
||||
const current = await this.ensureExists(id);
|
||||
// An archived customer's number is history, not a claim on the shelf.
|
||||
if (!current.archivedAt) await this.assertFolderFree(dto.folderNumber, id);
|
||||
return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
|
||||
}
|
||||
|
||||
@@ -186,7 +361,9 @@ export class CustomersService {
|
||||
}
|
||||
|
||||
async restore(id: string) {
|
||||
await this.ensureExists(id);
|
||||
const current = await this.ensureExists(id);
|
||||
// Its folder may have been handed to someone else while it was closed.
|
||||
await this.assertFolderFree(current.folderNumber, id);
|
||||
return this.prisma.customer.update({
|
||||
where: { id },
|
||||
data: { archivedAt: null },
|
||||
@@ -196,9 +373,46 @@ export class CustomersService {
|
||||
private async ensureExists(id: string) {
|
||||
const found = await this.prisma.customer.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
select: { id: true, archivedAt: true, folderNumber: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException(`Customer ${id} not found`);
|
||||
return found;
|
||||
}
|
||||
|
||||
// --- folder numbers ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A folder number belongs to one open account at a time. Archived customers
|
||||
* keep theirs as a record of which folder they used, without holding it.
|
||||
*
|
||||
* ponytail: check-then-write, not a constraint — two staff saving the same
|
||||
* number in the same instant both win. A functional unique index on
|
||||
* IF(archivedAt IS NULL, folderNumber, NULL) closes it, if Prisma ever models one.
|
||||
*/
|
||||
private async assertFolderFree(folderNumber: number | null | undefined, exceptId?: string) {
|
||||
if (folderNumber == null) return;
|
||||
const holder = await this.prisma.customer.findFirst({
|
||||
where: { folderNumber, archivedAt: null, ...(exceptId ? { id: { not: exceptId } } : {}) },
|
||||
select: { name: true },
|
||||
});
|
||||
if (holder) {
|
||||
throw new ConflictException(`La carpeta ${folderNumber} ya la tiene ${holder.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowest folder number no open account holds — how the office reuses them. */
|
||||
async nextFolderNumber(): Promise<number> {
|
||||
const [row] = await this.prisma.$queryRaw<{ n: bigint | number }[]>`
|
||||
SELECT MIN(c.n) AS n FROM (
|
||||
SELECT 1 AS n
|
||||
UNION SELECT folderNumber + 1 FROM customers
|
||||
WHERE archivedAt IS NULL AND folderNumber IS NOT NULL
|
||||
) c
|
||||
WHERE c.n NOT IN (
|
||||
SELECT folderNumber FROM customers
|
||||
WHERE archivedAt IS NULL AND folderNumber IS NOT NULL
|
||||
)`;
|
||||
return Number(row.n);
|
||||
}
|
||||
|
||||
/** Top-line counts for a dashboard header. */
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { ServiceDto } from "../properties/property.dto";
|
||||
|
||||
/** The property, minus the customer it is being created for. */
|
||||
export class OnboardPropertyDto {
|
||||
@IsOptional() @IsString() addressLine1?: string;
|
||||
@IsOptional() @IsString() addressLine2?: string;
|
||||
@IsOptional() @IsString() phone1?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening balance the office takes when an account is opened, captured as
|
||||
* the customer's first payment. Cash issues an EFE receipt; a check posts a
|
||||
* CHECK DEPOSIT against that check number.
|
||||
*/
|
||||
export class OpeningPaymentDto {
|
||||
@IsIn(["CASH", "CHECK"]) method!: "CASH" | "CHECK";
|
||||
@IsNumber() amount!: number;
|
||||
@IsEnum(Currency) currency!: Currency;
|
||||
@IsString() @MinLength(1) receivedAt!: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
/** Cash only: dollars credited to a pesos ledger. */
|
||||
@IsOptional() @IsNumber() exchangeRate?: number;
|
||||
@IsOptional() @IsString() concept?: string;
|
||||
}
|
||||
|
||||
/** One screen: cliente + propiedad + servicios + saldo inicial. */
|
||||
export class OnboardCustomerDto {
|
||||
@ValidateNested() @Type(() => CreateCustomerDto) customer!: CreateCustomerDto;
|
||||
|
||||
@IsOptional() @ValidateNested() @Type(() => OnboardPropertyDto)
|
||||
property?: OnboardPropertyDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(12)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ServiceDto)
|
||||
services?: ServiceDto[];
|
||||
|
||||
@IsOptional() @ValidateNested() @Type(() => OpeningPaymentDto)
|
||||
openingPayment?: OpeningPaymentDto;
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
@@ -12,6 +14,7 @@ import { Currency } from "@jorgecuadros/database";
|
||||
/** Same editable fields as create, all optional. */
|
||||
export class UpdateCustomerDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
@IsOptional() @IsInt() @Min(1) folderNumber?: number | null;
|
||||
@IsOptional() @IsString() addressLine1?: string;
|
||||
@IsOptional() @IsString() addressLine2?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
|
||||
@@ -31,6 +31,29 @@ export const INGEST_FILES = [
|
||||
] as const;
|
||||
export type IngestName = (typeof INGEST_FILES)[number];
|
||||
|
||||
/**
|
||||
* A prior-period archive: one Access snapshot per closed year, named for the
|
||||
* period it holds. `2025.accdb` is UTILITIES as it stood when 2025 was cut.
|
||||
*
|
||||
* The filename is the entire declaration of the period — nothing inside the
|
||||
* file names its year, because a snapshot's `datos2` is indistinguishable from
|
||||
* the live one — so this pattern is both the allowlist and the contract. It is
|
||||
* anchored and allows no separator, which is what keeps an upload from
|
||||
* escaping the ingest directory.
|
||||
*/
|
||||
const PERIOD_FILE_RE = /^(\d{4})\.accdb$/i;
|
||||
|
||||
/** Earliest period we will accept, so a typo'd year cannot mint a bogus one. */
|
||||
const PERIOD_MIN_YEAR = 1990;
|
||||
|
||||
export function periodYearOf(name: string): number | null {
|
||||
const m = PERIOD_FILE_RE.exec(name);
|
||||
if (!m) return null;
|
||||
const year = Number(m[1]);
|
||||
if (year < PERIOD_MIN_YEAR || year > new Date().getUTCFullYear()) return null;
|
||||
return year;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix for every command containing a pipe. Without it the exit status of
|
||||
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
|
||||
@@ -120,19 +143,30 @@ export class OpsService implements OnModuleInit {
|
||||
|
||||
/* -------------------------------------------------------------- ingest */
|
||||
|
||||
private assertIngestName(name: string): IngestName {
|
||||
if (!INGEST_FILES.includes(name as IngestName)) {
|
||||
throw new BadRequestException(
|
||||
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return name as IngestName;
|
||||
private assertIngestName(name: string): string {
|
||||
if (INGEST_FILES.includes(name as IngestName)) return name;
|
||||
if (periodYearOf(name) !== null) return name;
|
||||
throw new BadRequestException(
|
||||
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}` +
|
||||
`, o un archivo de periodo anterior con nombre AAAA.accdb (por ejemplo 2025.accdb).`,
|
||||
);
|
||||
}
|
||||
|
||||
async listIngest(): Promise<
|
||||
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[]
|
||||
{
|
||||
name: string;
|
||||
present: boolean;
|
||||
size: number | null;
|
||||
modifiedAt: string | null;
|
||||
/** Set only on a prior-period archive; null on the four fixed sources. */
|
||||
periodYear: number | null;
|
||||
}[]
|
||||
> {
|
||||
return Promise.all(
|
||||
// The four fixed sources are listed whether present or not — they are
|
||||
// required, so "missing" is the useful state to show. Period archives are
|
||||
// optional and unbounded, so they are listed only once uploaded, newest
|
||||
// year first.
|
||||
const fixed = await Promise.all(
|
||||
INGEST_FILES.map(async (name) => {
|
||||
try {
|
||||
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||
@@ -141,12 +175,46 @@ export class OpsService implements OnModuleInit {
|
||||
present: true,
|
||||
size: st.size,
|
||||
modifiedAt: st.mtime.toISOString(),
|
||||
periodYear: null as number | null,
|
||||
};
|
||||
} catch {
|
||||
return { name, present: false, size: null, modifiedAt: null };
|
||||
return {
|
||||
name,
|
||||
present: false,
|
||||
size: null,
|
||||
modifiedAt: null,
|
||||
periodYear: null as number | null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(this.ingestDir);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
const periods = (
|
||||
await Promise.all(
|
||||
entries
|
||||
.map((name) => ({ name, year: periodYearOf(name) }))
|
||||
.filter((e): e is { name: string; year: number } => e.year !== null)
|
||||
.sort((a, b) => b.year - a.year)
|
||||
.map(async ({ name, year }) => {
|
||||
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||
return {
|
||||
name,
|
||||
present: true,
|
||||
size: st.size,
|
||||
modifiedAt: st.mtime.toISOString(),
|
||||
periodYear: year,
|
||||
};
|
||||
}),
|
||||
)
|
||||
).filter(Boolean);
|
||||
|
||||
return [...fixed, ...periods];
|
||||
}
|
||||
|
||||
async saveIngest(name: string, data: Buffer): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { periodYearOf } from "./ops.service";
|
||||
|
||||
/**
|
||||
* `periodYearOf` is the upload allowlist for prior-period archives, so it is
|
||||
* doing two jobs at once: deciding what counts as a period file, and keeping a
|
||||
* caller-supplied name from escaping the ingest directory. Both are pinned here.
|
||||
*/
|
||||
describe("periodYearOf", () => {
|
||||
it("accepts a four-digit year archive", () => {
|
||||
expect(periodYearOf("2025.accdb")).toBe(2025);
|
||||
expect(periodYearOf("1999.accdb")).toBe(1999);
|
||||
});
|
||||
|
||||
it("is case-insensitive on the extension", () => {
|
||||
expect(periodYearOf("2025.ACCDB")).toBe(2025);
|
||||
});
|
||||
|
||||
it("rejects a path that would escape the ingest directory", () => {
|
||||
// The name is joined onto the ingest path, so anything with a separator or
|
||||
// a parent reference has to fail before it reaches the filesystem.
|
||||
expect(periodYearOf("../2025.accdb")).toBeNull();
|
||||
expect(periodYearOf("../../etc/passwd")).toBeNull();
|
||||
expect(periodYearOf("sub/2025.accdb")).toBeNull();
|
||||
expect(periodYearOf("2025.accdb/../../x")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects names that only look like a period", () => {
|
||||
expect(periodYearOf("202.accdb")).toBeNull();
|
||||
expect(periodYearOf("20255.accdb")).toBeNull();
|
||||
expect(periodYearOf("2025.mdb")).toBeNull();
|
||||
expect(periodYearOf("copia 2025.accdb")).toBeNull();
|
||||
expect(periodYearOf("2025.accdb.bak")).toBeNull();
|
||||
expect(periodYearOf("UTILITIES.accdb")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects years outside the plausible range", () => {
|
||||
// A typo'd year would otherwise mint a period nobody can ever reconcile:
|
||||
// there is no BALANCE FORWARD for the year after it to check against.
|
||||
expect(periodYearOf("1889.accdb")).toBeNull();
|
||||
expect(periodYearOf(`${new Date().getUTCFullYear() + 1}.accdb`)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts the current year, which is the earliest a period can be cut", () => {
|
||||
expect(periodYearOf(`${new Date().getUTCFullYear()}.accdb`)).toBe(
|
||||
new Date().getUTCFullYear(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,10 @@ import { Currency } from "@jorgecuadros/database";
|
||||
// Each child DTO covers create; updates reuse the same shape with all fields
|
||||
// optional via the corresponding Update class. Route supplies the policyId.
|
||||
|
||||
// A policy split into several exhibiciones prices each payment on its own —
|
||||
// the Access form printed the whole money row once per pago — so the premium
|
||||
// breakdown repeats here. `amount` remains what was actually collected and is
|
||||
// never recomputed from the breakdown; the two differ by rounding in the books.
|
||||
export class InstallmentDto {
|
||||
@IsInt() sequence!: number;
|
||||
@IsOptional() @IsNumber() amount?: number;
|
||||
@@ -20,6 +24,13 @@ export class InstallmentDto {
|
||||
@IsOptional() @IsString() paidDate?: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() surcharge?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() taxRate?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsNumber() commission?: number;
|
||||
}
|
||||
export class UpdateInstallmentDto {
|
||||
@IsOptional() @IsInt() sequence?: number;
|
||||
@@ -29,6 +40,13 @@ export class UpdateInstallmentDto {
|
||||
@IsOptional() @IsString() paidDate?: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() surcharge?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() taxRate?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsNumber() commission?: number;
|
||||
}
|
||||
|
||||
export class VehicleDto {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { IsNumber, IsOptional, IsString, Max, Min, MinLength } from "class-validator";
|
||||
|
||||
export class ProviderDto {
|
||||
@IsString() @MinLength(1) name!: string;
|
||||
@@ -7,13 +7,19 @@ export class UpdateProviderDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
}
|
||||
|
||||
// `taxRate` is the IVA fraction for this line of business (0.08 = 8%), the
|
||||
// legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables made editable. Bounded at
|
||||
// 1 because a rate is a fraction, not a percentage: 8 entered here would tax a
|
||||
// $600 premium $4,800, and the mistake is easy to make.
|
||||
export class PolicyTypeDto {
|
||||
@IsString() @MinLength(1) name!: string;
|
||||
@IsOptional() @IsString() shortDescription?: string;
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
|
||||
}
|
||||
export class UpdatePolicyTypeDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
@IsOptional() @IsString() shortDescription?: string;
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
|
||||
}
|
||||
|
||||
export class AdjusterDto {
|
||||
|
||||
@@ -250,7 +250,16 @@ export class PoliciesService {
|
||||
const [types, providers] = await this.prisma.$transaction([
|
||||
this.prisma.policyType.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, _count: { select: { policies: true } } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortDescription: true,
|
||||
// The capture form computes IVA client-side as the operator types,
|
||||
// so the rate has to travel with the type list it already loads —
|
||||
// an extra round-trip per keystroke is not an option.
|
||||
taxRate: true,
|
||||
_count: { select: { policies: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.insuranceProvider.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
@@ -259,7 +268,13 @@ export class PoliciesService {
|
||||
]);
|
||||
|
||||
return {
|
||||
types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })),
|
||||
types: types.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
shortDescription: t.shortDescription,
|
||||
taxRate: t.taxRate,
|
||||
count: t._count.policies,
|
||||
})),
|
||||
providers: providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -397,6 +412,13 @@ export class PoliciesService {
|
||||
paidDate: toDate(dto.paidDate) ?? undefined,
|
||||
checkNumber: dto.checkNumber,
|
||||
isCash: dto.isCash,
|
||||
netPremium: dto.netPremium,
|
||||
surcharge: dto.surcharge,
|
||||
policyFee: dto.policyFee,
|
||||
tax: dto.tax,
|
||||
taxRate: dto.taxRate,
|
||||
total: dto.total,
|
||||
commission: dto.commission,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -412,6 +434,13 @@ export class PoliciesService {
|
||||
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
|
||||
checkNumber: dto.checkNumber,
|
||||
isCash: dto.isCash,
|
||||
netPremium: dto.netPremium,
|
||||
surcharge: dto.surcharge,
|
||||
policyFee: dto.policyFee,
|
||||
tax: dto.tax,
|
||||
taxRate: dto.taxRate,
|
||||
total: dto.total,
|
||||
commission: dto.commission,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
import { Currency, PaymentFrequency } from "@jorgecuadros/database";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not
|
||||
* exposed for editing. Dates arrive as ISO strings and are coerced by the
|
||||
* service. `total` is legacy-dead data — the UI uses netPremium. */
|
||||
* service. `total` is legacy-dead data on migrated rows — list and sort code
|
||||
* still uses netPremium — but the capture form writes it going forward, along
|
||||
* with `tax`, from the arithmetic in premium.ts. Both arrive as plain numbers
|
||||
* rather than being recomputed server-side: the printed policy is the record
|
||||
* of truth and staff must be able to key its rounding verbatim. */
|
||||
export class CreatePolicyDto {
|
||||
@IsString() @MinLength(1) policyNumber!: string;
|
||||
@IsString() @MinLength(1) customerId!: string;
|
||||
@@ -24,10 +28,14 @@ export class CreatePolicyDto {
|
||||
@IsOptional() @IsString() policyTo?: string;
|
||||
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() surcharge?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() commission?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() taxRate?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
@IsOptional() @IsString() observations?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@@ -48,10 +56,14 @@ export class UpdatePolicyDto {
|
||||
@IsOptional() @IsString() policyTo?: string;
|
||||
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() surcharge?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() commission?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() taxRate?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
@IsOptional() @IsString() observations?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
DEFAULT_TAX_RATE,
|
||||
computeTax,
|
||||
computeTotal,
|
||||
resolveTaxRate,
|
||||
surchargeApplies,
|
||||
taxableBase,
|
||||
} from "./premium";
|
||||
|
||||
/**
|
||||
* The reference case is policy 7006785 (MULT, semestral, GMX, two payments) as
|
||||
* it stands in the Access books — the screen Jorge sent. Both of its money
|
||||
* rows are asserted, because the second one is the case that proves the
|
||||
* surcharge belongs in the taxable base and that a zero policy fee is a real
|
||||
* value rather than a missing one.
|
||||
*/
|
||||
describe("premium arithmetic", () => {
|
||||
it("matches the first payment of policy 7006785", () => {
|
||||
const parts = { netPremium: 610.86, surcharge: 8.55, policyFee: 31.0 };
|
||||
expect(taxableBase(parts)).toBe(650.41);
|
||||
expect(computeTax(parts, 0.08)).toBe(52.03);
|
||||
expect(computeTotal(parts, 0.08)).toBe(702.44);
|
||||
});
|
||||
|
||||
it("matches the second payment of policy 7006785", () => {
|
||||
const parts = { netPremium: 589.71, surcharge: 8.26, policyFee: 0 };
|
||||
expect(computeTax(parts, 0.08)).toBe(47.84);
|
||||
expect(computeTotal(parts, 0.08)).toBe(645.81);
|
||||
});
|
||||
|
||||
it("excluding the surcharge does NOT reconcile", () => {
|
||||
// Guards the one decision in this module that is easy to get wrong: the
|
||||
// spoken-language version of the rule ("prima neta + derecho * 8%") gives
|
||||
// 51.35, and the printed policy says 52.03.
|
||||
const withoutSurcharge = { netPremium: 610.86, surcharge: 0, policyFee: 31.0 };
|
||||
expect(computeTax(withoutSurcharge, 0.08)).not.toBe(52.03);
|
||||
});
|
||||
|
||||
it("treats blank and null money as zero, not NaN", () => {
|
||||
expect(taxableBase({ netPremium: "610.86", surcharge: null, policyFee: "" })).toBe(
|
||||
610.86,
|
||||
);
|
||||
expect(computeTax({ netPremium: undefined, surcharge: null, policyFee: null }, 0.08))
|
||||
.toBe(0);
|
||||
});
|
||||
|
||||
it("rounds half-up to cents", () => {
|
||||
// 100.06 * 0.08 = 8.0048 -> 8.00; 100.13 * 0.08 = 8.0104 -> 8.01.
|
||||
expect(computeTax({ netPremium: 100.06, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8);
|
||||
expect(computeTax({ netPremium: 100.13, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8.01);
|
||||
});
|
||||
|
||||
describe("surchargeApplies", () => {
|
||||
it("is false for the two single-payment frequencies", () => {
|
||||
expect(surchargeApplies("ANNUAL")).toBe(false);
|
||||
expect(surchargeApplies("SINGLE")).toBe(false);
|
||||
});
|
||||
it("is true for every split frequency", () => {
|
||||
expect(surchargeApplies("SEMIANNUAL")).toBe(true);
|
||||
expect(surchargeApplies("QUARTERLY")).toBe(true);
|
||||
expect(surchargeApplies("MONTHLY")).toBe(true);
|
||||
});
|
||||
it("allows it when the frequency is unknown", () => {
|
||||
// Every migrated policy is null here — the original ETL dropped FORMA
|
||||
// PAGO — and those rows DO carry recargo figures in the legacy data.
|
||||
expect(surchargeApplies(null)).toBe(true);
|
||||
expect(surchargeApplies(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTaxRate", () => {
|
||||
it("prefers the rate the policy was issued at", () => {
|
||||
expect(resolveTaxRate(0.16, 0.08)).toBe(0.16);
|
||||
});
|
||||
it("falls back to the line of business", () => {
|
||||
expect(resolveTaxRate(null, 0.08)).toBe(0.08);
|
||||
});
|
||||
it("falls back to the default when nothing is configured", () => {
|
||||
expect(resolveTaxRate(null, null)).toBe(DEFAULT_TAX_RATE);
|
||||
expect(resolveTaxRate(undefined, "")).toBe(DEFAULT_TAX_RATE);
|
||||
});
|
||||
it("accepts a zero rate as a real choice, not as absent", () => {
|
||||
// An exempt line of business must read 0, not silently fall through to 8%.
|
||||
expect(resolveTaxRate(null, 0)).toBe(0);
|
||||
});
|
||||
it("accepts Prisma's decimal strings", () => {
|
||||
expect(resolveTaxRate(null, "0.0800")).toBe(0.08);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The premium arithmetic the Access capture form did in unbound calculated
|
||||
* controls, moved somewhere it can be tested.
|
||||
*
|
||||
* Two figures are derived, everything else is keyed by hand:
|
||||
*
|
||||
* base = netPremium + surcharge + policyFee
|
||||
* tax = round(base * rate)
|
||||
* total = base + tax
|
||||
*
|
||||
* The surcharge IS part of the taxable base. That is not an assumption — it is
|
||||
* the only reading that reconciles the books. Policy 7006785 (MULT, semestral,
|
||||
* two payments) prints IVA 52.03 and 47.84 against net premiums 610.86 / 589.71,
|
||||
* surcharges 8.55 / 8.26 and policy fees 31.00 / 0.00; excluding the surcharge
|
||||
* gives 51.35, which matches nothing on the page.
|
||||
*
|
||||
* The surcharge itself is NEVER derived. It is the carrier's financing charge
|
||||
* for paying in installments, quoted per policy, so staff key it in. It only
|
||||
* ever appears on a policy that is not paid annually or in a single exhibición
|
||||
* — `surchargeApplies` is what the UI uses to grey the field out.
|
||||
*/
|
||||
|
||||
/** Used when neither the policy nor its type carries a rate. Matches the
|
||||
* single row both legacy IMPUESTOS tables held (0.08 = 8%). */
|
||||
export const DEFAULT_TAX_RATE = 0.08;
|
||||
|
||||
export type PaymentFrequencyValue =
|
||||
| "ANNUAL"
|
||||
| "SEMIANNUAL"
|
||||
| "QUARTERLY"
|
||||
| "MONTHLY"
|
||||
| "SINGLE";
|
||||
|
||||
/** Paying in more than one exhibición is what earns a surcharge. A null
|
||||
* frequency (every migrated row — Access's FORMA PAGO was dropped by the
|
||||
* original ETL) is treated as "unknown, allow it" rather than "annual":
|
||||
* refusing to show a figure that is sitting in the legacy data would hide it. */
|
||||
export function surchargeApplies(
|
||||
frequency: PaymentFrequencyValue | null | undefined,
|
||||
): boolean {
|
||||
return frequency !== "ANNUAL" && frequency !== "SINGLE";
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
if (v === null || v === undefined || v === "") return 0;
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** Half-up to cents, the way the printed policy rounds. */
|
||||
export function round2(n: number): number {
|
||||
return Math.round((n + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
export interface PremiumParts {
|
||||
netPremium?: unknown;
|
||||
surcharge?: unknown;
|
||||
policyFee?: unknown;
|
||||
}
|
||||
|
||||
export function taxableBase(p: PremiumParts): number {
|
||||
return round2(num(p.netPremium) + num(p.surcharge) + num(p.policyFee));
|
||||
}
|
||||
|
||||
export function computeTax(p: PremiumParts, rate: number): number {
|
||||
return round2(taxableBase(p) * rate);
|
||||
}
|
||||
|
||||
export function computeTotal(p: PremiumParts, rate: number): number {
|
||||
return round2(taxableBase(p) + computeTax(p, rate));
|
||||
}
|
||||
|
||||
/** Rate ladder: the figure stored on the policy (so an old policy keeps the
|
||||
* rate it was issued at even after the catalog changes), else the rate on its
|
||||
* line of business, else the shipped default. */
|
||||
export function resolveTaxRate(
|
||||
policyRate: unknown,
|
||||
policyTypeRate: unknown,
|
||||
): number {
|
||||
for (const candidate of [policyRate, policyTypeRate]) {
|
||||
if (candidate === null || candidate === undefined || candidate === "") continue;
|
||||
const n = Number(candidate);
|
||||
if (Number.isFinite(n) && n >= 0) return n;
|
||||
}
|
||||
return DEFAULT_TAX_RATE;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
nameTokens,
|
||||
suggestCustomersByName,
|
||||
suggestionNote,
|
||||
type CustomerNameRow,
|
||||
} from "./name-matcher";
|
||||
|
||||
/**
|
||||
* Every row here is a real name out of the customer book (1536 rows, dev
|
||||
* mirror of production), chosen because it is one of the shapes that breaks
|
||||
* naive matching: surname-first ordering, a middle initial, a Spanish double
|
||||
* surname, a joint account, a missing comma, and the `(SIN NOMBRE)`
|
||||
* placeholder the migration left for customers whose DATGRAL row had no name.
|
||||
*/
|
||||
const BOOK: CustomerNameRow[] = [
|
||||
{ id: "c1", name: "WAGONER, PAMELA" },
|
||||
{ id: "c2", name: "MCWILLIAMS, BRIAN MICHAEL" },
|
||||
{ id: "c3", name: "MCWILLIAMS, BRIAN" },
|
||||
{ id: "c4", name: "WEAKLAND, RICHARD E." },
|
||||
{ id: "c5", name: "ESTRADA, JERRY & MARILYN" },
|
||||
{ id: "c6", name: "CABALLERO PRIETO, GUILLERMO" },
|
||||
{ id: "c7", name: "GREENE STEPHANIE" },
|
||||
{ id: "c8", name: "(SIN NOMBRE)" },
|
||||
{ id: "c9", name: "MUÑOZ, LUIS ALBERTO" },
|
||||
{ id: "c10", name: "SMITH, DANIEL" },
|
||||
{ id: "c11", name: "SMITH, JOHN" },
|
||||
];
|
||||
|
||||
describe("nameTokens", () => {
|
||||
it("makes the two orderings the same set", () => {
|
||||
expect(nameTokens("PAMELA WAGONER").sort()).toEqual(
|
||||
nameTokens("WAGONER, PAMELA").sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops initials, particles and corporate suffixes", () => {
|
||||
expect(nameTokens("WEAKLAND, RICHARD E.")).toEqual(["WEAKLAND", "RICHARD"]);
|
||||
expect(nameTokens("GARCIA DE LA TORRE, ANA")).toEqual(["GARCIA", "TORRE", "ANA"]);
|
||||
expect(nameTokens("CONSTRUCTORA BAJA S.A. DE C.V.")).toEqual([
|
||||
"CONSTRUCTORA",
|
||||
"BAJA",
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds accents so OCR's MUNOZ reaches the book's MUÑOZ", () => {
|
||||
expect(nameTokens("MUÑOZ")).toEqual(["MUNOZ"]);
|
||||
});
|
||||
|
||||
it("drops the phone number ANA prints against the insured name", () => {
|
||||
// Observed verbatim from the ANA automobile face.
|
||||
expect(nameTokens("MARIA GARCIA Ph.3102001538")).toEqual([
|
||||
"MARIA",
|
||||
"GARCIA",
|
||||
"PH",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestCustomersByName", () => {
|
||||
it("matches the reversed name exactly", () => {
|
||||
const [top] = suggestCustomersByName("PAMELA WAGONER", BOOK);
|
||||
expect(top).toMatchObject({ customerId: "c1", tier: "EXACT", score: 1 });
|
||||
});
|
||||
|
||||
it("treats a printed middle name the book lacks as a partial hit", () => {
|
||||
const hits = suggestCustomersByName("PAMELA DENISE WAGONER", BOOK);
|
||||
expect(hits[0]).toMatchObject({ customerId: "c1", tier: "PARTIAL" });
|
||||
expect(hits[0].score).toBeCloseTo(2 / 3);
|
||||
});
|
||||
|
||||
it("ranks the exact row above the row that merely contains it", () => {
|
||||
// Both MCWILLIAMS rows are reachable from this name; the one that holds
|
||||
// the middle name is the exact set and must come first.
|
||||
const hits = suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK);
|
||||
expect(hits.map((h) => h.customerId)).toEqual(["c2", "c3"]);
|
||||
expect(hits[0].tier).toBe("EXACT");
|
||||
expect(hits[1].tier).toBe("PARTIAL");
|
||||
});
|
||||
|
||||
it("reaches a joint account from the one spouse the carrier printed", () => {
|
||||
const hits = suggestCustomersByName("JERRY ESTRADA", BOOK);
|
||||
expect(hits[0]).toMatchObject({ customerId: "c5", tier: "PARTIAL" });
|
||||
});
|
||||
|
||||
it("will not reach a joint account on given names alone", () => {
|
||||
// No surname printed: `JERRY MARILYN` overlaps ESTRADA, JERRY & MARILYN
|
||||
// on two tokens, and matching on that would book a stranger's policy.
|
||||
expect(suggestCustomersByName("JERRY MARILYN", BOOK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches a Spanish double surname regardless of where the comma fell", () => {
|
||||
const [top] = suggestCustomersByName("GUILLERMO CABALLERO PRIETO", BOOK);
|
||||
expect(top).toMatchObject({ customerId: "c6", tier: "EXACT" });
|
||||
});
|
||||
|
||||
it("still matches a book row that has no comma", () => {
|
||||
const [top] = suggestCustomersByName("STEPHANIE GREENE", BOOK);
|
||||
expect(top).toMatchObject({ customerId: "c7", tier: "EXACT" });
|
||||
});
|
||||
|
||||
it("never suggests the (SIN NOMBRE) placeholder", () => {
|
||||
expect(suggestCustomersByName("SIN NOMBRE", BOOK)).toEqual([]);
|
||||
expect(suggestCustomersByName("NOMBRE DEL ASEGURADO", BOOK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing on a shared surname alone", () => {
|
||||
// 185 surnames are shared by 524 customers; one token is not evidence.
|
||||
expect(suggestCustomersByName("SMITH", BOOK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing for a different person with the same surname", () => {
|
||||
expect(suggestCustomersByName("ROBERT SMITH", BOOK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a page-sized blob", () => {
|
||||
// GMX's especificación has no field labels and the parser has handed its
|
||||
// whole first page over as the insured name.
|
||||
const blob =
|
||||
"ESPECIFICACION DE LA POLIZA DE SEGURO DE RESPONSABILIDAD CIVIL " +
|
||||
"EXPEDIDA A FAVOR DE PAMELA WAGONER CON VIGENCIA DEL 01 DE ENERO";
|
||||
expect(suggestCustomersByName(blob, BOOK)).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps the list", () => {
|
||||
expect(suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK, 1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles a null insured name", () => {
|
||||
expect(suggestCustomersByName(null, BOOK)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestionNote", () => {
|
||||
it("says nothing when there is nothing", () => {
|
||||
expect(suggestionNote([])).toBeNull();
|
||||
});
|
||||
|
||||
it("names a single exact hit", () => {
|
||||
expect(suggestionNote(suggestCustomersByName("PAMELA WAGONER", BOOK))).toBe(
|
||||
"posible cliente por nombre: WAGONER, PAMELA",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a tie rather than picking one", () => {
|
||||
// The book really does hold EMERY, LAURA twice and KIRCHHOFF, CINDY
|
||||
// three times.
|
||||
const dupes: CustomerNameRow[] = [
|
||||
{ id: "d1", name: "EMERY, LAURA" },
|
||||
{ id: "d2", name: "EMERY, LAURA" },
|
||||
];
|
||||
expect(suggestionNote(suggestCustomersByName("LAURA EMERY", dupes))).toBe(
|
||||
"2 clientes tienen ese mismo nombre; elija cuál",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists partial hits", () => {
|
||||
expect(suggestionNote(suggestCustomersByName("PAMELA DENISE WAGONER", BOOK))).toBe(
|
||||
"posibles clientes por nombre: WAGONER, PAMELA",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Suggests which existing customer a printed insured name belongs to.
|
||||
*
|
||||
* The office books customers surname-first ("WAGONER, PAMELA") and carriers
|
||||
* print them given-name-first ("PAMELA DENISE WAGONER"), so a string compare
|
||||
* never hits. Comparing *token sets* does, and it is order-insensitive by
|
||||
* construction — which is the whole trick.
|
||||
*
|
||||
* **These are suggestions, never matches.** Nothing here sets
|
||||
* `matchedCustomerId` or `confident`; the review screen offers the ranked
|
||||
* names and a human picks. That line is not caution, it is what the book
|
||||
* measures out to: of 1536 customers, 1487 have a distinct normalized token
|
||||
* set — but loosen the rule to surname + first given name only and 131 of
|
||||
* them (8.5%) collide, because the book holds `MCWILLIAMS, BRIAN MICHAEL`
|
||||
* *and* `MCWILLIAMS, BRIAN`, and `CUADROS, JORGE JR` alongside three
|
||||
* `CUADROS, JORGE H.`. 185 surnames are shared by 524 customers, so a
|
||||
* surname alone carries no information at all.
|
||||
*
|
||||
* The two tiers below are drawn at the two places that measurement puts a
|
||||
* cliff: full token-set equality, where cross-person collisions are
|
||||
* effectively zero, and strict containment, where they are common enough
|
||||
* that the result can only ever be a hint.
|
||||
*/
|
||||
|
||||
/** A customer row as the matcher needs it — id and the book's name. */
|
||||
export interface CustomerNameRow {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type NameMatchTier = "EXACT" | "PARTIAL";
|
||||
|
||||
export interface CustomerNameSuggestion {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
/**
|
||||
* `EXACT` — the two names carry the same tokens, in any order.
|
||||
* `PARTIAL` — one name's tokens are all present in the other's, plus the
|
||||
* surname. A printed middle name the book does not hold, or a joint
|
||||
* account where the carrier named one spouse, both land here.
|
||||
*/
|
||||
tier: NameMatchTier;
|
||||
/** Shared tokens over the longer name's token count, 0..1. */
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Words that carry no identity. Spanish particles and the ampersand joining
|
||||
* a couple are noise; the corporate suffixes are dropped so `S.A. DE C.V.`
|
||||
* does not make every company look alike.
|
||||
*/
|
||||
const NOISE = new Set([
|
||||
"DE", "DEL", "LA", "LAS", "LOS", "Y", "AND", "VDA",
|
||||
"JR", "SR", "II", "III", "IV",
|
||||
"SA", "CV", "SAPI", "SRL", "RL", "SC", "INC", "LLC", "LTD", "CORP", "CO",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Placeholder rows the migration left behind. Fourteen customers are named
|
||||
* literally `(SIN NOMBRE)`; without this they would be one 14-way tie on
|
||||
* every unreadable name.
|
||||
*/
|
||||
const PLACEHOLDER = new Set(["SIN NOMBRE", "NOMBRE SIN"]);
|
||||
|
||||
/**
|
||||
* A name blob longer than this is not a name. GMX's PVL especificación has
|
||||
* no field labels, and the parser has been seen handing its entire first
|
||||
* page over as `insuredName`; matching that against the book would find
|
||||
* a surname somewhere in the prose and suggest a stranger.
|
||||
*/
|
||||
const MAX_TOKENS = 8;
|
||||
const MAX_CHARS = 80;
|
||||
|
||||
/**
|
||||
* Splits a name into comparable tokens.
|
||||
*
|
||||
* Accents go first, and deliberately in both directions: the book holds
|
||||
* `MUÑOZ` where OCR routinely reads `MUNOZ`, and folding both to the same
|
||||
* ASCII makes that a hit rather than a miss.
|
||||
*
|
||||
* Tokens containing digits are dropped outright. ANA's automobile face
|
||||
* prints the phone number hard against the insured name — the parser has
|
||||
* emitted `MARIA GARCIA Ph.3102001538` — and the digits would otherwise
|
||||
* be an extra token forever blocking `EXACT`.
|
||||
*
|
||||
* Single letters are dropped as initials: the book is full of
|
||||
* `WEAKLAND, RICHARD E.`, and a carrier that prints the middle name in
|
||||
* full should still match the row that abbreviates it.
|
||||
*/
|
||||
export function nameTokens(raw: string): string[] {
|
||||
const cleaned = raw
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, " ")
|
||||
.trim();
|
||||
|
||||
const tokens = cleaned
|
||||
.split(" ")
|
||||
.filter((t) => t.length > 1 && !/\d/.test(t) && !NOISE.has(t));
|
||||
|
||||
return [...new Set(tokens)];
|
||||
}
|
||||
|
||||
/** The surname tokens — everything before the comma the book writes. */
|
||||
function surnameTokens(bookName: string): string[] {
|
||||
const comma = bookName.indexOf(",");
|
||||
// 54 of 1536 rows have no comma at all ("GREENE STEPHANIE",
|
||||
// "FAROOQ VAKIL"), and which half is the surname is unknowable. Requiring
|
||||
// a surname we cannot identify would silently exclude those rows, so they
|
||||
// fall back to requiring nothing beyond the containment rule.
|
||||
if (comma < 0) return [];
|
||||
return nameTokens(bookName.slice(0, comma));
|
||||
}
|
||||
|
||||
function isPlaceholder(tokens: string[]): boolean {
|
||||
return tokens.length === 0 || PLACEHOLDER.has([...tokens].sort().join(" "));
|
||||
}
|
||||
|
||||
function containsAll(haystack: Set<string>, needles: string[]): boolean {
|
||||
return needles.every((n) => haystack.has(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks the book against one printed name.
|
||||
*
|
||||
* Returns at most `limit` suggestions, `EXACT` before `PARTIAL` and higher
|
||||
* score first. An empty array means the printed name was unusable (too
|
||||
* long, too few real tokens) or nothing in the book came close — both of
|
||||
* which leave the review screen exactly as it is today.
|
||||
*/
|
||||
export function suggestCustomersByName(
|
||||
printedName: string | null | undefined,
|
||||
customers: CustomerNameRow[],
|
||||
limit = 3,
|
||||
): CustomerNameSuggestion[] {
|
||||
if (!printedName || printedName.length > MAX_CHARS) return [];
|
||||
|
||||
const printed = nameTokens(printedName);
|
||||
// One usable token is a surname or a given name on its own, and 34% of the
|
||||
// book shares a surname with someone. Nothing useful can come of it.
|
||||
if (printed.length < 2 || printed.length > MAX_TOKENS) return [];
|
||||
|
||||
const printedSet = new Set(printed);
|
||||
const out: CustomerNameSuggestion[] = [];
|
||||
|
||||
for (const c of customers) {
|
||||
const book = nameTokens(c.name);
|
||||
if (isPlaceholder(book) || book.length < 2) continue;
|
||||
|
||||
const bookSet = new Set(book);
|
||||
const overlap = printed.filter((t) => bookSet.has(t)).length;
|
||||
// Two shared tokens is the floor: one is a bare surname collision.
|
||||
if (overlap < 2) continue;
|
||||
|
||||
const bookInPrinted = containsAll(printedSet, book);
|
||||
const printedInBook = containsAll(bookSet, printed);
|
||||
if (!bookInPrinted && !printedInBook) continue;
|
||||
|
||||
// When the book's name is the shorter one, containment already proves
|
||||
// the surname was printed. When the printed name is shorter — the book
|
||||
// holds a middle name or a second spouse the carrier omitted — the
|
||||
// surname must be there explicitly, or `JERRY MARILYN` would match
|
||||
// `ESTRADA, JERRY & MARILYN` on given names alone.
|
||||
if (!bookInPrinted && !containsAll(printedSet, surnameTokens(c.name))) continue;
|
||||
|
||||
out.push({
|
||||
customerId: c.id,
|
||||
customerName: c.name,
|
||||
tier: bookInPrinted && printedInBook ? "EXACT" : "PARTIAL",
|
||||
score: overlap / Math.max(book.length, printed.length),
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (a.tier !== b.tier) return a.tier === "EXACT" ? -1 : 1;
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.customerName.localeCompare(b.customerName);
|
||||
});
|
||||
|
||||
return out.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Review-queue wording for what the suggestions amount to. */
|
||||
export function suggestionNote(suggestions: CustomerNameSuggestion[]): string | null {
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
const exact = suggestions.filter((s) => s.tier === "EXACT");
|
||||
// More than one exact hit is the duplicate-customer case the book really
|
||||
// has (`EMERY, LAURA` twice, `KIRCHHOFF, CINDY` three times). Saying so is
|
||||
// more useful than naming whichever one sorted first.
|
||||
if (exact.length > 1) {
|
||||
return `${exact.length} clientes tienen ese mismo nombre; elija cuál`;
|
||||
}
|
||||
if (exact.length === 1) {
|
||||
return `posible cliente por nombre: ${exact[0].customerName}`;
|
||||
}
|
||||
return `posibles clientes por nombre: ${suggestions.map((s) => s.customerName).join(", ")}`;
|
||||
}
|
||||
@@ -689,8 +689,23 @@ describe("parsePolicy / ANA automobile", () => {
|
||||
// reading would shift every value one column left.
|
||||
expect(p.netPremium).toBe(298.61);
|
||||
expect(p.policyFee).toBe(30);
|
||||
expect(p.tax).toBe(26.29);
|
||||
expect(p.total).toBe(354.9);
|
||||
expect(p.notes.join(" | ")).toMatch(/impuesto: 26\.29/);
|
||||
});
|
||||
|
||||
it("reads a TAX that reconciles against the rest of the row", () => {
|
||||
// 298.61 + 30.00 = 328.61, taxed at 8% -> 26.29, totalling 354.90. The
|
||||
// whole row agreeing is what proves the positional mapping landed on the
|
||||
// right cells rather than merely on six numbers.
|
||||
const base = p.netPremium! + p.policyFee!;
|
||||
expect(Math.round(base * 0.08 * 100) / 100).toBe(p.tax);
|
||||
expect(Math.round((base + p.tax!) * 100) / 100).toBe(p.total);
|
||||
});
|
||||
|
||||
it("does not fold LOCAL TAX into the IVA", () => {
|
||||
// It prints 0.00 here, so nothing to fold — but the guard is that a
|
||||
// non-zero one would surface as a note instead of inflating `tax`.
|
||||
expect(p.notes.join(" | ")).not.toMatch(/impuesto local/);
|
||||
});
|
||||
|
||||
it("reads the vehicle by token role, not by column", () => {
|
||||
|
||||
@@ -36,6 +36,17 @@ export interface ParsedPolicy {
|
||||
netPremium: number | null;
|
||||
policyFee: number | null;
|
||||
brokerFee: number | null;
|
||||
/**
|
||||
* IVA, off A.N.A.'s `TAX` cell. Null on GMX — its certificate carries no
|
||||
* premium at all, so there is no tax on it to read either.
|
||||
*
|
||||
* The adjacent `LOCAL TAX` cell is deliberately NOT folded in here. It is a
|
||||
* separate levy with no column of its own, and summing the two would report
|
||||
* an IVA figure that no longer divides back to a rate — the whole point of
|
||||
* storing it. It prints 0.00 on every policy seen so far and is surfaced as
|
||||
* a note when it is not.
|
||||
*/
|
||||
tax: number | null;
|
||||
total: number | null;
|
||||
/** "CONTADO" / "MENSUAL" / … — premium-payment cadence text. */
|
||||
premiumPayment: string | null;
|
||||
@@ -319,6 +330,7 @@ function emptyParsedPolicy(provider: string): ParsedPolicy {
|
||||
netPremium: null,
|
||||
policyFee: null,
|
||||
brokerFee: null,
|
||||
tax: null,
|
||||
total: null,
|
||||
premiumPayment: null,
|
||||
coverages: [],
|
||||
@@ -1178,6 +1190,7 @@ interface AnaHeader {
|
||||
coveragePeriodDays: number | null;
|
||||
netPremium: number | null;
|
||||
policyFee: number | null;
|
||||
tax: number | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
@@ -1253,8 +1266,13 @@ function parseAnaHeader(lines: string[], notes: string[]): AnaHeader {
|
||||
// ----- money row ---------------------------------------------------------
|
||||
const row = anaMoneyRow(lines);
|
||||
if (!row) notes.push("no se pudo leer el renglón de primas");
|
||||
if (row?.tax) notes.push(`impuesto: ${row.tax.toFixed(2)}`);
|
||||
if (row?.discount) notes.push(`descuento: ${row.discount.toFixed(2)}`);
|
||||
// LOCAL TAX has no destination column and prints 0.00 on every A.N.A. policy
|
||||
// seen so far. A non-zero one means the total will not reconcile against the
|
||||
// stored IVA, so say so rather than folding it in and hiding the difference.
|
||||
if (row?.localTax) {
|
||||
notes.push(`impuesto local ${row.localTax.toFixed(2)} no capturado`);
|
||||
}
|
||||
|
||||
return {
|
||||
policyNumber,
|
||||
@@ -1266,6 +1284,7 @@ function parseAnaHeader(lines: string[], notes: string[]): AnaHeader {
|
||||
coveragePeriodDays,
|
||||
netPremium: row?.netPremium ?? null,
|
||||
policyFee: row?.policyFee ?? null,
|
||||
tax: row?.tax ?? null,
|
||||
total: row?.total ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1455,6 +1474,7 @@ function parseAnaAutomobile(lines: string[]): ParsedPolicy {
|
||||
currency: anaCurrency(text, notes),
|
||||
netPremium: header.netPremium,
|
||||
policyFee: header.policyFee,
|
||||
tax: header.tax,
|
||||
total: header.total,
|
||||
premiumPayment: paymentDeadline,
|
||||
coverages,
|
||||
@@ -1854,6 +1874,7 @@ function parseAnaDriverPolicy(lines: string[]): ParsedPolicy {
|
||||
currency: anaCurrency(text, notes),
|
||||
netPremium: header.netPremium,
|
||||
policyFee: header.policyFee,
|
||||
tax: header.tax,
|
||||
total: header.total,
|
||||
coverages,
|
||||
coveragePeriodDays: header.coveragePeriodDays,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { PolicyMatcherService } from "./policy-matcher.service";
|
||||
import type { PrismaService } from "../prisma/prisma.service";
|
||||
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||
|
||||
function parsed(over: Partial<ParsedPolicy> = {}): ParsedPolicy {
|
||||
return {
|
||||
provider: "GMX",
|
||||
policyNumber: null,
|
||||
insuredName: null,
|
||||
notes: [],
|
||||
coverages: [],
|
||||
vehicles: [],
|
||||
drivers: [],
|
||||
...over,
|
||||
} as unknown as ParsedPolicy;
|
||||
}
|
||||
|
||||
function prismaStub(policies: unknown[], customers: { id: string; name: string }[]) {
|
||||
const findManyPolicy = jest.fn().mockResolvedValue(policies);
|
||||
const findManyCustomer = jest.fn().mockResolvedValue(customers);
|
||||
return {
|
||||
prisma: {
|
||||
policy: { findMany: findManyPolicy },
|
||||
customer: { findMany: findManyCustomer },
|
||||
} as unknown as PrismaService,
|
||||
findManyPolicy,
|
||||
findManyCustomer,
|
||||
};
|
||||
}
|
||||
|
||||
const BOOK = [
|
||||
{ id: "cust-1", name: "WAGONER, PAMELA" },
|
||||
{ id: "cust-2", name: "SMITH, JOHN" },
|
||||
];
|
||||
|
||||
describe("PolicyMatcherService name suggestions", () => {
|
||||
it("suggests a customer when the policy number is new", async () => {
|
||||
const { prisma } = prismaStub([], BOOK);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
const r = await svc.match(
|
||||
parsed({ policyNumber: "P-999", insuredName: "PAMELA DENISE WAGONER" } as never),
|
||||
);
|
||||
|
||||
expect(r.customerSuggestions).toEqual([
|
||||
expect.objectContaining({ customerId: "cust-1", tier: "PARTIAL" }),
|
||||
]);
|
||||
// The suggestion is surfaced, never applied.
|
||||
expect(r.customerId).toBeNull();
|
||||
expect(r.confident).toBe(false);
|
||||
expect(r.note).toContain("posibles clientes por nombre: WAGONER, PAMELA");
|
||||
});
|
||||
|
||||
it("suggests when the policy number could not be read at all", async () => {
|
||||
const { prisma } = prismaStub([], BOOK);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
const r = await svc.match(parsed({ insuredName: "PAMELA WAGONER" } as never));
|
||||
|
||||
expect(r.customerSuggestions[0]).toMatchObject({ customerId: "cust-1", tier: "EXACT" });
|
||||
expect(r.customerId).toBeNull();
|
||||
expect(r.note).toBe(
|
||||
"no se pudo leer el número de póliza; posible cliente por nombre: WAGONER, PAMELA",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch the book when the policy number hits", async () => {
|
||||
const { prisma, findManyCustomer } = prismaStub(
|
||||
[
|
||||
{
|
||||
id: "pol-1",
|
||||
policyNumber: "P-1",
|
||||
customerId: "cust-2",
|
||||
customer: { name: "SMITH, JOHN" },
|
||||
},
|
||||
],
|
||||
BOOK,
|
||||
);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
const r = await svc.match(
|
||||
parsed({ policyNumber: "P-1", insuredName: "PAMELA WAGONER" } as never),
|
||||
);
|
||||
|
||||
expect(r.confident).toBe(true);
|
||||
expect(r.customerId).toBe("cust-2");
|
||||
expect(r.customerSuggestions).toEqual([]);
|
||||
expect(findManyCustomer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the customer book once across a batch", async () => {
|
||||
const { prisma, findManyCustomer } = prismaStub([], BOOK);
|
||||
const svc = new PolicyMatcherService(prisma);
|
||||
|
||||
await svc.match(parsed({ policyNumber: "A", insuredName: "PAMELA WAGONER" } as never));
|
||||
await svc.match(parsed({ policyNumber: "B", insuredName: "JOHN SMITH" } as never));
|
||||
|
||||
expect(findManyCustomer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||
import {
|
||||
suggestCustomersByName,
|
||||
suggestionNote,
|
||||
type CustomerNameRow,
|
||||
type CustomerNameSuggestion,
|
||||
} from "./name-matcher";
|
||||
|
||||
export interface MatchResult {
|
||||
policyId: string | null;
|
||||
@@ -14,8 +20,24 @@ export interface MatchResult {
|
||||
* the policy number is shared across customers and a human must pick.
|
||||
*/
|
||||
candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[];
|
||||
/**
|
||||
* Customers whose name resembles the printed insured name. Populated only
|
||||
* when the policy number resolved to nothing, and never used to set
|
||||
* `customerId` or `confident` — see the class comment.
|
||||
*/
|
||||
customerSuggestions: CustomerNameSuggestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the customer book is reused across documents in a batch.
|
||||
*
|
||||
* A twenty-page batch would otherwise read all 1536 rows twenty times. The
|
||||
* only cost of the staleness is that a customer created in the last minute
|
||||
* is not suggested — the picker still finds them, so nothing is lost that a
|
||||
* reviewer cannot do in one click.
|
||||
*/
|
||||
const BOOK_TTL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Resolves a parsed policy page to an existing Policy (and its customer) the
|
||||
* office already holds.
|
||||
@@ -33,14 +55,29 @@ export interface MatchResult {
|
||||
* policy numbers across customers do occur (same group policy bound by two
|
||||
* related parties), and picking one arbitrarily would silently book the
|
||||
* wrong coverage.
|
||||
*
|
||||
* On that zero-hit path only, the printed name is used to *rank the picker*
|
||||
* — see `name-matcher.ts`. That is not a walk-back of the rule above: the
|
||||
* suggestion never reaches `customerId` or `confident`, a human still picks,
|
||||
* and the ranking exists because the office writes names surname-first
|
||||
* ("WAGONER, PAMELA") while carriers print them given-name-first ("PAMELA
|
||||
* DENISE WAGONER"), so the reviewer is retyping a name the machine could
|
||||
* have offered.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PolicyMatcherService {
|
||||
private book: { rows: CustomerNameRow[]; loadedAt: number } | null = null;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async match(parsed: ParsedPolicy): Promise<MatchResult> {
|
||||
if (!parsed.policyNumber) {
|
||||
return this.unmatched("no se pudo leer el número de póliza");
|
||||
// No number to search on, so the page goes to review with a picker —
|
||||
// the same place the name suggestions help.
|
||||
return this.unmatched(
|
||||
"no se pudo leer el número de póliza",
|
||||
await this.suggestByName(parsed.insuredName),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await this.prisma.policy.findMany({
|
||||
@@ -61,22 +98,33 @@ export class PolicyMatcherService {
|
||||
}));
|
||||
|
||||
if (rows.length === 0) {
|
||||
const suggestions = await this.suggestByName(parsed.insuredName);
|
||||
const hint = suggestionNote(suggestions);
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note: `no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
||||
note: [
|
||||
`no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
||||
hint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
confident: false,
|
||||
candidates: [],
|
||||
customerSuggestions: suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
if (rows.length > 1) {
|
||||
// The policy number did find rows; the reviewer picks among those, and
|
||||
// adding name guesses on top would only add noise.
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
|
||||
confident: false,
|
||||
candidates,
|
||||
customerSuggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,16 +134,47 @@ export class PolicyMatcherService {
|
||||
note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
|
||||
confident: true,
|
||||
candidates,
|
||||
customerSuggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
private unmatched(note: string): MatchResult {
|
||||
private async suggestByName(
|
||||
insuredName: string | null | undefined,
|
||||
): Promise<CustomerNameSuggestion[]> {
|
||||
if (!insuredName) return [];
|
||||
return suggestCustomersByName(insuredName, await this.customerBook());
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole customer book, held briefly. 1536 rows of `{id, name}` is a
|
||||
* few hundred kilobytes and the comparison is pure token-set work, so
|
||||
* scanning it beats any SQL approximation — and a `LIKE` search would in
|
||||
* any case have to guess which token is the surname, which is the one
|
||||
* thing the office's own data does not agree on.
|
||||
*/
|
||||
private async customerBook(): Promise<CustomerNameRow[]> {
|
||||
if (this.book && Date.now() - this.book.loadedAt < BOOK_TTL_MS) {
|
||||
return this.book.rows;
|
||||
}
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
this.book = { rows, loadedAt: Date.now() };
|
||||
return rows;
|
||||
}
|
||||
|
||||
private unmatched(
|
||||
note: string,
|
||||
customerSuggestions: CustomerNameSuggestion[] = [],
|
||||
): MatchResult {
|
||||
const hint = suggestionNote(customerSuggestions);
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note,
|
||||
note: [note, hint].filter(Boolean).join("; "),
|
||||
confident: false,
|
||||
candidates: [],
|
||||
customerSuggestions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export class ConfirmPolicyDocumentDto {
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsString() premiumPayment?: string;
|
||||
/** Printed term in days. Omitted leaves the parsed value (or the schema's
|
||||
@@ -79,6 +80,7 @@ export class ReviewPolicyDocumentDto {
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() tax?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsString() premiumPayment?: string;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
|
||||
|
||||
@@ -192,6 +192,8 @@ export class PolicyOcrService {
|
||||
parsed.policyFee != null ? new Prisma.Decimal(parsed.policyFee) : null,
|
||||
extractedBrokerFee:
|
||||
parsed.brokerFee != null ? new Prisma.Decimal(parsed.brokerFee) : null,
|
||||
extractedTax:
|
||||
parsed.tax != null ? new Prisma.Decimal(parsed.tax) : null,
|
||||
extractedTotal:
|
||||
parsed.total != null ? new Prisma.Decimal(parsed.total) : null,
|
||||
extractedCoveragesJson: parsed.coverages.length
|
||||
@@ -211,6 +213,9 @@ export class PolicyOcrService {
|
||||
matchCandidates: match.candidates.length
|
||||
? (match.candidates as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
customerSuggestions: match.customerSuggestions.length
|
||||
? (match.customerSuggestions as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
matchNote: notes.join("; "),
|
||||
},
|
||||
});
|
||||
@@ -362,6 +367,7 @@ export class PolicyOcrService {
|
||||
dto.policyFee != null ? new Prisma.Decimal(dto.policyFee) : undefined,
|
||||
extractedBrokerFee:
|
||||
dto.brokerFee != null ? new Prisma.Decimal(dto.brokerFee) : undefined,
|
||||
extractedTax: dto.tax != null ? new Prisma.Decimal(dto.tax) : undefined,
|
||||
extractedTotal:
|
||||
dto.total != null ? new Prisma.Decimal(dto.total) : undefined,
|
||||
extractedCoveragesJson: dto.coveragesJson
|
||||
@@ -796,6 +802,7 @@ function buildPolicyUpdateFromDoc(
|
||||
extractedNetPremium: Prisma.Decimal | null;
|
||||
extractedPolicyFee: Prisma.Decimal | null;
|
||||
extractedBrokerFee: Prisma.Decimal | null;
|
||||
extractedTax: Prisma.Decimal | null;
|
||||
extractedTotal: Prisma.Decimal | null;
|
||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
@@ -842,6 +849,10 @@ function buildPolicyUpdateFromDoc(
|
||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
|
||||
// `taxRate` is deliberately left alone. A.N.A. prints the IVA amount, not
|
||||
// the rate, and back-dividing it would mint a rate the document never
|
||||
// stated — the policy form resolves one from the line of business instead.
|
||||
tax: numOrUndef(item.tax, doc.extractedTax),
|
||||
total: numOrUndef(item.total, doc.extractedTotal),
|
||||
// coveragesJson / observations: freeform, keep the GMX data when present.
|
||||
coveragesJson:
|
||||
@@ -883,6 +894,7 @@ function buildPolicyCreateFromDoc(
|
||||
extractedNetPremium: Prisma.Decimal | null;
|
||||
extractedPolicyFee: Prisma.Decimal | null;
|
||||
extractedBrokerFee: Prisma.Decimal | null;
|
||||
extractedTax: Prisma.Decimal | null;
|
||||
extractedTotal: Prisma.Decimal | null;
|
||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
@@ -933,6 +945,10 @@ function buildPolicyCreateFromDoc(
|
||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
|
||||
// `taxRate` is deliberately left alone. A.N.A. prints the IVA amount, not
|
||||
// the rate, and back-dividing it would mint a rate the document never
|
||||
// stated — the policy form resolves one from the line of business instead.
|
||||
tax: numOrUndef(item.tax, doc.extractedTax),
|
||||
total: numOrUndef(item.total, doc.extractedTotal),
|
||||
coveragesJson:
|
||||
item.coveragesJson !== undefined
|
||||
|
||||
@@ -46,6 +46,20 @@ describe("renderRenewalEmail", () => {
|
||||
expect(result.html).toContain("Calle Uno 123");
|
||||
});
|
||||
|
||||
it("omits the premium when the sender did not ask for it", () => {
|
||||
// The unattended sweep quotes no amount: the premium can still be
|
||||
// re-rated at renewal, and a number a robot mailed out is one the office
|
||||
// has to walk back.
|
||||
const result = renderRenewalEmail(letter(), { includePremium: false });
|
||||
|
||||
expect(result.html).not.toContain("Prima");
|
||||
expect(result.html).not.toContain("1,392.00");
|
||||
// Everything else the customer needs is still there.
|
||||
expect(result.html).toContain("POL-123");
|
||||
expect(result.html).toContain("01/09/2026");
|
||||
expect(result.html).toContain("Ana Pérez");
|
||||
});
|
||||
|
||||
it("uses overdue wording for generation three", () => {
|
||||
const result = renderRenewalEmail(letter({ generation: 3 }));
|
||||
|
||||
|
||||
@@ -34,10 +34,23 @@ function row(label: string, value: string): string {
|
||||
return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`;
|
||||
}
|
||||
|
||||
export function renderRenewalEmail(letter: RenewalLetterRow): {
|
||||
/**
|
||||
* Render one renewal letter.
|
||||
*
|
||||
* `includePremium` decides whether the "Prima" row appears. The unattended
|
||||
* sweep sends without it — an amount quoted by a robot, on a premium that may
|
||||
* still be re-rated at renewal, is a number the office has to walk back — and
|
||||
* every staff-triggered send (the manual barrido and the per-row "Enviar
|
||||
* aviso") keeps it, because a person chose to quote it.
|
||||
*/
|
||||
export function renderRenewalEmail(
|
||||
letter: RenewalLetterRow,
|
||||
options: { includePremium?: boolean } = {},
|
||||
): {
|
||||
subject: string;
|
||||
html: string;
|
||||
} {
|
||||
const includePremium = options.includePremium !== false;
|
||||
const expired = letter.generation === 3;
|
||||
const subject = expired
|
||||
? `Póliza vencida: ${letter.policyNumber}`
|
||||
@@ -51,7 +64,7 @@ export function renderRenewalEmail(letter: RenewalLetterRow): {
|
||||
row("Tipo de póliza", letter.policyType),
|
||||
row("Aseguradora", letter.provider),
|
||||
row("Fecha de vencimiento", displayDate(letter.policyTo)),
|
||||
row("Prima", money(premium, letter.currency)),
|
||||
...(includePremium ? [row("Prima", money(premium, letter.currency))] : []),
|
||||
row("Cliente", letter.customerName),
|
||||
row("Correo", letter.customerEmail ?? "No disponible"),
|
||||
row("Teléfono", phone),
|
||||
|
||||
@@ -183,6 +183,50 @@ describe("renewal notices write the shared notification log", () => {
|
||||
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quotes the premium on a staff-triggered sweep but not the scheduled one", async () => {
|
||||
const manual = build({});
|
||||
await manual.service.sweep("user-1");
|
||||
expect(manual.record.mock.calls[0][0].bodySnapshot).toContain("Prima");
|
||||
|
||||
const automatic = build({});
|
||||
await automatic.service.scheduledSweep();
|
||||
const body = automatic.record.mock.calls[0][0].bodySnapshot;
|
||||
// The snapshot has to match the mail that actually went out, or the
|
||||
// office reads a letter the customer never received.
|
||||
expect(body).not.toContain("Prima");
|
||||
expect(body).toContain("700442181");
|
||||
});
|
||||
|
||||
it("scopes a sweep to one aseguradora without advancing the catch-up window", async () => {
|
||||
const { service, prisma, send } = build({});
|
||||
|
||||
const result = await service.sweep("user-1", { providerId: "gmx-id" });
|
||||
|
||||
expect(result.sent).toBe(1);
|
||||
expect(result.providerId).toBe("gmx-id");
|
||||
expect(prisma.policy.findMany.mock.calls[0][0].where).toMatchObject({
|
||||
insuranceProviderId: "gmx-id",
|
||||
});
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
// Only one carrier was mailed, so the days this run covered are still owed
|
||||
// to every other carrier: advancing `lastSuccessfulAt` would move them out
|
||||
// of tomorrow's window and they would never be sent.
|
||||
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
|
||||
expect(release.data.lastSuccessfulAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("advances the catch-up window on a clean unfiltered sweep", async () => {
|
||||
const { service, prisma } = build({});
|
||||
|
||||
await service.sweep("user-1");
|
||||
|
||||
expect(prisma.policy.findMany.mock.calls[0][0].where).not.toHaveProperty(
|
||||
"insuranceProviderId",
|
||||
);
|
||||
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
|
||||
expect(release.data.lastSuccessfulAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("does not fail a delivered notice when the log write throws", async () => {
|
||||
const { service, record } = build({});
|
||||
record.mockRejectedValue(new Error("log table gone"));
|
||||
|
||||
@@ -25,6 +25,13 @@ class RenewalFlagsDto {
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
class SweepRenewalsDto extends RenewalFlagsDto {
|
||||
/** Sweep one aseguradora only (GMX, ANA, …). Omitted = todas. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
class SendRenewalDto extends RenewalFlagsDto {
|
||||
@IsString()
|
||||
policyId!: string;
|
||||
@@ -43,17 +50,22 @@ export class RenewalsController {
|
||||
constructor(private readonly renewals: RenewalsService) {}
|
||||
|
||||
@Get("pending")
|
||||
pending(@Query("days") days?: string) {
|
||||
pending(
|
||||
@Query("days") days?: string,
|
||||
@Query("providerId") providerId?: string,
|
||||
) {
|
||||
return this.renewals.pending(
|
||||
Math.min(365, Math.max(1, Number(days) || 30)),
|
||||
providerId?.trim() || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("sweep")
|
||||
@RequireAbility("renewal:send")
|
||||
sweep(@Body() dto: RenewalFlagsDto, @Req() req: Request) {
|
||||
sweep(@Body() dto: SweepRenewalsDto, @Req() req: Request) {
|
||||
return this.renewals.sweep((req.user as { id: string }).id, {
|
||||
debug: dto?.debug,
|
||||
providerId: dto?.providerId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -84,10 +84,14 @@ export class RenewalsService implements OnModuleInit {
|
||||
|
||||
/** The unattended run always sends for real: `debug` is a per-click switch
|
||||
* in the UI, never persisted, so the schedule cannot inherit a forgotten
|
||||
* test toggle and silently stop mailing customers. */
|
||||
* test toggle and silently stop mailing customers.
|
||||
*
|
||||
* `automatic` is what drops the premium from the letter — see
|
||||
* `renderRenewalEmail`. It is set here and nowhere else, so every sweep a
|
||||
* person clicks still quotes the amount. */
|
||||
async scheduledSweep(): Promise<void> {
|
||||
try {
|
||||
await this.sweep();
|
||||
await this.sweep(undefined, { automatic: true });
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Falló el barrido de renovaciones: ${(error as Error).message}`,
|
||||
@@ -95,7 +99,10 @@ export class RenewalsService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
async pending(days = 30) {
|
||||
/** @param providerId Restrict to one aseguradora. The list has to agree
|
||||
* with what a sweep would send, or the carrier-scoped barrido shows rows it
|
||||
* will not mail. */
|
||||
async pending(days = 30, providerId?: string) {
|
||||
const today = dateInTimeZone(new Date());
|
||||
const state = await this.prisma.scheduledJobState.findUnique({
|
||||
where: { name: JOB_NAME },
|
||||
@@ -111,6 +118,7 @@ export class RenewalsService implements OnModuleInit {
|
||||
item,
|
||||
today,
|
||||
state?.lastSuccessfulAt ?? null,
|
||||
providerId,
|
||||
),
|
||||
})),
|
||||
);
|
||||
@@ -122,8 +130,20 @@ export class RenewalsService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
async sweep(userId?: string, flags: { debug?: boolean } = {}) {
|
||||
/**
|
||||
* @param flags.providerId Sweep only one aseguradora. GMX and ANA are worked
|
||||
* as separate batches by the office, so mixing them in one run is what this
|
||||
* exists to prevent.
|
||||
* @param flags.automatic Set only by the scheduler. Drops the premium from
|
||||
* the letter.
|
||||
*/
|
||||
async sweep(
|
||||
userId?: string,
|
||||
flags: { debug?: boolean; providerId?: string; automatic?: boolean } = {},
|
||||
) {
|
||||
const debug = !!flags.debug;
|
||||
const providerId = flags.providerId?.trim() || undefined;
|
||||
const includePremium = !flags.automatic;
|
||||
const now = new Date();
|
||||
const state = await this.acquireLock(now);
|
||||
|
||||
@@ -145,6 +165,7 @@ export class RenewalsService implements OnModuleInit {
|
||||
cadence,
|
||||
today,
|
||||
state.lastSuccessfulAt,
|
||||
providerId,
|
||||
);
|
||||
eligible += policies.length;
|
||||
|
||||
@@ -157,13 +178,17 @@ export class RenewalsService implements OnModuleInit {
|
||||
await this.recordLog(policy, cadence.generation, "", {
|
||||
status: "SKIPPED_NO_EMAIL",
|
||||
debug,
|
||||
includePremium,
|
||||
});
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deliver(policy, cadence.generation, to, userId, debug);
|
||||
await this.deliver(policy, cadence.generation, to, userId, {
|
||||
debug,
|
||||
includePremium,
|
||||
});
|
||||
sent++;
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
@@ -182,11 +207,18 @@ export class RenewalsService implements OnModuleInit {
|
||||
failed: failures.length,
|
||||
failures,
|
||||
debug,
|
||||
providerId: providerId ?? null,
|
||||
};
|
||||
// A debug run must not advance `lastSuccessfulAt`: it wrote no
|
||||
// RenewalNotice rows, so the days it "covered" are still owed, and
|
||||
// narrowing tomorrow's window back to a single day would drop them.
|
||||
await this.releaseLock(!debug && failures.length === 0 ? now : null);
|
||||
//
|
||||
// A carrier-scoped run must not advance it either, for the same reason
|
||||
// one step out: it looked at the whole window but only mailed one
|
||||
// aseguradora, so every other carrier's letters in those days would fall
|
||||
// outside tomorrow's window and never be sent at all.
|
||||
const complete = !debug && !providerId && failures.length === 0;
|
||||
await this.releaseLock(complete ? now : null);
|
||||
void this.audit.log(userId, "renewalNotice.sweep", result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -233,12 +265,14 @@ export class RenewalsService implements OnModuleInit {
|
||||
throw new BadRequestException("El cliente no tiene correo registrado.");
|
||||
}
|
||||
|
||||
// A person clicked this, so the premium stays in the letter — only the
|
||||
// scheduler's unattended run omits it.
|
||||
const { sentAt, providerMessageId, addressedTo } = await this.deliver(
|
||||
policy,
|
||||
generation,
|
||||
to,
|
||||
userId,
|
||||
debug,
|
||||
{ debug, includePremium: true },
|
||||
);
|
||||
return {
|
||||
policyId,
|
||||
@@ -270,10 +304,12 @@ export class RenewalsService implements OnModuleInit {
|
||||
generation: number,
|
||||
to: string,
|
||||
userId?: string,
|
||||
debug = false,
|
||||
options: { debug?: boolean; includePremium?: boolean } = {},
|
||||
) {
|
||||
const debug = !!options.debug;
|
||||
const includePremium = options.includePremium !== false;
|
||||
const letter = toRenewalLetterRow(policy, generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
const message = renderRenewalEmail(letter, { includePremium });
|
||||
const addressedTo = debug ? DEBUG_RECIPIENT : to;
|
||||
|
||||
let result: Awaited<ReturnType<MailService["send"]>>;
|
||||
@@ -291,6 +327,7 @@ export class RenewalsService implements OnModuleInit {
|
||||
status: "FAILED",
|
||||
error: detail,
|
||||
debug,
|
||||
includePremium,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -324,6 +361,7 @@ export class RenewalsService implements OnModuleInit {
|
||||
providerResponse: result.response || undefined,
|
||||
sendDate: sentAt,
|
||||
debug,
|
||||
includePremium,
|
||||
});
|
||||
void this.audit.log(userId, "renewalNotice.send", {
|
||||
policyId: policy.id,
|
||||
@@ -355,10 +393,15 @@ export class RenewalsService implements OnModuleInit {
|
||||
error?: string;
|
||||
sendDate?: Date;
|
||||
debug?: boolean;
|
||||
/** Must match what `deliver` rendered, or `bodySnapshot` shows the
|
||||
* office a letter the customer never received. */
|
||||
includePremium?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const letter = toRenewalLetterRow(policy, generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
const message = renderRenewalEmail(letter, {
|
||||
includePremium: outcome.includePremium,
|
||||
});
|
||||
try {
|
||||
await this.notificationLog.record({
|
||||
notificationType: "RENEWAL_NOTICE",
|
||||
@@ -391,11 +434,13 @@ export class RenewalsService implements OnModuleInit {
|
||||
cadence: (typeof RENEWAL_CADENCE)[number],
|
||||
today: Date,
|
||||
lastSuccessfulAt: Date | null,
|
||||
providerId?: string,
|
||||
) {
|
||||
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
|
||||
return this.prisma.policy.findMany({
|
||||
where: {
|
||||
archivedAt: null,
|
||||
...(providerId && { insuranceProviderId: providerId }),
|
||||
policyTo: { gte: window.from, lte: window.to },
|
||||
customer: {
|
||||
archivedAt: null,
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface CompanyInfo {
|
||||
addressLine2: string;
|
||||
/** "City, State, ZIP, Country" — single line. */
|
||||
cityState: string;
|
||||
/** US postal address, printed after the office address. Optional. */
|
||||
mailingAddress: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
/** Mexican tax ID ("RFC"). Optional. */
|
||||
@@ -83,19 +85,23 @@ export function getCompanyInfo(): CompanyInfo {
|
||||
}
|
||||
cached = {
|
||||
name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"),
|
||||
addressLine1: envOr(
|
||||
"COMPANY_ADDRESS_LINE1",
|
||||
"Av. Revolución 1234, Int. 5",
|
||||
),
|
||||
// The office's real locator, as published on jorgecuadros.com. Prod sets
|
||||
// no COMPANY_* variables, so these defaults ARE what every printed
|
||||
// statement shows — they were placeholders until 2026-10.
|
||||
addressLine1: envOr("COMPANY_ADDRESS_LINE1", "Quinta Plaza Ste 3-B"),
|
||||
addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""),
|
||||
cityState: envOr(
|
||||
"COMPANY_CITY_STATE",
|
||||
"Tijuana, Baja California 22000, México",
|
||||
"Rosarito, Baja California, México",
|
||||
),
|
||||
phone: envOr("COMPANY_PHONE", "(664) 000-0000"),
|
||||
email: envOr("COMPANY_EMAIL", "contacto@jorgecuadros.local"),
|
||||
mailingAddress: envOr(
|
||||
"COMPANY_MAILING_ADDRESS",
|
||||
"US: 416 W. San Ysidro Blvd L #3108, San Ysidro, CA 92173",
|
||||
),
|
||||
phone: envOr("COMPANY_PHONE", "011-52 (661) 612-1295"),
|
||||
email: envOr("COMPANY_EMAIL", "info@jorgecuadros.com"),
|
||||
taxId: envOr("COMPANY_TAX_ID", ""),
|
||||
website: envOr("COMPANY_WEBSITE", "jorgecuadros.local"),
|
||||
website: envOr("COMPANY_WEBSITE", "jorgecuadros.com"),
|
||||
logoPath,
|
||||
logo,
|
||||
};
|
||||
|
||||
@@ -199,6 +199,7 @@ export function renderPdf(
|
||||
company.addressLine1,
|
||||
company.addressLine2,
|
||||
[company.cityState].filter(Boolean).join(" · "),
|
||||
company.mailingAddress,
|
||||
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||
].filter(Boolean);
|
||||
@@ -363,6 +364,7 @@ export function renderPrintHtml(
|
||||
company.addressLine1,
|
||||
company.addressLine2,
|
||||
company.cityState,
|
||||
company.mailingAddress,
|
||||
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||
].filter(Boolean);
|
||||
|
||||
@@ -14,8 +14,20 @@
|
||||
* the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash).
|
||||
*/
|
||||
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { Prisma, type ServiceKind } from "@jorgecuadros/database";
|
||||
import {
|
||||
BALANCE_FLOOR_JOIN,
|
||||
BALANCE_FORWARD_TYPE,
|
||||
balanceScopeSql,
|
||||
NOT_SUPERSEDED,
|
||||
notCashJournal,
|
||||
periodSourceTable,
|
||||
} from "../billing/billing.service";
|
||||
import { UTILITIES_TABLE } from "../customers/numid.service";
|
||||
import type { PrismaService } from "../prisma/prisma.service";
|
||||
import { CONCEPT_BY_KIND } from "../statements/statements.service";
|
||||
import {
|
||||
type ColumnDef,
|
||||
intParam,
|
||||
NOT_VOIDED,
|
||||
parseDate,
|
||||
@@ -32,19 +44,84 @@ function nameOf(c: { name: string; nameMissing: boolean }): string {
|
||||
return c.nameMissing ? "(sin nombre)" : c.name;
|
||||
}
|
||||
|
||||
const isoDay = (d: Date | null | undefined) =>
|
||||
d ? d.toISOString().slice(0, 10) : "—";
|
||||
|
||||
/** Every report leads with the customer's shelf folder number (2026-10 meeting). */
|
||||
const FOLDER_COLUMN: ColumnDef = { key: "folder", label: "Carpeta", type: "text" };
|
||||
/** Policy reports add the insurance file number beside it. */
|
||||
const INSURANCE_NO_COLUMN: ColumnDef = {
|
||||
key: "insuranceNo",
|
||||
label: "No. seguros",
|
||||
type: "text",
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer numbers for a page of report rows. The office files, captures and
|
||||
* talks in folder numbers, not platform ids.
|
||||
*
|
||||
* The insurance file number is the legacy insurance/DATGRAL id, a separate id
|
||||
* space under the same table name as the utilities NUMid; 16 customers hold
|
||||
* more than one.
|
||||
*/
|
||||
async function legacyNumbers(prisma: PrismaService, customerIds: string[]) {
|
||||
const ids = [...new Set(customerIds)];
|
||||
const [customers, refs] = ids.length
|
||||
? await Promise.all([
|
||||
prisma.customer.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, folderNumber: true },
|
||||
}),
|
||||
prisma.customerLegacyRef.findMany({
|
||||
where: {
|
||||
customerId: { in: ids },
|
||||
sourceTable: UTILITIES_TABLE,
|
||||
sourceSystem: "insurance",
|
||||
},
|
||||
select: { customerId: true, legacyId: true },
|
||||
}),
|
||||
])
|
||||
: [[], []];
|
||||
const folders = new Map(customers.map((c) => [c.id, c.folderNumber]));
|
||||
const insurance = new Map<string, string[]>();
|
||||
for (const r of refs) {
|
||||
insurance.set(r.customerId, [...(insurance.get(r.customerId) ?? []), r.legacyId]);
|
||||
}
|
||||
return {
|
||||
folder: (id: string) => String(folders.get(id) ?? "—"),
|
||||
insurance: (id: string) =>
|
||||
insurance
|
||||
.get(id)
|
||||
?.sort((a, b) => Number(a) - Number(b))
|
||||
.join(", ") || "—",
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ reports */
|
||||
|
||||
/**
|
||||
* LISTADO EN ROJO — overdue customers worklist.
|
||||
* Same data as the receivables worklist with balance=owing, but presented
|
||||
* as a printable report rather than a paginated browser.
|
||||
*
|
||||
* Legacy printed it from `ALFABETICO EN ROJO`: `SUM(CHARGECREDIT) < 0` over
|
||||
* DATOS2, by name. That sum was only honest because DATOS2 held one year plus
|
||||
* its BALANCE FORWARD. The platform keeps every year, so the balance here is
|
||||
* the statement's own — the floor, the cash journal and the archive window via
|
||||
* the same fragments `BillingService.balances()` uses. Summed raw, as this
|
||||
* report used to, 195 customers read in the red against 22 real ones: every
|
||||
* closed account whose history the opening balance had already settled.
|
||||
*
|
||||
* Ordered by the most recent real movement (Jorge, 2026-10): the oldest
|
||||
* debtors are the accounts that closed, the ones worth chasing are recent.
|
||||
* Inactive customers (`status = 0` — the office's own and internal accounts,
|
||||
* e.g. TRASPASOS PAYPAL at -12M) stay out unless asked for.
|
||||
*/
|
||||
const listadoEnRojo: ReportDef = {
|
||||
slug: "listado-en-rojo",
|
||||
title: "Clientes en rojo",
|
||||
description:
|
||||
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada, " +
|
||||
"ordenados del más antiguo al más reciente.",
|
||||
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada — " +
|
||||
"el mismo saldo de su estado de cuenta — ordenados por el movimiento " +
|
||||
"más reciente. Los clientes inactivos quedan fuera salvo que se pidan.",
|
||||
domain: "estado-cuenta",
|
||||
legacyName: "LISTADO EN ROJO",
|
||||
format: "tabular",
|
||||
@@ -59,6 +136,45 @@ const listadoEnRojo: ReportDef = {
|
||||
],
|
||||
defaultValue: "MXN",
|
||||
},
|
||||
{
|
||||
key: "domain",
|
||||
label: "Ramo",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "ALL", label: "Todos" },
|
||||
{ value: "UTILITY", label: "Servicios" },
|
||||
{ value: "INSURANCE", label: "Seguros" },
|
||||
],
|
||||
defaultValue: "ALL",
|
||||
},
|
||||
{ key: "since", label: "Con movimiento desde", kind: "date" },
|
||||
{
|
||||
key: "minOwed",
|
||||
label: "Adeudo mínimo",
|
||||
kind: "number",
|
||||
placeholder: "0",
|
||||
},
|
||||
{
|
||||
key: "inactive",
|
||||
label: "Clientes inactivos",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "no", label: "Excluir" },
|
||||
{ value: "yes", label: "Incluir" },
|
||||
],
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
key: "sort",
|
||||
label: "Orden",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "recent", label: "Movimiento más reciente" },
|
||||
{ value: "debt", label: "Mayor adeudo" },
|
||||
{ value: "name", label: "Nombre" },
|
||||
],
|
||||
defaultValue: "recent",
|
||||
},
|
||||
{
|
||||
key: "query",
|
||||
label: "Buscar (nombre o ciudad)",
|
||||
@@ -67,24 +183,48 @@ const listadoEnRojo: ReportDef = {
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "id", label: "#", type: "text" },
|
||||
FOLDER_COLUMN,
|
||||
{ key: "name", label: "Cliente", type: "text" },
|
||||
{ key: "city", label: "Ciudad", type: "text" },
|
||||
{ key: "movements", label: "Movs.", type: "number", align: "right" },
|
||||
{ key: "balance", label: "Saldo", type: "money", align: "right" },
|
||||
{ key: "lastMovement", label: "Último movimiento", type: "date" },
|
||||
{ key: "lastPayment", label: "Último abono", type: "date" },
|
||||
{ key: "balance", label: "Saldo", type: "money", align: "right" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
||||
const q = p.query?.trim();
|
||||
const nameFilter = q
|
||||
? Prisma.sql`AND (c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`
|
||||
const since = parseDate(p.since);
|
||||
const minOwed = Math.max(Number(p.minOwed) || 0, 0.005);
|
||||
|
||||
const filters: Prisma.Sql[] = [];
|
||||
if (q) {
|
||||
filters.push(Prisma.sql`(c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`);
|
||||
}
|
||||
if (p.domain === "UTILITY" || p.domain === "INSURANCE") {
|
||||
filters.push(Prisma.sql`t.domain = ${p.domain}`);
|
||||
}
|
||||
if (p.inactive !== "yes") {
|
||||
filters.push(Prisma.sql`c.status = 1 AND c.archivedAt IS NULL`);
|
||||
}
|
||||
const where = filters.length
|
||||
? Prisma.sql`AND ${Prisma.join(filters, " AND ")}`
|
||||
: Prisma.empty;
|
||||
|
||||
const bal =
|
||||
currency === "USD"
|
||||
? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)`
|
||||
: Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`;
|
||||
// The Jan 1 BALANCE FORWARD is not a movement — every customer has one, so
|
||||
// counting it would date every closed account to this year.
|
||||
const real = Prisma.sql`(tt.nameEn IS NULL OR tt.nameEn <> ${BALANCE_FORWARD_TYPE})`;
|
||||
const lastMovement = Prisma.sql`MAX(CASE WHEN ${real} THEN t.transactionDate END)`;
|
||||
|
||||
const orderBy =
|
||||
p.sort === "debt"
|
||||
? Prisma.sql`ORDER BY balance ASC`
|
||||
: p.sort === "name"
|
||||
? Prisma.sql`ORDER BY c.nameMissing ASC, c.name ASC`
|
||||
: Prisma.sql`ORDER BY lastMovement DESC, balance ASC`;
|
||||
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
@@ -92,38 +232,39 @@ const listadoEnRojo: ReportDef = {
|
||||
name: string;
|
||||
nameMissing: boolean;
|
||||
city: string | null;
|
||||
movements: bigint | number | string;
|
||||
balance: Prisma.Decimal | null;
|
||||
lastMovement: Date | null;
|
||||
lastPayment: Date | null;
|
||||
}>
|
||||
>`
|
||||
SELECT c.id, c.name, c.nameMissing, c.city,
|
||||
COUNT(*) AS movements,
|
||||
${bal} AS balance,
|
||||
MAX(t.transactionDate) AS lastMovement
|
||||
${lastMovement} AS lastMovement,
|
||||
MAX(CASE WHEN ${real} AND t.amount > 0 THEN t.transactionDate END) AS lastPayment
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
WHERE t.voidedAt IS NULL ${nameFilter}
|
||||
LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED}
|
||||
${balanceScopeSql()} ${where}
|
||||
GROUP BY c.id, c.name, c.nameMissing, c.city
|
||||
HAVING ${bal} < -0.005
|
||||
ORDER BY MAX(t.transactionDate) ASC, c.nameMissing ASC, c.name ASC
|
||||
HAVING ${bal} < ${-minOwed}
|
||||
${since ? Prisma.sql`AND ${lastMovement} >= ${since}` : Prisma.empty}
|
||||
${orderBy}
|
||||
`;
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.id));
|
||||
let totalBalance = new Prisma.Decimal(0);
|
||||
let totalMovs = 0;
|
||||
const out = rows.map((r) => {
|
||||
const b = r.balance ?? new Prisma.Decimal(0);
|
||||
totalBalance = totalBalance.plus(b);
|
||||
totalMovs += Number(r.movements);
|
||||
return {
|
||||
id: r.id.slice(0, 8),
|
||||
folder: nums.folder(r.id),
|
||||
name: nameOf(r),
|
||||
city: r.city ?? "—",
|
||||
movements: Number(r.movements),
|
||||
lastMovement: isoDay(r.lastMovement),
|
||||
lastPayment: isoDay(r.lastPayment),
|
||||
balance: b.toFixed(2),
|
||||
lastMovement: r.lastMovement
|
||||
? r.lastMovement.toISOString().slice(0, 10)
|
||||
: "—",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -131,31 +272,58 @@ const listadoEnRojo: ReportDef = {
|
||||
rows: out,
|
||||
totals: {
|
||||
customers: out.length,
|
||||
movements: totalMovs,
|
||||
balance: totalBalance.toFixed(2),
|
||||
currency,
|
||||
},
|
||||
subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor`,
|
||||
subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor${
|
||||
p.inactive === "yes" ? " · incluye inactivos" : ""
|
||||
}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** The services whose bill the office pays and books as a ledger concept. */
|
||||
const SERVICE_OPTIONS: { value: ServiceKind; label: string }[] = [
|
||||
{ value: "WATER", label: "Agua" },
|
||||
{ value: "ELECTRIC", label: "Luz" },
|
||||
{ value: "TELEPHONE", label: "Teléfono" },
|
||||
{ value: "GAS", label: "Gas" },
|
||||
{ value: "PROPERTY_TAX", label: "Predial" },
|
||||
{ value: "FEDERAL_ZONE", label: "Zona federal" },
|
||||
{ value: "CABLE", label: "Cable" },
|
||||
];
|
||||
|
||||
const serviceLabel = (kind: string) =>
|
||||
SERVICE_OPTIONS.find((o) => o.value === kind)?.label ?? kind;
|
||||
|
||||
/**
|
||||
* PAGOS NO EFECTUADOS (AGUA / LUZ / TEL).
|
||||
* Customers enrolled in a service (by PropertyService.kind) with no
|
||||
* related ledger charge in the last N days. Heuristic: any non-voided
|
||||
* charge-type transaction in the window counts as "they paid". The
|
||||
* report groups by property so the same customer with two water meters
|
||||
* appears once per property.
|
||||
* PAGOS NO EFECTUADOS (AGUA / LUZ / TEL / …).
|
||||
*
|
||||
* Legacy `PAGO AGUA/LUZ/TEL NO`: every customer with the service, LEFT JOIN
|
||||
* `LOCALIZA PAGO *` — the rows of THAT service's concept dated in the period
|
||||
* the operator typed — where nothing was found. So: "whose water did we not
|
||||
* pay this month". The first port asked a different question ("no charge of
|
||||
* any kind in N days"), which is why customers who were up to date kept
|
||||
* showing: one IZZI or annual-fee row hid a missing water bill, and the
|
||||
* reverse. It also passed `ELECTRICITY`/`PHONE`, which are not ServiceKind
|
||||
* values, so Luz and Teléfono never returned a row at all.
|
||||
*
|
||||
* Matched per customer, as legacy did. A charge row carries no link to the
|
||||
* service it paid, and its reference only matches the stored account for
|
||||
* water and phone (813/822, 501/521) — CFE bills post under a reference that
|
||||
* is not the RPU (5 of 1109) — so matching on the reference would list every
|
||||
* electric customer as unpaid.
|
||||
*
|
||||
* A row captured "sin fondos" in the window is not a payment: the customer is
|
||||
* listed with that status, which is the follow-up the office actually owes.
|
||||
*/
|
||||
const pagosNoEfectuados: ReportDef = {
|
||||
slug: "pagos-no-efectuados",
|
||||
title: "Pagos no efectuados",
|
||||
description:
|
||||
"Clientes con un servicio contratado (agua, luz o teléfono) sin " +
|
||||
"movimientos de cargo en los últimos N días. Heurística basada en el " +
|
||||
"servicio registrado en la propiedad y la ausencia de cargos en el " +
|
||||
"periodo seleccionado.",
|
||||
"Clientes con el servicio contratado sin un pago de ese servicio " +
|
||||
"registrado en el periodo. Sin fechas, revisa un ciclo hacia atrás " +
|
||||
"desde hoy: 31 días, o 62 para luz (bimestral).",
|
||||
domain: "servicios",
|
||||
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
|
||||
format: "tabular",
|
||||
@@ -164,139 +332,143 @@ const pagosNoEfectuados: ReportDef = {
|
||||
key: "serviceKind",
|
||||
label: "Servicio",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "WATER", label: "Agua" },
|
||||
{ value: "ELECTRICITY", label: "Luz" },
|
||||
{ value: "PHONE", label: "Teléfono" },
|
||||
],
|
||||
options: SERVICE_OPTIONS,
|
||||
defaultValue: "WATER",
|
||||
},
|
||||
{ key: "from", label: "Desde (por defecto, un ciclo atrás)", kind: "date" },
|
||||
{ key: "to", label: "Hasta (por defecto, hoy)", kind: "date", endOfDay: true },
|
||||
{
|
||||
key: "days",
|
||||
label: "Días sin movimiento",
|
||||
kind: "number",
|
||||
defaultValue: "60",
|
||||
},
|
||||
{
|
||||
key: "currency",
|
||||
label: "Moneda",
|
||||
key: "inactive",
|
||||
label: "Clientes inactivos",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "MXN", label: "MXN" },
|
||||
{ value: "USD", label: "USD" },
|
||||
{ value: "no", label: "Excluir" },
|
||||
{ value: "yes", label: "Incluir" },
|
||||
],
|
||||
defaultValue: "MXN",
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
||||
FOLDER_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
||||
{ key: "accountNumber", label: "Cuenta / Medidor", type: "text" },
|
||||
{ key: "lastCharge", label: "Último cargo", type: "date" },
|
||||
{ key: "balance", label: "Saldo", type: "money", align: "right" },
|
||||
{ key: "accountNumber", label: "Cuenta", type: "text" },
|
||||
{ key: "status", label: "Estado", type: "text" },
|
||||
{ key: "lastPaid", label: "Último pago del servicio", type: "date" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const kind = (p.serviceKind ?? "WATER") as
|
||||
| "WATER"
|
||||
| "ELECTRICITY"
|
||||
| "PHONE";
|
||||
const days = intParam(p, "days", 60, 1, 365);
|
||||
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
||||
const cutoff = new Date(Date.now() - days * 86400000);
|
||||
const kind = (
|
||||
SERVICE_OPTIONS.some((o) => o.value === p.serviceKind)
|
||||
? p.serviceKind
|
||||
: "WATER"
|
||||
) as ServiceKind;
|
||||
const concept = CONCEPT_BY_KIND[kind]!;
|
||||
// No period given: one billing cycle back from the end date, so the
|
||||
// report means the same on the 2nd as on the 28th. "This month" listed
|
||||
// nearly everyone early in the month — the bills are captured ~20th.
|
||||
const to = parseDate(p.to, true) ?? new Date();
|
||||
const cycleDays = kind === "ELECTRIC" ? 62 : 31;
|
||||
const from =
|
||||
parseDate(p.from) ?? new Date(to.getTime() - cycleDays * 86400000);
|
||||
const inactive =
|
||||
p.inactive === "yes"
|
||||
? Prisma.empty
|
||||
: Prisma.sql`AND c.status = 1`;
|
||||
|
||||
// One concept row for the customer, not voided, in the window.
|
||||
const conceptRow = (outstanding: 0 | 1) => Prisma.sql`
|
||||
SELECT 1 FROM transactions t
|
||||
JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.outstanding = ${outstanding} AND tt.nameEn = ${concept}
|
||||
AND t.transactionDate >= ${from} AND t.transactionDate <= ${to}`;
|
||||
|
||||
// Customers enrolled in the service on a non-archived property, with no
|
||||
// charge-type transaction in the window. The subquery picks up
|
||||
// *anything* the customer paid (any domain, any type) — close enough
|
||||
// for the staff's "who's overdue" view; the precise per-service match
|
||||
// would need a per-service typeId taxonomy that doesn't exist in the
|
||||
// legacy data.
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
nameMissing: boolean;
|
||||
propertyId: string;
|
||||
propertyAddress: string | null;
|
||||
accountNumber: string | null;
|
||||
lastCharge: Date | null;
|
||||
balance: Prisma.Decimal | null;
|
||||
addresses: string | null;
|
||||
accounts: string | null;
|
||||
unfunded: bigint | number;
|
||||
lastPaid: Date | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
c.id AS customerId,
|
||||
c.name AS customerName,
|
||||
c.nameMissing AS nameMissing,
|
||||
pr.id AS propertyId,
|
||||
pr.addressLine1 AS propertyAddress,
|
||||
ps.accountNumber AS accountNumber,
|
||||
GROUP_CONCAT(DISTINCT pr.addressLine1 ORDER BY pr.addressLine1 SEPARATOR ' / ') AS addresses,
|
||||
GROUP_CONCAT(DISTINCT NULLIF(ps.accountNumber, '') SEPARATOR ', ') AS accounts,
|
||||
EXISTS(${conceptRow(1)}) AS unfunded,
|
||||
(SELECT MAX(t.transactionDate) FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.amount < 0
|
||||
AND t.transactionDate >= ${cutoff}) AS lastCharge,
|
||||
(SELECT SUM(t.amount) FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.currency = ${currency}) AS balance
|
||||
JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.outstanding = 0 AND tt.nameEn = ${concept}) AS lastPaid
|
||||
FROM property_services ps
|
||||
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
||||
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
||||
WHERE ps.kind = ${kind} AND ps.active = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.amount < 0 AND t.transactionDate >= ${cutoff}
|
||||
)
|
||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
||||
WHERE ps.kind = ${kind} AND ps.active = 1 ${inactive}
|
||||
AND NOT EXISTS (${conceptRow(0)})
|
||||
GROUP BY c.id, c.name, c.nameMissing
|
||||
ORDER BY c.nameMissing ASC, c.name ASC
|
||||
`;
|
||||
|
||||
let totalBalance = new Prisma.Decimal(0);
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
|
||||
let unfunded = 0;
|
||||
const out = rows.map((r) => {
|
||||
const b = r.balance ?? new Prisma.Decimal(0);
|
||||
totalBalance = totalBalance.plus(b);
|
||||
if (Number(r.unfunded)) unfunded++;
|
||||
return {
|
||||
customerId: r.customerId.slice(0, 8),
|
||||
customerName: nameOf({
|
||||
name: r.customerName,
|
||||
nameMissing: r.nameMissing,
|
||||
}),
|
||||
propertyAddress: r.propertyAddress ?? "—",
|
||||
accountNumber: r.accountNumber ?? "—",
|
||||
lastCharge: r.lastCharge
|
||||
? r.lastCharge.toISOString().slice(0, 10)
|
||||
: "—",
|
||||
balance: b.toFixed(2),
|
||||
folder: nums.folder(r.customerId),
|
||||
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
|
||||
propertyAddress: r.addresses ?? "—",
|
||||
accountNumber: r.accounts ?? "—",
|
||||
status: Number(r.unfunded) ? "Sin fondos" : "Sin pago",
|
||||
lastPaid: isoDay(r.lastPaid),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: {
|
||||
rows: out.length,
|
||||
balance: totalBalance.toFixed(2),
|
||||
currency,
|
||||
clientes: out.length,
|
||||
"sin pago": out.length - unfunded,
|
||||
"sin fondos": unfunded,
|
||||
},
|
||||
subtitle: `Servicio: ${
|
||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
||||
} · ${days} días · ${out.length} propiedades sin cargo reciente`,
|
||||
subtitle: `Servicio: ${serviceLabel(kind)} · ${isoDay(from)} a ${isoDay(to)} · ${out.length} clientes sin pago registrado`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* FALTANTES DE (AGUA / LUZ / TEL).
|
||||
* Data-quality report: properties enrolled in a service that are missing
|
||||
* the key identifier the legacy system required (account/meter/route).
|
||||
* Different `faltante` per service kind in the legacy because the
|
||||
* service's identifier fields differ; here we flag any of the three
|
||||
* common identifiers being blank.
|
||||
* What each service must have on file to be paid and matched — the columns
|
||||
* legacy's FALTANTES DE AGUA/LUZ/TEL read (DATMEX.AGUA; MEDIDOR + RPU;
|
||||
* TELEFONO), in the field `scopedRefField` stores each one in. The first port
|
||||
* demanded account AND meter AND route of every kind; water and phone have no
|
||||
* route, so every one of them was flagged.
|
||||
*/
|
||||
const REQUIRED_FIELDS: Partial<
|
||||
Record<ServiceKind, Array<["accountNumber" | "meterNumber", string]>>
|
||||
> = {
|
||||
WATER: [["accountNumber", "Sin número de cuenta"]],
|
||||
ELECTRIC: [
|
||||
["accountNumber", "Sin RPU"],
|
||||
["meterNumber", "Sin medidor"],
|
||||
],
|
||||
TELEPHONE: [["accountNumber", "Sin número de teléfono"]],
|
||||
};
|
||||
|
||||
/**
|
||||
* FALTANTES DE (AGUA / LUZ / TEL) — data quality: active services missing the
|
||||
* identifier the office needs to pay and match the bill.
|
||||
*/
|
||||
const faltantes: ReportDef = {
|
||||
slug: "faltantes",
|
||||
title: "Faltantes de datos por servicio",
|
||||
description:
|
||||
"Calidad de datos: propiedades con un servicio contratado que no " +
|
||||
"tienen número de cuenta, medidor o ruta registrado. El reporte que " +
|
||||
"en la legacy corría como FALTANTES DE AGUA / LUZ / TEL.",
|
||||
"Calidad de datos: servicios activos sin el dato que se necesita para " +
|
||||
"pagar el recibo — agua: número de cuenta; luz: RPU y medidor; " +
|
||||
"teléfono: número.",
|
||||
domain: "servicios",
|
||||
legacyName: "FALTANTES DE AGUA/LUZ/TEL",
|
||||
format: "tabular",
|
||||
@@ -305,82 +477,65 @@ const faltantes: ReportDef = {
|
||||
key: "serviceKind",
|
||||
label: "Servicio",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "WATER", label: "Agua" },
|
||||
{ value: "ELECTRICITY", label: "Luz" },
|
||||
{ value: "PHONE", label: "Teléfono" },
|
||||
],
|
||||
options: SERVICE_OPTIONS.filter((o) => REQUIRED_FIELDS[o.value]),
|
||||
defaultValue: "WATER",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
||||
FOLDER_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
||||
{ key: "missing", label: "Faltante", type: "text" },
|
||||
{ key: "dueDay", label: "Día de vencimiento", type: "text" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const kind = (p.serviceKind ?? "WATER") as
|
||||
| "WATER"
|
||||
| "ELECTRICITY"
|
||||
| "PHONE";
|
||||
const kind = (
|
||||
p.serviceKind && REQUIRED_FIELDS[p.serviceKind as ServiceKind]
|
||||
? p.serviceKind
|
||||
: "WATER"
|
||||
) as ServiceKind;
|
||||
const required = REQUIRED_FIELDS[kind]!;
|
||||
|
||||
// Column names come from the constant above, never from the request.
|
||||
const blank = (field: string) =>
|
||||
Prisma.sql`(ps.${Prisma.raw(field)} IS NULL OR TRIM(ps.${Prisma.raw(field)}) = '')`;
|
||||
|
||||
// A row per (property, missing field). The "missing" string describes
|
||||
// what's blank so the report is self-explanatory when printed.
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
nameMissing: boolean;
|
||||
propertyId: string;
|
||||
propertyAddress: string | null;
|
||||
dueDay: string | null;
|
||||
missing: string;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
c.id AS customerId,
|
||||
c.name AS customerName,
|
||||
c.nameMissing AS nameMissing,
|
||||
pr.id AS propertyId,
|
||||
c.id AS customerId,
|
||||
c.name AS customerName,
|
||||
c.nameMissing AS nameMissing,
|
||||
pr.addressLine1 AS propertyAddress,
|
||||
ps.dueDay AS dueDay,
|
||||
CASE
|
||||
WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta'
|
||||
WHEN ps.meterNumber IS NULL OR ps.meterNumber = '' THEN 'Sin número de medidor'
|
||||
WHEN ps.route IS NULL OR ps.route = '' THEN 'Sin ruta'
|
||||
ELSE ''
|
||||
END AS missing
|
||||
CONCAT_WS(', ', ${Prisma.join(
|
||||
required.map(([field, label]) => Prisma.sql`IF(${blank(field)}, ${label}, NULL)`),
|
||||
)}) AS missing
|
||||
FROM property_services ps
|
||||
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
||||
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
||||
WHERE ps.kind = ${kind} AND ps.active = 1
|
||||
AND (
|
||||
ps.accountNumber IS NULL OR ps.accountNumber = ''
|
||||
OR ps.meterNumber IS NULL OR ps.meterNumber = ''
|
||||
OR ps.route IS NULL OR ps.route = ''
|
||||
)
|
||||
WHERE ps.kind = ${kind} AND ps.active = 1 AND c.status = 1
|
||||
AND (${Prisma.join(required.map(([field]) => blank(field)), " OR ")})
|
||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
||||
`;
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
|
||||
const out = rows.map((r) => ({
|
||||
customerId: r.customerId.slice(0, 8),
|
||||
customerName: nameOf({
|
||||
name: r.customerName,
|
||||
nameMissing: r.nameMissing,
|
||||
}),
|
||||
folder: nums.folder(r.customerId),
|
||||
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
|
||||
propertyAddress: r.propertyAddress ?? "—",
|
||||
missing: r.missing,
|
||||
dueDay: r.dueDay ?? "—",
|
||||
}));
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: { rows: out.length },
|
||||
subtitle: `Servicio: ${
|
||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
||||
} · ${out.length} propiedades con datos faltantes`,
|
||||
subtitle: `Servicio: ${serviceLabel(kind)} · ${out.length} servicios con datos faltantes`,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -395,8 +550,8 @@ const reporteDeEfectivo: ReportDef = {
|
||||
slug: "reporte-de-efectivo",
|
||||
title: "Reporte de efectivo",
|
||||
description:
|
||||
"Recibos de efectivo en el periodo seleccionado. Cubre los abonos " +
|
||||
"provenientes de las tablas legacy EFECTIVO* y los créditos sin " +
|
||||
"Recibos de efectivo en el periodo seleccionado: los recibos EFE de la " +
|
||||
"plataforma, los de las tablas legacy EFECTIVO* y los créditos sin " +
|
||||
"número de cheque (efectivo real). El match del reporte original.",
|
||||
domain: "chequera",
|
||||
legacyName: "REPORTE DE EFECTIVO",
|
||||
@@ -417,6 +572,7 @@ const reporteDeEfectivo: ReportDef = {
|
||||
],
|
||||
columns: [
|
||||
{ key: "date", label: "Fecha", type: "date" },
|
||||
FOLDER_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "concept", label: "Concepto", type: "text" },
|
||||
{ key: "source", label: "Origen", type: "text" },
|
||||
@@ -431,8 +587,13 @@ const reporteDeEfectivo: ReportDef = {
|
||||
NOT_VOIDED,
|
||||
{ amount: { gt: 0 } },
|
||||
{ currency },
|
||||
// A credit opening balance has no check number but is not cash.
|
||||
{ OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] },
|
||||
{
|
||||
OR: [
|
||||
// A platform receipt posts one row (its C<folio> credit, under a
|
||||
// CASH… pseudo check), so it is matched by the receipt, not the check.
|
||||
{ cashReceipt: { isNot: null } },
|
||||
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
|
||||
{
|
||||
AND: [
|
||||
@@ -462,19 +623,22 @@ const reporteDeEfectivo: ReportDef = {
|
||||
checkNumber: true,
|
||||
message: true,
|
||||
legacySourceTable: true,
|
||||
cashReceipt: { select: { folio: true } },
|
||||
type: { select: { nameEs: true, nameEn: true } },
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||
let total = new Prisma.Decimal(0);
|
||||
const out = rows.map((r) => {
|
||||
total = total.plus(r.amount);
|
||||
return {
|
||||
date: r.transactionDate.toISOString().slice(0, 10),
|
||||
folder: nums.folder(r.customer.id),
|
||||
customerName: nameOf(r.customer),
|
||||
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
|
||||
source: r.legacySourceTable ?? "—",
|
||||
source: r.cashReceipt ? `EFE ${r.cashReceipt.folio}` : r.legacySourceTable ?? "—",
|
||||
amount: r.amount.toFixed(2),
|
||||
};
|
||||
});
|
||||
@@ -536,6 +700,8 @@ const vigente: ReportDef = {
|
||||
],
|
||||
columns: [
|
||||
{ key: "policyNumber", label: "Póliza", type: "text" },
|
||||
FOLDER_COLUMN,
|
||||
INSURANCE_NO_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "provider", label: "Aseguradora", type: "text" },
|
||||
{ key: "agent", label: "Agente", type: "text" },
|
||||
@@ -566,11 +732,12 @@ const vigente: ReportDef = {
|
||||
policyTo: true,
|
||||
netPremium: true,
|
||||
agentName: true,
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||
insuranceProvider: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||
let totalPremium = new Prisma.Decimal(0);
|
||||
const out = rows.map((r) => {
|
||||
const daysTo = r.policyTo
|
||||
@@ -581,6 +748,8 @@ const vigente: ReportDef = {
|
||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||
return {
|
||||
policyNumber: r.policyNumber,
|
||||
folder: nums.folder(r.customer.id),
|
||||
insuranceNo: nums.insurance(r.customer.id),
|
||||
customerName: nameOf(r.customer),
|
||||
provider: r.insuranceProvider?.name ?? "—",
|
||||
agent: r.agentName ?? "—",
|
||||
@@ -684,6 +853,8 @@ const avisoRenovacion: ReportDef = {
|
||||
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
|
||||
columns: [
|
||||
{ key: "policyNumber", label: "Póliza", type: "text" },
|
||||
FOLDER_COLUMN,
|
||||
INSURANCE_NO_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "provider", label: "Aseguradora", type: "text" },
|
||||
{ key: "policyTo", label: "Vence", type: "date" },
|
||||
@@ -715,11 +886,16 @@ const avisoRenovacion: ReportDef = {
|
||||
select: renewalLetterSelect(generation),
|
||||
});
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||
let totalPremium = new Prisma.Decimal(0);
|
||||
let sentCount = 0;
|
||||
const out = rows.map((r) => {
|
||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||
const letter = toRenewalLetterRow(r, generation);
|
||||
const letter = {
|
||||
...toRenewalLetterRow(r, generation),
|
||||
folder: nums.folder(r.customer.id),
|
||||
insuranceNo: nums.insurance(r.customer.id),
|
||||
};
|
||||
if (letter.sentAt) sentCount++;
|
||||
return letter;
|
||||
});
|
||||
@@ -751,13 +927,22 @@ const edoCuentaDatos: ReportDef = {
|
||||
title: "Estado de cuenta",
|
||||
description:
|
||||
"Estado de cuenta de un cliente: saldos por moneda, desglose por " +
|
||||
"ramo y concepto, y el historial completo de movimientos con saldo " +
|
||||
"corrido. El reporte del cliente final.",
|
||||
"ramo y concepto, y los movimientos del año en curso con saldo " +
|
||||
"corrido, abriendo con el saldo anterior. El reporte del cliente final.",
|
||||
domain: "estado-cuenta",
|
||||
legacyName: "EDO CUENTA DATOS",
|
||||
format: "statement",
|
||||
params: [
|
||||
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
|
||||
// Which period to print. Blank means the year in progress; an earlier year
|
||||
// prints from its imported archive, the same source the on-screen
|
||||
// statement reads.
|
||||
{
|
||||
key: "year",
|
||||
label: "Periodo (año)",
|
||||
kind: "number",
|
||||
placeholder: "año en curso",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
// Statement rows carry synthetic `__kind` discriminators instead of
|
||||
@@ -788,23 +973,65 @@ const edoCuentaDatos: ReportDef = {
|
||||
},
|
||||
});
|
||||
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
|
||||
const folder = (await legacyNumbers(prisma, [customer.id])).folder(customer.id);
|
||||
|
||||
// The source-table exclusion, the balance floor and the year scope below
|
||||
// are BillingService.statement's, because this report and
|
||||
// /estado-cuenta/[id] are the same statement — one printable, one on
|
||||
// screen — and a customer holding both must not read two balances.
|
||||
const floor = await prisma.transaction.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
voidedAt: null,
|
||||
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||
},
|
||||
orderBy: { transactionDate: "desc" },
|
||||
select: { transactionDate: true },
|
||||
});
|
||||
|
||||
// Which period to print. An earlier year comes from its imported archive,
|
||||
// tagged rather than dated, exactly as the on-screen statement reads it.
|
||||
const thisYear = new Date().getUTCFullYear();
|
||||
const askedYear = Number(p.year);
|
||||
const requestedYear =
|
||||
Number.isInteger(askedYear) && askedYear > 0 ? askedYear : thisYear;
|
||||
const isArchive = requestedYear !== thisYear;
|
||||
|
||||
// Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter
|
||||
// as BillingService.statement so the numbers match what the customer
|
||||
// already sees in /estado-cuenta/[id].
|
||||
const rows = await prisma.transaction.findMany({
|
||||
where: {
|
||||
customerId,
|
||||
voidedAt: null,
|
||||
legacySourceTable: {
|
||||
notIn: [
|
||||
"EFECTIVO",
|
||||
"EFECTIVO_BACKUP",
|
||||
"EFECTIVO FM3",
|
||||
"CHEQUE FM3",
|
||||
"IVA 2015",
|
||||
],
|
||||
},
|
||||
...(isArchive
|
||||
? // The archive is one period's ledger already, so the tag is the
|
||||
// whole filter and the balance floor must not apply — the floor
|
||||
// hides exactly the history this period is asking for.
|
||||
{ legacySourceTable: periodSourceTable(requestedYear) }
|
||||
: {
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// Archive rows count as history below the year start (that is
|
||||
// what `opening` is for, and for a customer floored by an archive
|
||||
// it is the only carry there is) and are dropped at or above it.
|
||||
// Same rule as the on-screen twin — see BillingService.statement.
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||
{
|
||||
transactionDate: {
|
||||
lt: new Date(Date.UTC(requestedYear, 0, 1)),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// The cash receipt book, which the ledger already carries as
|
||||
// its own `C<folio>` postings. Taken from the shared helper
|
||||
// rather than restated, so the printed statement and the screen
|
||||
// cannot drift apart — and so this keeps the database
|
||||
// qualifier that spares the insurance line's own EFECTIVO.
|
||||
notCashJournal(),
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
@@ -822,12 +1049,32 @@ const edoCuentaDatos: ReportDef = {
|
||||
},
|
||||
});
|
||||
|
||||
// Compute running balance per currency, then return newest-first.
|
||||
// Scoped to the calendar year and listed oldest-first, the way the legacy
|
||||
// EDO CUENTA sheet reads. Rows from earlier years still move the running
|
||||
// balance — they are folded into `opening` and printed as a single "saldo
|
||||
// anterior" line, which is what a BALANCE FORWARD row is.
|
||||
// An archive needs no fold: it *is* the period, and its own Jan-1 BALANCE
|
||||
// FORWARD row is the carry, printed like legacy printed it.
|
||||
const yearStart = isArchive
|
||||
? new Date(0)
|
||||
: new Date(Date.UTC(requestedYear, 0, 1));
|
||||
const year = requestedYear;
|
||||
|
||||
const running = new Map<string, Prisma.Decimal>();
|
||||
const movements = rows.map((r) => {
|
||||
const opening = new Map<string, Prisma.Decimal>();
|
||||
const visible: typeof rows = [];
|
||||
|
||||
const movements = rows.flatMap((r) => {
|
||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||
const next = prev.plus(r.amount);
|
||||
running.set(r.currency, next);
|
||||
|
||||
if (r.transactionDate < yearStart) {
|
||||
opening.set(r.currency, next);
|
||||
return [];
|
||||
}
|
||||
|
||||
visible.push(r);
|
||||
return {
|
||||
date: r.transactionDate.toISOString().slice(0, 10),
|
||||
domain: r.domain,
|
||||
@@ -840,14 +1087,38 @@ const edoCuentaDatos: ReportDef = {
|
||||
balanceAfter: next.toFixed(2),
|
||||
};
|
||||
});
|
||||
movements.reverse();
|
||||
|
||||
// Per-currency summary + per-domain breakdown.
|
||||
// The carried balance, printed as the statement's first line — same shape
|
||||
// as a movement row so it needs nothing special from the renderer.
|
||||
const carried = [...opening.entries()]
|
||||
.filter(([, amount]) => !amount.isZero())
|
||||
.map(([currency, amount]) => ({
|
||||
date: yearStart.toISOString().slice(0, 10),
|
||||
domain: "UTILITY",
|
||||
currency,
|
||||
reference: "",
|
||||
period: `Al cierre de ${year - 1}`,
|
||||
checkNumber: "",
|
||||
concept: "SALDO ANTERIOR",
|
||||
amount: amount.toFixed(2),
|
||||
balanceAfter: amount.toFixed(2),
|
||||
}));
|
||||
|
||||
// Per-currency summary, seeded with the carried balance so it reconciles
|
||||
// against the last running balance printed below.
|
||||
const perCurrency = new Map<
|
||||
string,
|
||||
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
for (const [currency, amount] of opening) {
|
||||
perCurrency.set(currency, {
|
||||
currency,
|
||||
charges: amount.lessThan(0) ? amount : new Prisma.Decimal(0),
|
||||
credits: amount.lessThan(0) ? new Prisma.Decimal(0) : amount,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
for (const r of visible) {
|
||||
const c =
|
||||
perCurrency.get(r.currency) ??
|
||||
{
|
||||
@@ -867,6 +1138,7 @@ const edoCuentaDatos: ReportDef = {
|
||||
{
|
||||
__kind: "header",
|
||||
name: nameOf(customer),
|
||||
folder,
|
||||
address: customer.addressLine1 ?? "",
|
||||
city: [customer.city, customer.state].filter(Boolean).join(", "),
|
||||
phone: customer.phone ?? "",
|
||||
@@ -881,9 +1153,10 @@ const edoCuentaDatos: ReportDef = {
|
||||
count: c.count,
|
||||
})),
|
||||
{ __kind: "movements-header" },
|
||||
...carried,
|
||||
...movements,
|
||||
],
|
||||
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
|
||||
subtitle: `Carpeta ${folder} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -921,6 +1194,7 @@ const chequeCount: ReportDef = {
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
FOLDER_COLUMN,
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "reference", label: "Referencia", type: "text" },
|
||||
{ key: "period", label: "Periodo", type: "text" },
|
||||
@@ -950,7 +1224,7 @@ const chequeCount: ReportDef = {
|
||||
period: true,
|
||||
outstanding: true,
|
||||
type: { select: { nameEn: true, nameEs: true } },
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -976,8 +1250,10 @@ const chequeCount: ReportDef = {
|
||||
}
|
||||
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
|
||||
|
||||
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||
return {
|
||||
rows: rows.map((r) => ({
|
||||
folder: nums.folder(r.customer.id),
|
||||
customerName: nameOf(r.customer),
|
||||
reference: r.reference ?? "—",
|
||||
period: r.period ?? "—",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { StatementsService, assertSplitsSum } from "./statements.service";
|
||||
|
||||
/**
|
||||
* Confirming a batch is where a receipt becomes ledger rows. What must hold:
|
||||
* a split receipt charges each part to its own customer and nothing to the
|
||||
* matched one, sin fondos on one receipt holds back only that receipt, and a
|
||||
* split that no longer adds up never posts.
|
||||
*/
|
||||
|
||||
const D = (v: string) => new Prisma.Decimal(v);
|
||||
|
||||
function build(docs: unknown[]) {
|
||||
const lines: { customerId: string; amount: number; outstanding: boolean }[] = [];
|
||||
let refs: (string | undefined)[] = [];
|
||||
const billing = {
|
||||
createBatch: jest.fn().mockImplementation((dto, opts) => {
|
||||
lines.push(...dto.lines);
|
||||
refs = opts.refs;
|
||||
return Promise.resolve({
|
||||
items: dto.lines.map((_: unknown, i: number) => ({ id: `tx-${i}` })),
|
||||
count: dto.lines.length,
|
||||
total: "0.00",
|
||||
outstandingCount: 0,
|
||||
});
|
||||
}),
|
||||
};
|
||||
const prisma = {
|
||||
statementBatch: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: "b1", serviceKind: "WATER" }),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
statementDocument: {
|
||||
findMany: jest.fn().mockResolvedValue(docs),
|
||||
update: jest.fn((a) => a),
|
||||
updateMany: jest.fn((a) => a),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
statementDocumentSplit: { update: jest.fn((a) => a) },
|
||||
typeTransaction: { findFirst: jest.fn().mockResolvedValue({ id: "type-water" }) },
|
||||
propertyService: { updateMany: jest.fn() },
|
||||
$transaction: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const service = new StatementsService(
|
||||
prisma as never,
|
||||
{} as never,
|
||||
billing as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, prisma, lines, refs: () => refs };
|
||||
}
|
||||
|
||||
const doc = (over: Record<string, unknown>) => ({
|
||||
id: "d1",
|
||||
pageNumber: 1,
|
||||
matchedCustomerId: "c-411",
|
||||
matchedPropertyServiceId: null,
|
||||
extractedAmount: D("300.00"),
|
||||
extractedAccountRef: "3014057",
|
||||
extractedPeriod: "ENE-FEB",
|
||||
outstanding: false,
|
||||
splits: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const confirm = { checkNumber: "8728", transactionDate: "2026-10-08" };
|
||||
|
||||
describe("confirming OCR receipts", () => {
|
||||
it("posts one charge per part of a split receipt", async () => {
|
||||
const { service, prisma, lines, refs } = build([
|
||||
doc({
|
||||
splits: [
|
||||
{ id: "s1", sequence: 1, customerId: "c-411", amount: D("150.00") },
|
||||
{ id: "s2", sequence: 2, customerId: "c-523", amount: D("150.00") },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
await service.confirmBatch("b1", confirm, "u1");
|
||||
|
||||
expect(lines.map((l) => [l.customerId, l.amount])).toEqual([
|
||||
["c-411", -150],
|
||||
["c-523", -150],
|
||||
]);
|
||||
expect(refs()).toEqual(["d1#1", "d1#2"]);
|
||||
// The rows hang off the parts, not the document.
|
||||
expect(prisma.statementDocumentSplit.update).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.statementDocument.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("holds back only the receipt marked sin fondos", async () => {
|
||||
const { service, lines } = build([
|
||||
doc({ id: "d1", outstanding: true }),
|
||||
doc({ id: "d2", matchedCustomerId: "c-208" }),
|
||||
]);
|
||||
|
||||
await service.confirmBatch("b1", confirm, "u1");
|
||||
|
||||
expect(lines.map((l) => l.outstanding)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("refuses a split that no longer adds up to the receipt", async () => {
|
||||
const { service, prisma } = build([
|
||||
doc({
|
||||
extractedAmount: D("320.00"), // edited after the split was saved
|
||||
splits: [
|
||||
{ id: "s1", sequence: 1, customerId: "c-411", amount: D("150.00") },
|
||||
{ id: "s2", sequence: 2, customerId: "c-523", amount: D("150.00") },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(service.confirmBatch("b1", confirm, "u1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("split sums", () => {
|
||||
it("compares in cents, so float noise is not a mismatch", () => {
|
||||
expect(() => assertSplitsSum([0.1, 0.2], 0.3)).not.toThrow();
|
||||
expect(() => assertSplitsSum([100, 33.33, 33.33], 166.67)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
@@ -6,7 +8,9 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
||||
|
||||
export class CreateStatementBatchDto {
|
||||
@@ -27,6 +31,22 @@ export class ReviewDocumentDto {
|
||||
@IsOptional()
|
||||
@IsEnum(StatementDocumentStatus)
|
||||
status?: Extract<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">;
|
||||
/** Sin fondos for this receipt alone. */
|
||||
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||
}
|
||||
|
||||
export class SplitPartDto {
|
||||
@IsString() @MinLength(1) customerId!: string;
|
||||
@IsNumber() amount!: number;
|
||||
}
|
||||
|
||||
/** A receipt's split; an empty list removes it. */
|
||||
export class SetSplitsDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SplitPartDto)
|
||||
parts!: SplitPartDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Put,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
@@ -22,7 +23,7 @@ import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import type { UploadedFileLike } from "../storage/upload-file";
|
||||
import { StatementsService } from "./statements.service";
|
||||
import { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
||||
import { ConfirmBatchDto, ReviewDocumentDto, SetSplitsDto } from "./statement.dto";
|
||||
|
||||
/**
|
||||
* Statement OCR intake (RECEIPT_CAPTURE_SPEC §2).
|
||||
@@ -117,6 +118,39 @@ export class StatementsController {
|
||||
return batch;
|
||||
}
|
||||
|
||||
/** More scans for a batch kept open for one check. */
|
||||
@Post("batches/:id/files")
|
||||
@RequireAbility("statement:ingest")
|
||||
@UseInterceptors(FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }))
|
||||
async addFiles(
|
||||
@Param("id") id: string,
|
||||
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const batch = await this.statements.addFiles(id, files ?? []);
|
||||
void this.audit.log(this.actingId(req), "statement.batch.add-files", {
|
||||
batchId: id,
|
||||
added: files?.length ?? 0,
|
||||
});
|
||||
return batch;
|
||||
}
|
||||
|
||||
/** Split one receipt across several customers; empty parts undo it. */
|
||||
@Put("documents/:id/splits")
|
||||
@RequireAbility("statement:review")
|
||||
async setSplits(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: SetSplitsDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const doc = await this.statements.setSplits(id, dto.parts, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "statement.document.split", {
|
||||
documentId: id,
|
||||
parts: dto.parts.length,
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Patch("documents/:id")
|
||||
@RequireAbility("statement:review")
|
||||
async review(
|
||||
|
||||
@@ -17,14 +17,14 @@ import type { UploadedFileLike } from "../storage/upload-file";
|
||||
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
||||
import { parseStatement } from "./parsers/statement-parser";
|
||||
import { StatementMatcherService, scopedRefField } from "./statement-matcher.service";
|
||||
import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
||||
import type { ConfirmBatchDto, ReviewDocumentDto, SplitPartDto } from "./statement.dto";
|
||||
|
||||
/**
|
||||
* Default ledger concept per service kind. The names are the legacy
|
||||
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
|
||||
* per confirm rather than hard-coded as ids, which differ per environment.
|
||||
*/
|
||||
const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
|
||||
export const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
|
||||
ELECTRIC: "ELECTRIC",
|
||||
WATER: "WATER",
|
||||
TELEPHONE: "TELEPHONE",
|
||||
@@ -74,6 +74,49 @@ export class StatementsService {
|
||||
uploadedById: string,
|
||||
label?: string,
|
||||
) {
|
||||
await this.assertCanIngest(files);
|
||||
|
||||
const batch = await this.prisma.statementBatch.create({
|
||||
data: { serviceKind, uploadedById, label, fileCount: files.length },
|
||||
});
|
||||
this.startProcessing(batch.id, files, serviceKind, 0);
|
||||
return batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add more scans to a batch already under review — the office keeps one
|
||||
* batch open for one check while the receipts trickle in. New pages are
|
||||
* numbered after the existing ones; a settled (COMPLETED) batch reopens.
|
||||
*/
|
||||
async addFiles(batchId: string, files: UploadedFileLike[]) {
|
||||
await this.assertCanIngest(files);
|
||||
const batch = await this.prisma.statementBatch.findUnique({ where: { id: batchId } });
|
||||
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||
if (batch.status === "DISCARDED") {
|
||||
throw new BadRequestException("Este lote fue descartado; crea uno nuevo.");
|
||||
}
|
||||
// Two passes numbering pages at once would collide on (batchId, pageNumber).
|
||||
if (batch.status === "PROCESSING" || batch.status === "UPLOADED") {
|
||||
throw new BadRequestException("El lote todavía se está procesando; espera a que termine.");
|
||||
}
|
||||
const last = await this.prisma.statementDocument.aggregate({
|
||||
where: { batchId },
|
||||
_max: { pageNumber: true },
|
||||
});
|
||||
const updated = await this.prisma.statementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status: "UPLOADED",
|
||||
fileCount: { increment: files.length },
|
||||
completedAt: null,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
this.startProcessing(batchId, files, batch.serviceKind, last._max.pageNumber ?? 0);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async assertCanIngest(files: UploadedFileLike[]) {
|
||||
if (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
|
||||
if (!(await this.ocr.available())) {
|
||||
throw new BadRequestException(
|
||||
@@ -88,23 +131,24 @@ export class StatementsService {
|
||||
"guardar los recibos escaneados.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const batch = await this.prisma.statementBatch.create({
|
||||
data: { serviceKind, uploadedById, label, fileCount: files.length },
|
||||
});
|
||||
|
||||
private startProcessing(
|
||||
batchId: string,
|
||||
files: UploadedFileLike[],
|
||||
serviceKind: ServiceKind,
|
||||
afterPage: number,
|
||||
) {
|
||||
// Buffers are held for the background pass; the request's own copies would
|
||||
// otherwise be garbage once the response is sent.
|
||||
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
||||
void this.process(batch.id, copies, serviceKind).catch(async (err) => {
|
||||
this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`);
|
||||
void this.process(batchId, copies, serviceKind, afterPage).catch(async (err) => {
|
||||
this.logger.error(`Batch ${batchId} failed: ${(err as Error).message}`);
|
||||
await this.prisma.statementBatch.update({
|
||||
where: { id: batch.id },
|
||||
where: { id: batchId },
|
||||
data: { status: "FAILED", error: (err as Error).message },
|
||||
});
|
||||
});
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
/** Render → OCR → parse → match, one document row per page. */
|
||||
@@ -112,13 +156,14 @@ export class StatementsService {
|
||||
batchId: string,
|
||||
files: { buffer: Buffer; name?: string }[],
|
||||
serviceKind: ServiceKind,
|
||||
afterPage = 0,
|
||||
) {
|
||||
await this.prisma.statementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: "PROCESSING" },
|
||||
});
|
||||
|
||||
let pageNumber = 0;
|
||||
let pageNumber = afterPage;
|
||||
for (const file of files) {
|
||||
// The source PDF is kept as well as the page images: it is the artifact
|
||||
// the office actually received, and the only way to re-run a corrected
|
||||
@@ -227,11 +272,24 @@ export class StatementsService {
|
||||
where: { batchId: id, status: { in: OPEN } },
|
||||
_sum: { extractedAmount: true },
|
||||
});
|
||||
// The check this batch was last posted against, so the next confirm of a
|
||||
// batch kept open for one check doesn't make staff retype it.
|
||||
const last = await this.prisma.transaction.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ statementDocument: { batchId: id } },
|
||||
{ statementSplit: { document: { batchId: id } } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { checkNumber: true },
|
||||
});
|
||||
|
||||
return {
|
||||
...batch,
|
||||
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
||||
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00",
|
||||
lastCheckNumber: last?.checkNumber ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,6 +308,10 @@ export class StatementsService {
|
||||
property: { select: { id: true, addressLine1: true } },
|
||||
},
|
||||
},
|
||||
splits: {
|
||||
orderBy: { sequence: "asc" },
|
||||
include: { customer: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -268,11 +330,17 @@ export class StatementsService {
|
||||
|
||||
/** Staff correction of an extracted field or of the match itself. */
|
||||
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) {
|
||||
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||
const doc = await this.prisma.statementDocument.findUnique({
|
||||
where: { id },
|
||||
include: { splits: { select: { amount: true } } },
|
||||
});
|
||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||
if (doc.status === "POSTED") {
|
||||
throw new BadRequestException("Este documento ya fue registrado.");
|
||||
}
|
||||
if (dto.amount != null && doc.splits.length) {
|
||||
assertSplitsSum(doc.splits.map((s) => s.amount), dto.amount);
|
||||
}
|
||||
|
||||
// Changing the service implies its owner; deriving the customer here rather
|
||||
// than trusting a client-supplied pair is what stops a page being posted to
|
||||
@@ -329,6 +397,7 @@ export class StatementsService {
|
||||
extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
||||
matchedPropertyServiceId,
|
||||
matchedCustomerId,
|
||||
outstanding: dto.outstanding ?? undefined,
|
||||
status: dto.status ?? "MATCHED",
|
||||
reviewedById,
|
||||
reviewedAt: new Date(),
|
||||
@@ -336,6 +405,62 @@ export class StatementsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a receipt's split. Empty clears it, and the receipt posts whole to
|
||||
* its matched customer again. Otherwise at least two parts, each positive,
|
||||
* summing to the receipt's amount to the cent — a split that doesn't add up
|
||||
* would charge the customers more or less than the bill.
|
||||
*/
|
||||
async setSplits(id: string, parts: SplitPartDto[], reviewedById: string) {
|
||||
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||
if (doc.status === "POSTED" || doc.status === "REJECTED") {
|
||||
throw new BadRequestException("Este documento ya no se puede dividir.");
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
throw new BadRequestException("Una división necesita al menos dos partes.");
|
||||
}
|
||||
if (parts.length) {
|
||||
if (doc.extractedAmount == null) {
|
||||
throw new BadRequestException("Captura el importe del recibo antes de dividirlo.");
|
||||
}
|
||||
if (parts.some((p) => !(p.amount > 0))) {
|
||||
throw new BadRequestException("Cada parte debe ser mayor a cero.");
|
||||
}
|
||||
assertSplitsSum(parts.map((p) => p.amount), Number(doc.extractedAmount));
|
||||
const ids = [...new Set(parts.map((p) => p.customerId))];
|
||||
const found = await this.prisma.customer.count({ where: { id: { in: ids } } });
|
||||
if (found !== ids.length) throw new BadRequestException("Cliente no encontrado.");
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.statementDocumentSplit.deleteMany({ where: { documentId: id } }),
|
||||
...parts.map((p, i) =>
|
||||
this.prisma.statementDocumentSplit.create({
|
||||
data: {
|
||||
documentId: id,
|
||||
sequence: i + 1,
|
||||
customerId: p.customerId,
|
||||
amount: new Prisma.Decimal(p.amount.toFixed(2)),
|
||||
},
|
||||
}),
|
||||
),
|
||||
// A split receipt has its customers; it is ready to post like any match.
|
||||
this.prisma.statementDocument.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parts.length ? { status: "MATCHED" } : {}),
|
||||
reviewedById,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return this.prisma.statementDocument.findUnique({
|
||||
where: { id },
|
||||
include: { splits: { orderBy: { sequence: "asc" } } },
|
||||
});
|
||||
}
|
||||
|
||||
async reject(id: string, reviewedById: string) {
|
||||
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||
@@ -414,9 +539,10 @@ export class StatementsService {
|
||||
where: {
|
||||
batchId,
|
||||
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
|
||||
matchedCustomerId: { not: null },
|
||||
OR: [{ matchedCustomerId: { not: null } }, { splits: { some: {} } }],
|
||||
},
|
||||
orderBy: { pageNumber: "asc" },
|
||||
include: { splits: { orderBy: { sequence: "asc" } } },
|
||||
});
|
||||
if (!docs.length) {
|
||||
throw new BadRequestException("No hay documentos listos para registrar.");
|
||||
@@ -430,8 +556,39 @@ export class StatementsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Re-checked here as well as when saved: the amount may have been edited
|
||||
// after the split was.
|
||||
for (const d of docs) {
|
||||
if (d.splits.length) {
|
||||
assertSplitsSum(d.splits.map((s) => s.amount), Number(d.extractedAmount));
|
||||
}
|
||||
}
|
||||
|
||||
const typeId = dto.typeId ?? (await this.conceptFor(batch.serviceKind));
|
||||
|
||||
// One ledger line per receipt, or per part of a split receipt. `posts`
|
||||
// remembers which record each line came from, so the created rows can be
|
||||
// written back below.
|
||||
const posts = docs.flatMap((d) =>
|
||||
d.splits.length
|
||||
? d.splits.map((s) => ({
|
||||
doc: d,
|
||||
splitId: s.id as string | null,
|
||||
customerId: s.customerId,
|
||||
amount: Number(s.amount),
|
||||
ref: `${d.id}#${s.sequence}`,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
doc: d,
|
||||
splitId: null,
|
||||
customerId: d.matchedCustomerId!,
|
||||
amount: Number(d.extractedAmount),
|
||||
ref: d.id,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = await this.billing.createBatch(
|
||||
{
|
||||
domain: "UTILITY",
|
||||
@@ -439,36 +596,44 @@ export class StatementsService {
|
||||
checkNumber: dto.checkNumber,
|
||||
currency: dto.currency ?? "MXN",
|
||||
typeId,
|
||||
lines: docs.map((d) => ({
|
||||
customerId: d.matchedCustomerId!,
|
||||
lines: posts.map((p) => ({
|
||||
customerId: p.customerId,
|
||||
// Charges are negative in this ledger: a negative amount is what the
|
||||
// customer owes. The parser reads the printed (positive) figure, so
|
||||
// the sign is applied here, at the single point where a statement
|
||||
// becomes a ledger row.
|
||||
amount: -Math.abs(Number(d.extractedAmount)),
|
||||
reference: d.extractedAccountRef ?? undefined,
|
||||
period: d.extractedPeriod ?? undefined,
|
||||
outstanding: dto.outstanding ?? false,
|
||||
amount: -Math.abs(p.amount),
|
||||
reference: p.doc.extractedAccountRef ?? undefined,
|
||||
period: p.doc.extractedPeriod ?? undefined,
|
||||
// Sin fondos is per check or per receipt: either one holds it back.
|
||||
outstanding: (dto.outstanding ?? false) || p.doc.outstanding,
|
||||
})),
|
||||
},
|
||||
{ source: "OCR", refs: docs.map((d) => d.id) },
|
||||
{ source: "OCR", refs: posts.map((p) => p.ref) },
|
||||
);
|
||||
|
||||
// `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
|
||||
// the created rows zip straight back onto the documents that produced them.
|
||||
await this.prisma.$transaction(
|
||||
docs.map((d, i) =>
|
||||
this.prisma.statementDocument.update({
|
||||
where: { id: d.id },
|
||||
data: {
|
||||
status: "POSTED",
|
||||
postedTransactionId: result.items[i].id,
|
||||
reviewedById,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
// the created rows zip straight back onto the records that produced them.
|
||||
// A split receipt's rows hang off its parts; the document itself keeps no
|
||||
// single posted row.
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
...posts.map((p, i) =>
|
||||
p.splitId
|
||||
? this.prisma.statementDocumentSplit.update({
|
||||
where: { id: p.splitId },
|
||||
data: { postedTransactionId: result.items[i].id },
|
||||
})
|
||||
: this.prisma.statementDocument.update({
|
||||
where: { id: p.doc.id },
|
||||
data: { postedTransactionId: result.items[i].id },
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.prisma.statementDocument.updateMany({
|
||||
where: { id: { in: docs.map((d) => d.id) } },
|
||||
data: { status: "POSTED", reviewedById, reviewedAt: now },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Teach the matcher. When a document was matched by clave catastral or by
|
||||
// hand because the scoped field was blank, writing the reference back means
|
||||
@@ -479,7 +644,12 @@ export class StatementsService {
|
||||
|
||||
await this.closeIfDone(batchId);
|
||||
|
||||
return { posted: result.count, total: result.total, checkNumber: dto.checkNumber };
|
||||
return {
|
||||
posted: result.count,
|
||||
total: result.total,
|
||||
outstanding: result.outstandingCount,
|
||||
checkNumber: dto.checkNumber,
|
||||
};
|
||||
}
|
||||
|
||||
/** Write a confirmed reference onto a service that had none. */
|
||||
@@ -524,3 +694,14 @@ export class StatementsService {
|
||||
return row?.id;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parts of a split must add up to the receipt, compared in cents. */
|
||||
export function assertSplitsSum(parts: (number | Prisma.Decimal)[], total: number) {
|
||||
const cents = (v: number | Prisma.Decimal) => Math.round(Number(v) * 100);
|
||||
const sum = parts.reduce<number>((s, p) => s + cents(p), 0);
|
||||
if (sum !== cents(total)) {
|
||||
throw new BadRequestException(
|
||||
`La división suma ${(sum / 100).toFixed(2)} y el recibo es de ${total.toFixed(2)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "1.0.20",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
@@ -18,6 +18,10 @@ const TYPE: ChildConfig = {
|
||||
fields: [
|
||||
{ key: "name", label: "Nombre" },
|
||||
{ key: "shortDescription", label: "Descripción" },
|
||||
// The rate is stored as a fraction, not a percentage, and the label has to
|
||||
// say so: 8 typed here would tax a $600 premium $4,800. The API rejects
|
||||
// anything above 1 rather than trusting the label alone.
|
||||
{ key: "taxRate", label: "IVA (0.08 = 8%)", type: "number", step: "0.0001" },
|
||||
],
|
||||
};
|
||||
const ADJUSTER: ChildConfig = {
|
||||
|
||||
@@ -128,6 +128,7 @@ function Detail({ id }: { id: string }) {
|
||||
customerId={data.id}
|
||||
summary={data.transactionSummary}
|
||||
transactions={data.transactions}
|
||||
year={data.transactionYear}
|
||||
/>
|
||||
<DocumentosSection data={data} />
|
||||
</div>
|
||||
@@ -255,6 +256,7 @@ function Hero({
|
||||
.join(" · ");
|
||||
|
||||
const facts: { label: string; value: string }[] = [
|
||||
{ label: "Carpeta", value: data.folderNumber != null ? String(data.folderNumber) : "—" },
|
||||
{ label: "Cliente desde", value: formatDate(data.customerSince) },
|
||||
{
|
||||
label: "Cuota",
|
||||
@@ -746,16 +748,18 @@ function EstadoCuentaSection({
|
||||
customerId,
|
||||
summary,
|
||||
transactions,
|
||||
year,
|
||||
}: {
|
||||
customerId: string;
|
||||
summary: TransactionSummaryRow[];
|
||||
transactions: Transaction[];
|
||||
year: number;
|
||||
}) {
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead
|
||||
rule="cuenta"
|
||||
title="Estado de cuenta"
|
||||
title={`Estado de cuenta ${year}`}
|
||||
count={transactions.length}
|
||||
countSuffix="movimientos"
|
||||
/>
|
||||
@@ -782,7 +786,7 @@ function EstadoCuentaSection({
|
||||
|
||||
<div className="card">
|
||||
{transactions.length === 0 ? (
|
||||
<div className="empty-inline">Sin movimientos registrados.</div>
|
||||
<div className="empty-inline">Sin movimientos en {year}.</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
@@ -804,11 +808,11 @@ function EstadoCuentaSection({
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{transactions.length >= 100 && (
|
||||
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||
Mostrando los 100 movimientos más recientes.
|
||||
</div>
|
||||
)}
|
||||
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||
Movimientos de {year}, del más antiguo al más reciente. Los saldos de
|
||||
arriba son el saldo actual por línea de negocio — los mismos del
|
||||
estado de cuenta, no la suma del año.
|
||||
</div>
|
||||
</div>
|
||||
{transactions.length > 0 && (
|
||||
<p className="section-note">
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { getNextFolderNumber, onboardCustomer } from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatMoney, SERVICE_KIND_LABELS } from "@/lib/labels";
|
||||
import type { Currency, OnboardInput, OnboardResult, ServiceKind } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Alta de cliente in one screen (2026-10-08 meeting): the customer, their
|
||||
* property, its services and the opening balance taken as the first payment,
|
||||
* saved together or not at all. Cash issues an EFE receipt with its folio.
|
||||
*/
|
||||
export default function AltaClientePage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Alta />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
type ServiceRow = { kind: ServiceKind; accountNumber: string; meterNumber: string };
|
||||
|
||||
// The office's calendar day, not UTC's — in the evening UTC is already tomorrow.
|
||||
const today = () => new Date().toLocaleDateString("en-CA");
|
||||
const t = (v: string) => v.trim() || undefined;
|
||||
|
||||
function Alta() {
|
||||
const allowed = useCan("customer:create");
|
||||
|
||||
const [folder, setFolder] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [preferredCurrency, setPreferredCurrency] = useState<Currency>("USD");
|
||||
|
||||
const [address1, setAddress1] = useState("");
|
||||
const [address2, setAddress2] = useState("");
|
||||
const [propertyPhone, setPropertyPhone] = useState("");
|
||||
const [services, setServices] = useState<ServiceRow[]>([]);
|
||||
|
||||
const [payAmount, setPayAmount] = useState("");
|
||||
const [payMethod, setPayMethod] = useState<"CASH" | "CHECK">("CASH");
|
||||
const [payCurrency, setPayCurrency] = useState<Currency>("MXN");
|
||||
const [payDate, setPayDate] = useState(today);
|
||||
const [checkNumber, setCheckNumber] = useState("");
|
||||
const [exchangeRate, setExchangeRate] = useState("");
|
||||
const [concept, setConcept] = useState("");
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<OnboardResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getNextFolderNumber()
|
||||
.then((r) => setFolder((prev) => prev || String(r.folderNumber)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const hasProperty = !!(address1.trim() || address2.trim() || propertyPhone.trim() || services.length);
|
||||
const paying = Number(payAmount) > 0;
|
||||
|
||||
function setService(i: number, patch: Partial<ServiceRow>) {
|
||||
setServices((prev) => prev.map((s, j) => (j === i ? { ...s, ...patch } : s)));
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const input: OnboardInput = {
|
||||
customer: {
|
||||
name: name.trim(),
|
||||
folderNumber: folder ? Number(folder) : undefined,
|
||||
email: t(email),
|
||||
phone: t(phone),
|
||||
mobile: t(mobile),
|
||||
preferredCurrency,
|
||||
},
|
||||
...(hasProperty && {
|
||||
property: { addressLine1: t(address1), addressLine2: t(address2), phone1: t(propertyPhone) },
|
||||
services: services.map((s) => ({
|
||||
kind: s.kind,
|
||||
accountNumber: t(s.accountNumber),
|
||||
meterNumber: t(s.meterNumber),
|
||||
})),
|
||||
}),
|
||||
...(paying && {
|
||||
openingPayment: {
|
||||
method: payMethod,
|
||||
amount: Number(payAmount),
|
||||
currency: payCurrency,
|
||||
receivedAt: payDate,
|
||||
checkNumber: payMethod === "CHECK" ? t(checkNumber) : undefined,
|
||||
exchangeRate:
|
||||
payMethod === "CASH" && payCurrency === "USD" && exchangeRate
|
||||
? Number(exchangeRate)
|
||||
: undefined,
|
||||
concept: t(concept),
|
||||
},
|
||||
}),
|
||||
};
|
||||
try {
|
||||
setDone(await onboardCustomer(input));
|
||||
} catch (e2) {
|
||||
setError((e2 as Error)?.message ?? "No se pudo dar de alta al cliente.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return <div className="state-box state-error">No tiene permisos para crear clientes.</div>;
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<h1 className="page-title">Cliente dado de alta</h1>
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<p>
|
||||
<strong>{done.customer.name}</strong>
|
||||
{done.customer.folderNumber != null && ` · Carpeta ${done.customer.folderNumber}`}
|
||||
</p>
|
||||
{done.receipt && (
|
||||
<p>
|
||||
Saldo inicial recibido en efectivo, folio {done.receipt.folio}.{" "}
|
||||
<Link href={`/efectivo/${done.receipt.folio}`} target="_blank">
|
||||
Imprimir recibo
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{done.payment && <p>Saldo inicial registrado con el cheque {checkNumber}.</p>}
|
||||
<div className="form-actions">
|
||||
<Link href={`/clientes/${done.customer.id}`} className="btn btn-primary">
|
||||
Ver cliente
|
||||
</Link>
|
||||
<button type="button" className="btn btn-outline" onClick={() => location.reload()}>
|
||||
Otra alta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<div className="page-head">
|
||||
<Link href="/clientes" className="back-link">← Clientes</Link>
|
||||
<h1 className="page-title">Alta de cliente</h1>
|
||||
</div>
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Cliente</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Carpeta">
|
||||
<input className="input" type="number" min={1} value={folder}
|
||||
onChange={(e) => setFolder(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Nombre *">
|
||||
<input className="input" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Correo">
|
||||
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Teléfono">
|
||||
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Celular">
|
||||
<input className="input" value={mobile} onChange={(e) => setMobile(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Moneda preferida">
|
||||
<select className="input select" value={preferredCurrency}
|
||||
onChange={(e) => setPreferredCurrency(e.target.value as Currency)}>
|
||||
<option value="USD">USD</option>
|
||||
<option value="MXN">MXN</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Propiedad y servicios</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Dirección 1">
|
||||
<input className="input" value={address1} onChange={(e) => setAddress1(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Dirección 2">
|
||||
<input className="input" value={address2} onChange={(e) => setAddress2(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Teléfono de la propiedad">
|
||||
<input className="input" value={propertyPhone} onChange={(e) => setPropertyPhone(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{services.map((s, i) => (
|
||||
<div key={i} className="form-grid" style={{ marginTop: 12, alignItems: "end" }}>
|
||||
<select className="input select" value={s.kind}
|
||||
onChange={(e) => setService(i, { kind: e.target.value as ServiceKind })}>
|
||||
{Object.entries(SERVICE_KIND_LABELS).map(([k, label]) => (
|
||||
<option key={k} value={k}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input className="input" placeholder="Cuenta / referencia" value={s.accountNumber}
|
||||
onChange={(e) => setService(i, { accountNumber: e.target.value })} />
|
||||
<input className="input" placeholder="Medidor" value={s.meterNumber}
|
||||
onChange={(e) => setService(i, { meterNumber: e.target.value })} />
|
||||
<button type="button" className="btn btn-ghost"
|
||||
onClick={() => setServices((prev) => prev.filter((_, j) => j !== i))}>
|
||||
Quitar
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-ghost" style={{ marginTop: 12 }}
|
||||
onClick={() => setServices((prev) => [...prev, { kind: "WATER", accountNumber: "", meterNumber: "" }])}>
|
||||
+ Agregar servicio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Saldo inicial (pago)</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Monto">
|
||||
<input className="input" type="number" step="0.01" min="0" value={payAmount}
|
||||
placeholder="Vacío = sin saldo inicial"
|
||||
onChange={(e) => setPayAmount(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Moneda">
|
||||
<select className="input select" value={payCurrency}
|
||||
onChange={(e) => setPayCurrency(e.target.value as Currency)}>
|
||||
<option value="MXN">MXN</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Forma de pago">
|
||||
<select className="input select" value={payMethod}
|
||||
onChange={(e) => setPayMethod(e.target.value as "CASH" | "CHECK")}>
|
||||
<option value="CASH">Efectivo (emite recibo EFE)</option>
|
||||
<option value="CHECK">Cheque</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Fecha">
|
||||
<input className="input" type="date" value={payDate} onChange={(e) => setPayDate(e.target.value)} />
|
||||
</Field>
|
||||
{payMethod === "CHECK" && (
|
||||
<Field label="Número de cheque *">
|
||||
<input className="input" required={paying} value={checkNumber}
|
||||
onChange={(e) => setCheckNumber(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{payMethod === "CASH" && payCurrency === "USD" && (
|
||||
<Field label="Tipo de cambio (abonar en pesos)">
|
||||
<input className="input" type="number" step="0.0001" min="0" value={exchangeRate}
|
||||
placeholder="Vacío = abonar en dólares"
|
||||
onChange={(e) => setExchangeRate(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Concepto">
|
||||
<input className="input" placeholder="SALDO INICIAL" value={concept}
|
||||
onChange={(e) => setConcept(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{paying && (
|
||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||
Se abonará {formatMoney(payAmount, payCurrency)} a la cuenta del cliente
|
||||
{payMethod === "CASH" ? " y se emitirá el recibo de efectivo con el siguiente folio." : "."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy || !name.trim()}>
|
||||
{busy ? "Guardando…" : "Dar de alta"}
|
||||
</button>
|
||||
<Link href="/clientes" className="btn btn-outline">Cancelar</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -107,9 +107,14 @@ function ClientesBrowser() {
|
||||
]}
|
||||
/>
|
||||
{canCreate && (
|
||||
<Link href="/clientes/nuevo" className="btn btn-primary">
|
||||
+ Nuevo cliente
|
||||
</Link>
|
||||
<>
|
||||
<Link href="/clientes/nuevo" className="btn btn-outline">
|
||||
+ Solo cliente
|
||||
</Link>
|
||||
<Link href="/clientes/alta" className="btn btn-primary">
|
||||
+ Alta de cliente
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<StatStrip stats={stats} />
|
||||
@@ -125,7 +130,7 @@ function ClientesBrowser() {
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar por nombre, ciudad, teléfono…"
|
||||
placeholder="Buscar por nombre, carpeta, ciudad, teléfono…"
|
||||
aria-label="Buscar clientes"
|
||||
/>
|
||||
</div>
|
||||
@@ -261,6 +266,8 @@ function CustomerRow({ c }: { c: CustomerListItem }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="cust-sub">
|
||||
{c.folderNumber != null && <span>Carpeta {c.folderNumber}</span>}
|
||||
{c.folderNumber != null && (location || contact) && <span className="sep">·</span>}
|
||||
{location && <span>{location}</span>}
|
||||
{location && contact && <span className="sep">·</span>}
|
||||
{contact && <span>{contact}</span>}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCashReceipt } from "@/lib/api";
|
||||
import { formatDate, formatMoney } from "@/lib/labels";
|
||||
import type { CashReceipt, CompanyHeader } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* One EFE receipt, laid out to print and hand to the customer. No app shell:
|
||||
* the page is the paper.
|
||||
*/
|
||||
export default function CashReceiptPrint({ params }: { params: { folio: string } }) {
|
||||
const [r, setR] = useState<(CashReceipt & { company: CompanyHeader }) | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getCashReceipt(Number(params.folio))
|
||||
.then(setR)
|
||||
.catch((e) => setError((e as Error)?.message ?? "No se encontró el recibo."));
|
||||
}, [params.folio]);
|
||||
|
||||
if (error) return <div className="state-box state-error">{error}</div>;
|
||||
if (!r) return <div className="state-box">Cargando…</div>;
|
||||
|
||||
const c = r.company;
|
||||
const voided = r.transaction.voidedAt != null;
|
||||
const converted = r.exchangeRate != null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "24px auto", padding: 24, fontSize: 14 }}>
|
||||
<style>{"@media print { .no-print { display: none } body { background: #fff } }"}</style>
|
||||
<div className="no-print" style={{ marginBottom: 16 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={() => window.print()}>
|
||||
Imprimir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ border: "1px solid #333", padding: 24 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 16 }}>
|
||||
<div>
|
||||
<strong style={{ fontSize: 18 }}>{c.name}</strong>
|
||||
<div>{c.addressLine1}</div>
|
||||
{c.addressLine2 && <div>{c.addressLine2}</div>}
|
||||
<div>{c.cityState}</div>
|
||||
{c.mailingAddress && <div>{c.mailingAddress}</div>}
|
||||
<div>
|
||||
{c.phone} · {c.email}
|
||||
</div>
|
||||
{c.taxId && <div>RFC {c.taxId}</div>}
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>RECIBO DE EFECTIVO</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>Folio {r.folio}</div>
|
||||
<div>{formatDate(r.receivedAt)}</div>
|
||||
{voided && <div style={{ color: "#b00", fontWeight: 700 }}>ANULADO</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table style={{ width: "100%", marginTop: 24, borderCollapse: "collapse" }}>
|
||||
<tbody>
|
||||
<Row label="Cliente">
|
||||
{r.customer.name}
|
||||
{r.customer.folderNumber != null && ` · Carpeta ${r.customer.folderNumber}`}
|
||||
</Row>
|
||||
<Row label="Monto">
|
||||
<strong>
|
||||
{formatMoney(r.amount, r.currency)} {r.currency}
|
||||
</strong>
|
||||
{converted &&
|
||||
` (abonado ${formatMoney(r.transaction.amount, r.transaction.currency)} a T.C. ${Number(r.exchangeRate).toFixed(4)})`}
|
||||
</Row>
|
||||
<Row label="Concepto">{r.concept}</Row>
|
||||
<Row label="Elaboró">{r.preparedBy.name}</Row>
|
||||
{r.depositedAt && <Row label="Fecha de depósito">{formatDate(r.depositedAt)}</Row>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ marginTop: 56, display: "flex", justifyContent: "space-between" }}>
|
||||
<div style={{ borderTop: "1px solid #333", width: 240, textAlign: "center", paddingTop: 4 }}>
|
||||
Recibí
|
||||
</div>
|
||||
<div style={{ borderTop: "1px solid #333", width: 240, textAlign: "center", paddingTop: 4 }}>
|
||||
Entregó
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ padding: "6px 12px 6px 0", width: 160, color: "#555", verticalAlign: "top" }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ padding: "6px 0" }}>{children}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import {
|
||||
createCashReceipt,
|
||||
getNextCashFolio,
|
||||
listCashReceipts,
|
||||
setCashReceiptDeposit,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney } from "@/lib/labels";
|
||||
import type { CashReceipt, Currency } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* The EFE cash receipt book — the legacy EFE form. Each receipt takes the next
|
||||
* folio, prints for the customer, and credits their account as C<folio> in the
|
||||
* same step. Elaboró is whoever is logged in.
|
||||
*/
|
||||
export default function EfectivoPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<CashBook />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
// The office's calendar day, not UTC's — in the evening UTC is already tomorrow.
|
||||
const today = () => new Date().toLocaleDateString("en-CA");
|
||||
|
||||
function CashBook() {
|
||||
const canCapture = useCan("ledger:create");
|
||||
const [query, setQuery] = useState("");
|
||||
const [items, setItems] = useState<CashReceipt[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await listCashReceipts({ query: query.trim() || undefined });
|
||||
setItems(r.items);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudieron cargar los recibos.");
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => void load(), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Recibos de efectivo</h1>
|
||||
<p className="page-sub">
|
||||
Cada recibo toma el siguiente folio y abona la cuenta del cliente como C‹folio›.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{canCapture && <ReceiptForm onIssued={load} />}
|
||||
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder="Buscar por folio, carpeta o cliente…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ marginBottom: 12, maxWidth: 360 }}
|
||||
/>
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Folio</th>
|
||||
<th>Fecha</th>
|
||||
<th>Carpeta</th>
|
||||
<th>Cliente</th>
|
||||
<th style={{ textAlign: "right" }}>Monto</th>
|
||||
<th>Concepto</th>
|
||||
<th>Elaboró</th>
|
||||
<th>Depósito</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<ReceiptRow key={r.id} r={r} canCapture={canCapture} onChange={load} />
|
||||
))}
|
||||
{!items.length && (
|
||||
<tr>
|
||||
<td colSpan={9} className="page-sub">Sin recibos.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiptForm({ onIssued }: { onIssued: () => void }) {
|
||||
const [customerId, setCustomerId] = useState("");
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [folio, setFolio] = useState("");
|
||||
const [receivedAt, setReceivedAt] = useState(today);
|
||||
const [amount, setAmount] = useState("");
|
||||
const [currency, setCurrency] = useState<Currency>("MXN");
|
||||
const [exchangeRate, setExchangeRate] = useState("");
|
||||
const [concept, setConcept] = useState("");
|
||||
const [depositedAt, setDepositedAt] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [issued, setIssued] = useState<CashReceipt | null>(null);
|
||||
|
||||
const refreshFolio = useCallback(() => {
|
||||
getNextCashFolio()
|
||||
.then((r) => setFolio(String(r.folio)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
useEffect(refreshFolio, [refreshFolio]);
|
||||
|
||||
// The wording the office already writes on every receipt.
|
||||
const suggested = amount
|
||||
? currency === "USD"
|
||||
? `RECEIVED $${amount} DLLS FOR ACCOUNT.`
|
||||
: `RECIBI $${amount} PESOS PARA CUENTA.`
|
||||
: "";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await createCashReceipt({
|
||||
customerId,
|
||||
folio: folio ? Number(folio) : undefined,
|
||||
receivedAt,
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
exchangeRate: currency === "USD" && exchangeRate ? Number(exchangeRate) : undefined,
|
||||
concept: concept.trim() || suggested,
|
||||
depositedAt: depositedAt || undefined,
|
||||
});
|
||||
setIssued(r);
|
||||
setCustomerId("");
|
||||
setCustomerName("");
|
||||
setAmount("");
|
||||
setExchangeRate("");
|
||||
setConcept("");
|
||||
setDepositedAt("");
|
||||
refreshFolio();
|
||||
onIssued();
|
||||
} catch (e2) {
|
||||
setError((e2 as Error)?.message ?? "No se pudo emitir el recibo.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card" style={{ padding: 16 }} onSubmit={submit}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>Nuevo recibo</h2>
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
{issued && (
|
||||
<div className="state-box" style={{ marginBottom: 12 }}>
|
||||
Folio {issued.folio} emitido a {issued.customer.name} por{" "}
|
||||
{formatMoney(issued.amount, issued.currency)}.{" "}
|
||||
<Link href={`/efectivo/${issued.folio}`} target="_blank">
|
||||
Imprimir
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Folio</span>
|
||||
<input className="input" type="number" min={1} value={folio}
|
||||
onChange={(e) => setFolio(e.target.value)} />
|
||||
</label>
|
||||
<div className="field" style={{ minWidth: 260 }}>
|
||||
<span className="field-label">Cliente *</span>
|
||||
<CustomerPicker
|
||||
value={customerId}
|
||||
valueName={customerName}
|
||||
onPick={(id, name) => {
|
||||
setCustomerId(id);
|
||||
setCustomerName(name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="field-label">Fecha *</span>
|
||||
<input className="input" type="date" required value={receivedAt}
|
||||
onChange={(e) => setReceivedAt(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Monto *</span>
|
||||
<input className="input" type="number" step="0.01" min="0.01" required value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Moneda</span>
|
||||
<select className="input select" value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
<option value="MXN">MXN</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</label>
|
||||
{currency === "USD" && (
|
||||
<label className="field">
|
||||
<span className="field-label">Tipo de cambio (abonar en pesos)</span>
|
||||
<input className="input" type="number" step="0.0001" min="0" value={exchangeRate}
|
||||
placeholder="Vacío = abonar en dólares"
|
||||
onChange={(e) => setExchangeRate(e.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<label className="field">
|
||||
<span className="field-label">Fecha de depósito</span>
|
||||
<input className="input" type="date" value={depositedAt}
|
||||
onChange={(e) => setDepositedAt(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field" style={{ marginTop: 12 }}>
|
||||
<span className="field-label">Concepto</span>
|
||||
<input className="input" value={concept} placeholder={suggested}
|
||||
onChange={(e) => setConcept(e.target.value)} />
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary"
|
||||
disabled={busy || !customerId || !(Number(amount) > 0)}>
|
||||
{busy ? "Emitiendo…" : "Emitir recibo"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiptRow({
|
||||
r,
|
||||
canCapture,
|
||||
onChange,
|
||||
}: {
|
||||
r: CashReceipt;
|
||||
canCapture: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const [deposit, setDeposit] = useState("");
|
||||
const voided = r.transaction.voidedAt != null;
|
||||
|
||||
async function saveDeposit() {
|
||||
await setCashReceiptDeposit(r.id, deposit);
|
||||
onChange();
|
||||
}
|
||||
|
||||
return (
|
||||
<tr style={voided ? { textDecoration: "line-through", opacity: 0.6 } : undefined}>
|
||||
<td>{r.folio}</td>
|
||||
<td>{formatDate(r.receivedAt)}</td>
|
||||
<td>{r.customer.folderNumber ?? "—"}</td>
|
||||
<td>
|
||||
<Link href={`/clientes/${r.customer.id}`}>{r.customer.name}</Link>
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>{formatMoney(r.amount, r.currency)}</td>
|
||||
<td>{r.concept}</td>
|
||||
<td>{r.preparedBy.name}</td>
|
||||
<td>
|
||||
{r.depositedAt ? (
|
||||
formatDate(r.depositedAt)
|
||||
) : canCapture && !voided ? (
|
||||
<span className="inline-form" style={{ gap: 4 }}>
|
||||
<input className="input" type="date" value={deposit}
|
||||
onChange={(e) => setDeposit(e.target.value)} />
|
||||
<button type="button" className="btn btn-ghost" disabled={!deposit}
|
||||
onClick={saveDeposit}>
|
||||
Guardar
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{voided ? "Anulado" : <Link href={`/efectivo/${r.folio}`} target="_blank">Imprimir</Link>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -36,10 +36,14 @@ import type {
|
||||
* charge and an insurance payment finally sit on the same page, under the same
|
||||
* person, with a running balance.
|
||||
*
|
||||
* The running balance is per currency (the API accumulates it chronologically
|
||||
* before handing the list back newest-first), so the movement table is scoped
|
||||
* to one currency at a time — a column that alternated between pesos and
|
||||
* dollars would be a meaningless number.
|
||||
* The running balance is per currency, so the movement table is scoped to one
|
||||
* currency at a time — a column that alternated between pesos and dollars would
|
||||
* be a meaningless number.
|
||||
*
|
||||
* Like the legacy EDO CUENTA report, the table covers one calendar year and runs
|
||||
* oldest-first, opening on the balance carried in from before it. The period
|
||||
* selector switches years; earlier ones are served from the imported archive of
|
||||
* that year, which is how legacy kept them — one table per closed year.
|
||||
*/
|
||||
export default function EstadoCuentaDetailPage({
|
||||
params,
|
||||
@@ -65,19 +69,27 @@ function StatementView({ id }: { id: string }) {
|
||||
|
||||
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
||||
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
||||
/** null = the current period; the API decides what that is. */
|
||||
const [year, setYear] = useState<number | null>(null);
|
||||
|
||||
function reload() {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getStatement(id)
|
||||
getStatement(id, year ?? undefined)
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
setData(d);
|
||||
// Default to the currency the customer actually moves the most in;
|
||||
// preserve a previously-chosen currency across reloads.
|
||||
// preserve a previously-chosen currency across reloads — but only if
|
||||
// the loaded period still has it. Switching to a year the customer
|
||||
// never moved dollars in would otherwise leave the picker on USD with
|
||||
// no matching option, showing an empty table for a year that has rows.
|
||||
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
||||
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN");
|
||||
const fallback = busiest?.currency ?? "MXN";
|
||||
setCurrency((prev) =>
|
||||
prev && d.summary.some((s) => s.currency === prev) ? prev : fallback,
|
||||
);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -99,7 +111,7 @@ function StatementView({ id }: { id: string }) {
|
||||
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
||||
return cleanup;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
}, [id, year]);
|
||||
|
||||
const movements = useMemo(() => {
|
||||
if (!data || !currency) return [];
|
||||
@@ -194,7 +206,7 @@ function StatementView({ id }: { id: string }) {
|
||||
<section className="section">
|
||||
<SectionHead
|
||||
rule="cuenta"
|
||||
title="Movimientos"
|
||||
title={`Movimientos ${data.year}`}
|
||||
count={movements.length}
|
||||
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
|
||||
right={
|
||||
@@ -227,6 +239,26 @@ function StatementView({ id }: { id: string }) {
|
||||
)}
|
||||
|
||||
<div className="filter-row">
|
||||
{/* Only the periods this customer has. A year with no archive would
|
||||
render an empty table that reads as "no hubo movimientos" when the
|
||||
truth is that the year was never imported. */}
|
||||
{data.availableYears.length > 1 && (
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Periodo</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={data.year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
>
|
||||
{data.availableYears.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
{y === data.availableYears[0] ? " (en curso)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Moneda</span>
|
||||
<select
|
||||
@@ -260,7 +292,7 @@ function StatementView({ id }: { id: string }) {
|
||||
<div className="card">
|
||||
{movements.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
Sin movimientos en {currency}
|
||||
Sin movimientos de {data.year} en {currency}
|
||||
{domain ? ` para ${domainLabel(domain)}` : ""}.
|
||||
</div>
|
||||
) : (
|
||||
@@ -282,6 +314,26 @@ function StatementView({ id }: { id: string }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/*
|
||||
The carried balance, shown the way the legacy report shows
|
||||
it: a BALANCE FORWARD line above the year's movements. It
|
||||
only appears when there is something to carry — when the
|
||||
customer's opening-balance row is itself dated inside this
|
||||
year (the usual case) it is listed as an ordinary movement
|
||||
and this row is zero, so it is left out.
|
||||
|
||||
Suppressed under a business-line filter: the carried balance
|
||||
is the customer's, across both lines, and printing it above
|
||||
one line's rows would read as that line's opening balance.
|
||||
*/}
|
||||
{!domain && Number(active?.opening ?? 0) !== 0 && (
|
||||
<OpeningRow
|
||||
opening={active!.opening}
|
||||
currency={currency}
|
||||
year={data.year}
|
||||
canVoid={canVoid}
|
||||
/>
|
||||
)}
|
||||
{movements.map((m) => (
|
||||
<StatementRow
|
||||
key={m.id}
|
||||
@@ -481,6 +533,44 @@ function ConceptosSection({
|
||||
);
|
||||
}
|
||||
|
||||
/** The balance carried into the statement year — legacy's BALANCE FORWARD. */
|
||||
function OpeningRow({
|
||||
opening,
|
||||
currency,
|
||||
year,
|
||||
canVoid,
|
||||
}: {
|
||||
opening: string;
|
||||
currency: LedgerCurrency;
|
||||
year: number;
|
||||
canVoid: boolean;
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(`${year}-01-01T00:00:00.000Z`)}
|
||||
</td>
|
||||
<td className="tx-domain-cell">Ambas líneas</td>
|
||||
<td>
|
||||
Saldo anterior
|
||||
<div className="tx-concept">Al cierre de {year - 1}</div>
|
||||
</td>
|
||||
<td className="tx-ref">—</td>
|
||||
<td className="num">
|
||||
<span className={`tx-amount ${Number(opening) < 0 ? "neg" : "pos"}`}>
|
||||
{formatMoney(opening, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
<span className={`bal-running ${balanceTone(opening)}`}>
|
||||
{formatMoney(opening, currency)}
|
||||
</span>
|
||||
</td>
|
||||
{canVoid && <td />}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function StatementRow({
|
||||
m,
|
||||
canVoid,
|
||||
|
||||
@@ -926,6 +926,15 @@ button {
|
||||
color: var(--ink-soft);
|
||||
margin-bottom: 0.4375rem;
|
||||
}
|
||||
/* Sub-label under an input: the computed figure behind an override field, or
|
||||
why a field is disabled. Quiet enough not to compete with .field-label. */
|
||||
.field-hint {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.35;
|
||||
color: var(--muted-2);
|
||||
margin-top: 0.3125rem;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
@@ -940,6 +949,12 @@ button {
|
||||
.input::placeholder {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
.input:disabled,
|
||||
.select:disabled {
|
||||
background: var(--surface-2, var(--surface));
|
||||
color: var(--muted-2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-600);
|
||||
@@ -3114,3 +3129,61 @@ button {
|
||||
border-color: var(--brand-500);
|
||||
color: var(--brand-700);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Layout + text utilities the screens already assumed
|
||||
Several components were written against these names before any rule
|
||||
defined them, so they rendered as bare inline spans. The visible symptom
|
||||
was the policy OCR review header running together —
|
||||
"Para revisarPágina 1700489616· PAMELA DENISE WAGONERLICENCIASANA" —
|
||||
because JSX drops the newline between sibling elements and the `gap` those
|
||||
call sites pass does nothing without a flex container.
|
||||
========================================================================== */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
/* The muted line under a page title, and the same voice reused inline. Only
|
||||
the block form takes a margin — as a flex child it would shift the item
|
||||
off the row's centre line. */
|
||||
.page-sub {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
p.page-sub {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
/* A neutral chip. Same shape as `.badge` so the OCR statuses, policy type and
|
||||
carrier read as the labels they are rather than as running prose. */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.1875rem 0.5625rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
background: var(--paper-2);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line-strong);
|
||||
}
|
||||
/* The warning sibling of `.state-error`, used where a page needs a human to
|
||||
choose between candidates rather than reporting a failure. */
|
||||
.state-warn {
|
||||
background: var(--servicios-tint);
|
||||
border: 1px solid rgba(154, 106, 18, 0.25);
|
||||
color: var(--servicios-ink);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.125rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -68,6 +68,7 @@ function Operaciones() {
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
const periodInput = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const refreshLists = useCallback(() => {
|
||||
listIngest().then(setIngest).catch(() => setIngest([]));
|
||||
@@ -143,6 +144,20 @@ function Operaciones() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload a prior-period archive under its own filename. */
|
||||
async function handleUploadPeriod(file: File | undefined) {
|
||||
if (!file) return;
|
||||
if (!/^\d{4}\.accdb$/i.test(file.name)) {
|
||||
setError(
|
||||
`"${file.name}" no es un archivo de periodo. Debe llamarse AAAA.accdb, por ejemplo 2025.accdb.`,
|
||||
);
|
||||
if (periodInput.current) periodInput.current.value = "";
|
||||
return;
|
||||
}
|
||||
await handleUpload(file.name, file);
|
||||
if (periodInput.current) periodInput.current.value = "";
|
||||
}
|
||||
|
||||
async function handleDeleteIngest(name: string) {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -197,7 +212,11 @@ function Operaciones() {
|
||||
else await start("RESTORE", c.file);
|
||||
}
|
||||
|
||||
const ingestReady = (ingest ?? []).every((f) => f.present);
|
||||
// Only the four fixed Access sources gate a run. Period archives are
|
||||
// optional extras — having none simply means no prior years are available.
|
||||
const ingestReady = (ingest ?? [])
|
||||
.filter((f) => f.periodYear === null)
|
||||
.every((f) => f.present);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -244,8 +263,9 @@ function Operaciones() {
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title">Carpeta de ingesta</h2>
|
||||
<p className="inline-form-note">
|
||||
Los cuatro archivos originales de Access. La reimportación y la
|
||||
sincronización leen de aquí. Tamaño máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
|
||||
Los cuatro archivos originales de Access, más los archivos de periodos
|
||||
anteriores. La reimportación y la sincronización leen de aquí. Tamaño
|
||||
máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
|
||||
</p>
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
@@ -262,7 +282,14 @@ function Operaciones() {
|
||||
{(ingest ?? []).map((f) => (
|
||||
<Fragment key={f.name}>
|
||||
<tr>
|
||||
<td className="mono">{f.name}</td>
|
||||
<td className="mono">
|
||||
{f.name}
|
||||
{f.periodYear !== null && (
|
||||
<span className="badge" style={{ marginLeft: 8 }}>
|
||||
periodo {f.periodYear}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||
{f.present ? "Presente" : "Falta"}
|
||||
@@ -313,6 +340,33 @@ function Operaciones() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* A period archive that has never been uploaded has no row to click,
|
||||
so it needs its own entry point. The file names itself: the archive
|
||||
IS `2025.accdb`, and that name is what declares the period, so the
|
||||
control reads it off the chosen file rather than asking twice. */}
|
||||
<div className="row-actions" style={{ marginTop: 16 }}>
|
||||
<input
|
||||
ref={periodInput}
|
||||
type="file"
|
||||
accept=".accdb"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleUploadPeriod(e.target.files?.[0])}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
disabled={uploading !== null}
|
||||
onClick={() => periodInput.current?.click()}
|
||||
>
|
||||
Agregar periodo anterior…
|
||||
</button>
|
||||
<span className="inline-form-note">
|
||||
Un archivo de Access por año cerrado, nombrado con su periodo:{" "}
|
||||
<span className="mono">2025.accdb</span>. Aporta el año anterior al
|
||||
estado de cuenta; no altera el saldo actual.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operations */}
|
||||
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
premiumHeadline,
|
||||
SIN_NOMBRE,
|
||||
} from "@/lib/labels";
|
||||
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
|
||||
import {
|
||||
PAYMENT_FREQUENCY_LABELS,
|
||||
type AdjusterRow,
|
||||
type Installment,
|
||||
type PolicyDetail,
|
||||
} from "@/lib/types";
|
||||
import { formatRate } from "@/lib/premium";
|
||||
|
||||
export default function PolizaDetailPage({
|
||||
params,
|
||||
@@ -186,6 +192,10 @@ function ChildrenEditor({
|
||||
const INSTALLMENTS: ChildConfig = {
|
||||
apiKind: "installments",
|
||||
title: "Pagos",
|
||||
// A policy paid in several exhibiciones prices each payment on its own, so
|
||||
// the whole premium breakdown repeats per row — that is the two-row money
|
||||
// block on the Access form. `amount` stays what was actually collected and
|
||||
// is deliberately separate from `total`; they differ by rounding.
|
||||
fields: [
|
||||
{ key: "sequence", label: "Sec.", type: "number" },
|
||||
{ key: "amount", label: "Monto", type: "number" },
|
||||
@@ -195,6 +205,12 @@ function ChildrenEditor({
|
||||
{ key: "paidDate", label: "Pagado", type: "date" },
|
||||
{ key: "checkNumber", label: "Cheque" },
|
||||
{ key: "isCash", label: "Efectivo", type: "checkbox" },
|
||||
{ key: "netPremium", label: "Prima neta", type: "number" },
|
||||
{ key: "surcharge", label: "Recargo", type: "number" },
|
||||
{ key: "policyFee", label: "Derecho", type: "number" },
|
||||
{ key: "tax", label: "IVA", type: "number" },
|
||||
{ key: "total", label: "Prima total", type: "number" },
|
||||
{ key: "commission", label: "Comisión", type: "number" },
|
||||
],
|
||||
};
|
||||
const VEHICLES: ChildConfig = {
|
||||
@@ -412,15 +428,40 @@ function CondicionesSection({ data }: { data: PolicyDetail }) {
|
||||
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
||||
}
|
||||
/>
|
||||
<KV
|
||||
label="Forma de pago"
|
||||
value={
|
||||
data.paymentFrequency
|
||||
? PAYMENT_FREQUENCY_LABELS[data.paymentFrequency]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
||||
{/* Only ever set on a policy paid in installments, so showing an
|
||||
empty row on the other 98% would be noise. */}
|
||||
{data.surcharge != null && Number(data.surcharge) !== 0 && (
|
||||
<KV label="Recargo" value={formatMoney(data.surcharge, cur)} />
|
||||
)}
|
||||
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
||||
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||
{/* Access never stored IVA — it was a calculated control on the form
|
||||
— so every migrated policy reads null here until it is edited. */}
|
||||
{data.tax != null && (
|
||||
<KV
|
||||
label={
|
||||
data.taxRate != null
|
||||
? `IVA (${formatRate(Number(data.taxRate))})`
|
||||
: "IVA"
|
||||
}
|
||||
value={formatMoney(data.tax, cur)}
|
||||
/>
|
||||
)}
|
||||
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
|
||||
only show it when it actually carries a figure. */}
|
||||
{data.total != null && Number(data.total) > 0 && (
|
||||
<KV label="Total" value={formatMoney(data.total, cur)} />
|
||||
<KV label="Prima total" value={formatMoney(data.total, cur)} />
|
||||
)}
|
||||
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||
<KV
|
||||
label="Liquidación"
|
||||
value={
|
||||
|
||||
@@ -6,18 +6,21 @@ import { AppShell } from "@/components/AppShell";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
||||
import {
|
||||
addStatementFiles,
|
||||
confirmStatementBatch,
|
||||
discardStatementBatch,
|
||||
getStatementBatch,
|
||||
listStatementDocuments,
|
||||
rejectStatementDocument,
|
||||
reviewStatementDocument,
|
||||
setStatementSplits,
|
||||
statementPageUrl,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
|
||||
import type {
|
||||
ConfirmBatchInput,
|
||||
SplitPartInput,
|
||||
StatementBatchDetail,
|
||||
StatementDocument,
|
||||
StatementDocumentStatus,
|
||||
@@ -64,6 +67,7 @@ export default function RecibosBatchPage({ params }: { params: { id: string } })
|
||||
|
||||
function BatchReview({ id }: { id: string }) {
|
||||
const canReview = useCan("statement:review");
|
||||
const canIngest = useCan("statement:ingest");
|
||||
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
|
||||
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -108,7 +112,7 @@ function BatchReview({ id }: { id: string }) {
|
||||
);
|
||||
|
||||
const readyCount = docs.filter(
|
||||
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
||||
(d) => d.status === "MATCHED" && (d.matchedCustomer || d.splits.length > 0),
|
||||
).length;
|
||||
|
||||
async function discard() {
|
||||
@@ -162,9 +166,14 @@ function BatchReview({ id }: { id: string }) {
|
||||
|
||||
<SummaryCard batch={batch} readyCount={readyCount} />
|
||||
|
||||
{canIngest && !processing && batch.status !== "DISCARDED" && (
|
||||
<AddFilesCard batchId={id} onDone={load} setError={setError} />
|
||||
)}
|
||||
|
||||
{canReview && readyCount > 0 && (
|
||||
<ConfirmCard
|
||||
batchId={id}
|
||||
lastCheckNumber={batch.lastCheckNumber}
|
||||
readyCount={readyCount}
|
||||
onDone={load}
|
||||
setError={setError}
|
||||
@@ -291,16 +300,19 @@ function SummaryCard({
|
||||
*/
|
||||
function ConfirmCard({
|
||||
batchId,
|
||||
lastCheckNumber,
|
||||
readyCount,
|
||||
onDone,
|
||||
setError,
|
||||
}: {
|
||||
batchId: string;
|
||||
lastCheckNumber: string | null;
|
||||
readyCount: number;
|
||||
onDone: () => void;
|
||||
setError: (m: string | null) => void;
|
||||
}) {
|
||||
const [checkNumber, setCheckNumber] = useState("");
|
||||
// A batch kept open for one check is posted in rounds against that check.
|
||||
const [checkNumber, setCheckNumber] = useState(lastCheckNumber ?? "");
|
||||
const [transactionDate, setTransactionDate] = useState(
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
@@ -322,9 +334,9 @@ function ConfirmCard({
|
||||
};
|
||||
const r = await confirmStatementBatch(batchId, input);
|
||||
setResult(
|
||||
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}.`,
|
||||
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}` +
|
||||
(r.outstanding ? `; ${r.outstanding} sin fondos.` : "."),
|
||||
);
|
||||
setCheckNumber("");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
|
||||
@@ -363,7 +375,7 @@ function ConfirmCard({
|
||||
checked={outstanding}
|
||||
onChange={(e) => setOutstanding(e.target.checked)}
|
||||
/>{" "}
|
||||
Sin fondos (queda pendiente)
|
||||
Todo el cheque sin fondos
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
@@ -389,12 +401,65 @@ function ConfirmCard({
|
||||
)}
|
||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||
Se registran como cargos del cliente, por la misma vía que la captura
|
||||
manual. Un lote registrado dos veces no duplica cobros.
|
||||
manual. Un lote registrado dos veces no duplica cobros. Para dejar
|
||||
pendiente un solo recibo, márcalo «Sin fondos» en el recibo.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** More scans for this batch — it stays open while receipts keep arriving. */
|
||||
function AddFilesCard({
|
||||
batchId,
|
||||
onDone,
|
||||
setError,
|
||||
}: {
|
||||
batchId: string;
|
||||
onDone: () => void;
|
||||
setError: (m: string | null) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function upload() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await addStatementFiles(batchId, files);
|
||||
setFiles([]);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudieron agregar los archivos.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Agregar recibos a este lote</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
disabled={!files.length || busy}
|
||||
onClick={upload}
|
||||
>
|
||||
{busy ? "Subiendo…" : `Agregar ${files.length || ""} archivo(s)`}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentRow({
|
||||
doc,
|
||||
canReview,
|
||||
@@ -411,6 +476,7 @@ function DocumentRow({
|
||||
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
|
||||
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
|
||||
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
|
||||
const [outstanding, setOutstanding] = useState(doc.outstanding);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
@@ -424,6 +490,7 @@ function DocumentRow({
|
||||
accountRef: accountRef.trim() || undefined,
|
||||
amount: amount ? Number(amount) : undefined,
|
||||
matchedCustomerId: customerId || undefined,
|
||||
outstanding,
|
||||
status,
|
||||
});
|
||||
onChange();
|
||||
@@ -456,9 +523,21 @@ function DocumentRow({
|
||||
<div>
|
||||
<strong>Página {doc.pageNumber}</strong>{" "}
|
||||
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
|
||||
{doc.outstanding && <span className="tag">Sin fondos</span>}{" "}
|
||||
{doc.provider && <span className="page-sub">{doc.provider}</span>}
|
||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||
{doc.matchedCustomer ? (
|
||||
{doc.splits.length > 0 ? (
|
||||
<>
|
||||
Dividido:{" "}
|
||||
{doc.splits.map((s, i) => (
|
||||
<span key={s.id}>
|
||||
{i > 0 && " + "}
|
||||
<Link href={`/clientes/${s.customer.id}`}>{s.customer.name}</Link>{" "}
|
||||
{formatMoney(s.amount, "MXN")}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : doc.matchedCustomer ? (
|
||||
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
|
||||
{doc.matchedCustomer.name}
|
||||
</Link>
|
||||
@@ -533,10 +612,18 @@ function DocumentRow({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={outstanding}
|
||||
onChange={(e) => setOutstanding(e.target.checked)}
|
||||
/>{" "}
|
||||
Sin fondos
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !customerId}
|
||||
disabled={busy || (!customerId && doc.splits.length === 0)}
|
||||
onClick={() => save("MATCHED")}
|
||||
>
|
||||
Guardar
|
||||
@@ -552,9 +639,164 @@ function DocumentRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReview && !settled && (
|
||||
<SplitEditor doc={doc} onChange={onChange} />
|
||||
)}
|
||||
|
||||
{err && <div className="state-box state-error">{err}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Part = { customerId: string; customerName: string; amount: string };
|
||||
|
||||
/** Equal parts in cents; the odd cents go to the first parts. */
|
||||
function equalParts(total: number, n: number): string[] {
|
||||
const cents = Math.round(total * 100);
|
||||
const base = Math.floor(cents / n);
|
||||
return Array.from({ length: n }, (_, i) =>
|
||||
((base + (i < cents - base * n ? 1 : 0)) / 100).toFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One receipt paid by several customers — a water bill two neighbours share.
|
||||
* Amounts are free (the office splits unevenly as often as not) but must add
|
||||
* up to the receipt; "partes iguales" fills them in.
|
||||
*/
|
||||
function SplitEditor({ doc, onChange }: { doc: StatementDocument; onChange: () => void }) {
|
||||
const total = Number(doc.extractedAmount ?? 0);
|
||||
const [parts, setParts] = useState<Part[]>(() =>
|
||||
doc.splits.map((s) => ({
|
||||
customerId: s.customer.id,
|
||||
customerName: s.customer.name,
|
||||
amount: Number(s.amount).toFixed(2),
|
||||
})),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const sum = parts.reduce((s, p) => s + Math.round(Number(p.amount || 0) * 100), 0) / 100;
|
||||
const balanced = Math.round(sum * 100) === Math.round(total * 100);
|
||||
const complete = parts.length >= 2 && parts.every((p) => p.customerId && Number(p.amount) > 0);
|
||||
|
||||
function setPart(i: number, patch: Partial<Part>) {
|
||||
setParts((prev) => prev.map((p, j) => (j === i ? { ...p, ...patch } : p)));
|
||||
}
|
||||
|
||||
function start() {
|
||||
const first = {
|
||||
customerId: doc.matchedCustomer?.id ?? "",
|
||||
customerName: doc.matchedCustomer?.name ?? "",
|
||||
};
|
||||
const [a, b] = equalParts(total, 2);
|
||||
setParts([
|
||||
{ ...first, amount: a },
|
||||
{ customerId: "", customerName: "", amount: b },
|
||||
]);
|
||||
}
|
||||
|
||||
function evenly() {
|
||||
const amounts = equalParts(total, parts.length);
|
||||
setParts((prev) => prev.map((p, i) => ({ ...p, amount: amounts[i] })));
|
||||
}
|
||||
|
||||
async function save(next: SplitPartInput[]) {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await setStatementSplits(doc.id, next);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? "No se pudo guardar la división.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={!doc.extractedAmount}
|
||||
title={doc.extractedAmount ? undefined : "Captura el importe primero"}
|
||||
onClick={start}
|
||||
>
|
||||
Dividir entre varios clientes
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<strong>División del recibo ({formatMoney(total, "MXN")})</strong>
|
||||
{parts.map((p, i) => (
|
||||
<div key={i} className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<div style={{ minWidth: 260 }}>
|
||||
<CustomerPicker
|
||||
value={p.customerId}
|
||||
valueName={p.customerName}
|
||||
onPick={(cid, name) => setPart(i, { customerId: cid, customerName: name })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="decimal"
|
||||
style={{ width: 120 }}
|
||||
value={p.amount}
|
||||
onChange={(e) => setPart(i, { amount: e.target.value })}
|
||||
/>
|
||||
{parts.length > 2 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setParts((prev) => prev.filter((_, j) => j !== i))}
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setParts((prev) => [...prev, { customerId: "", customerName: "", amount: "" }])}
|
||||
>
|
||||
Agregar cliente
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={evenly}>
|
||||
Partes iguales
|
||||
</button>
|
||||
<span className={balanced ? "page-sub" : "state-error"}>
|
||||
Suma {formatMoney(sum, "MXN")}
|
||||
{!balanced && ` — diferencia ${formatMoney(total - sum, "MXN")}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !balanced || !complete}
|
||||
onClick={() =>
|
||||
save(parts.map((p) => ({ customerId: p.customerId, amount: Number(p.amount) })))
|
||||
}
|
||||
>
|
||||
Guardar división
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
disabled={busy}
|
||||
onClick={() => (doc.splits.length ? save([]) : setParts([]))}
|
||||
>
|
||||
Quitar división
|
||||
</button>
|
||||
</div>
|
||||
{err && <div className="state-box state-error">{err}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,8 @@ const NAV: NavEntry[] = [
|
||||
ability: "ledger:create",
|
||||
aliases: ["/recibos"],
|
||||
},
|
||||
// The EFE cash receipt book (legacy EFE MENU → EFE).
|
||||
{ href: "/efectivo", label: "Recibos de efectivo", ability: "ledger:create" },
|
||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||
{ href: "/banco", label: "Chequera" },
|
||||
],
|
||||
|
||||
@@ -9,6 +9,9 @@ export type FieldDef = {
|
||||
type?: "text" | "number" | "date" | "checkbox" | "select";
|
||||
options?: { value: string; label: string }[];
|
||||
width?: number;
|
||||
/** Numeric granularity. Defaults to money (0.01); a tax rate stored as a
|
||||
* fraction needs finer, or the browser rejects 0.0825 as off-step. */
|
||||
step?: string;
|
||||
};
|
||||
|
||||
export type ChildConfig = {
|
||||
@@ -158,7 +161,7 @@ export function ChildCollection({
|
||||
<input
|
||||
className="input"
|
||||
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
|
||||
step={f.type === "number" ? "0.01" : undefined}
|
||||
step={f.type === "number" ? f.step ?? "0.01" : undefined}
|
||||
value={String(values[f.key] ?? "")}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CustomerDetail, CustomerInput, Currency } from "@/lib/types";
|
||||
import { createCustomer, updateCustomer } from "@/lib/api";
|
||||
import { createCustomer, getNextFolderNumber, updateCustomer } from "@/lib/api";
|
||||
|
||||
/** ISO date (yyyy-mm-dd) for a date input, from an API date string. */
|
||||
function toDateInput(v: string | null | undefined): string {
|
||||
@@ -20,6 +20,7 @@ function numOrUndef(v: string | number | null | undefined): number | undefined {
|
||||
|
||||
type Values = {
|
||||
name: string;
|
||||
folderNumber: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
city: string;
|
||||
@@ -45,6 +46,7 @@ type Values = {
|
||||
function initial(c?: CustomerDetail): Values {
|
||||
return {
|
||||
name: c?.name ?? "",
|
||||
folderNumber: c?.folderNumber != null ? String(c.folderNumber) : "",
|
||||
addressLine1: c?.addressLine1 ?? "",
|
||||
addressLine2: c?.addressLine2 ?? "",
|
||||
city: c?.city ?? "",
|
||||
@@ -85,6 +87,17 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// A new customer is offered the lowest free folder, the way the office
|
||||
// hands numbers back out; staff can type any other.
|
||||
useEffect(() => {
|
||||
if (editing) return;
|
||||
getNextFolderNumber()
|
||||
.then(({ folderNumber }) =>
|
||||
setV((prev) => (prev.folderNumber ? prev : { ...prev, folderNumber: String(folderNumber) })),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, [editing]);
|
||||
|
||||
function set<K extends keyof Values>(key: K, val: Values[K]) {
|
||||
setV((prev) => ({ ...prev, [key]: val }));
|
||||
}
|
||||
@@ -95,6 +108,8 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
||||
setError(null);
|
||||
const payload: CustomerInput = {
|
||||
name: v.name.trim(),
|
||||
// Blank clears it on edit; s() would leave the old number in place.
|
||||
folderNumber: v.folderNumber.trim() === "" ? (editing ? null : undefined) : Number(v.folderNumber),
|
||||
addressLine1: s(v.addressLine1),
|
||||
addressLine2: s(v.addressLine2),
|
||||
city: s(v.city),
|
||||
@@ -138,6 +153,10 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
||||
<input className="input" required value={v.name}
|
||||
onChange={(e) => set("name", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Carpeta">
|
||||
<input className="input" type="number" min={1} step={1} value={v.folderNumber}
|
||||
onChange={(e) => set("folderNumber", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Correo">
|
||||
<input className="input" type="email" value={v.email}
|
||||
onChange={(e) => set("email", e.target.value)} />
|
||||
|
||||
@@ -4,7 +4,13 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney } from "@/lib/labels";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
|
||||
import {
|
||||
apiFetch,
|
||||
getLookups,
|
||||
POLIZAS_LOG_SCOPE,
|
||||
type NotificationFlags,
|
||||
} from "@/lib/api";
|
||||
import type { ProviderRow } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
|
||||
@@ -22,6 +28,12 @@ import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
|
||||
* thing here as it does for servicios: the mail is diverted to the override
|
||||
* inbox. It additionally does NOT mark the notice as sent, so a test send
|
||||
* leaves the row exactly where it was — pending.
|
||||
*
|
||||
* The barrido manual is scoped by aseguradora because the office works GMX and
|
||||
* ANA as separate batches. The selection filters the pending list too, so what
|
||||
* is on screen is exactly what "Ejecutar barrido" will mail. A carrier-scoped
|
||||
* run deliberately does not advance the sweep's catch-up window — it only
|
||||
* covered one carrier — so the other carriers' letters stay pending.
|
||||
*/
|
||||
|
||||
export interface RenewalLetter {
|
||||
@@ -46,6 +58,8 @@ export interface RenewalSweepResult {
|
||||
failed: number;
|
||||
failures: { policyId: string; generation: number; error: string }[];
|
||||
debug: boolean;
|
||||
/** Echoed back so the confirmation says which carrier actually ran. */
|
||||
providerId: string | null;
|
||||
}
|
||||
|
||||
export interface RenewalSendResult {
|
||||
@@ -68,6 +82,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
const allowed = useCan("renewal:send");
|
||||
const debug = !!flags.debug;
|
||||
const [days, setDays] = useState(30);
|
||||
/** "" = ambas/todas. Holds an InsuranceProvider id, never a name — carriers
|
||||
* are renamed in the lookups screen and the filter must survive that. */
|
||||
const [providerId, setProviderId] = useState("");
|
||||
const [providers, setProviders] = useState<ProviderRow[]>([]);
|
||||
const [pending, setPending] = useState<RenewalLetter[] | null>(null);
|
||||
const [pendingError, setPendingError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
@@ -81,8 +99,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
const refresh = useCallback(async () => {
|
||||
setPendingError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ days: String(days) });
|
||||
if (providerId) params.set("providerId", providerId);
|
||||
const data = await apiFetch<RenewalLetter[]>(
|
||||
`/renewals/pending?days=${days}`,
|
||||
`/renewals/pending?${params.toString()}`,
|
||||
);
|
||||
setPending(data);
|
||||
} catch (e) {
|
||||
@@ -91,18 +111,44 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
);
|
||||
setPending([]);
|
||||
}
|
||||
}, [days]);
|
||||
}, [days, providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowed) refresh();
|
||||
}, [allowed, refresh]);
|
||||
|
||||
// Carriers come from the same lookups the policy form uses, so a new
|
||||
// aseguradora shows up here without a code change.
|
||||
useEffect(() => {
|
||||
if (!allowed) return;
|
||||
let cancelled = false;
|
||||
getLookups()
|
||||
.then((data) => {
|
||||
if (!cancelled) setProviders(data.providers);
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed lookup only costs the filter; the unfiltered sweep still
|
||||
// works, so this must not blank the screen.
|
||||
if (!cancelled) setProviders([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [allowed]);
|
||||
|
||||
const providerLabel =
|
||||
providers.find((item) => item.id === providerId)?.name ?? "todas las compañías";
|
||||
|
||||
async function handleSweep() {
|
||||
// Only worth confirming when debug is off — that is the case where real
|
||||
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab.
|
||||
// The carrier is named in the prompt: running GMX when ANA was meant is
|
||||
// exactly the mistake this filter exists to prevent, and it is not
|
||||
// reversible once the mail is out.
|
||||
if (!debug) {
|
||||
const ok = window.confirm(
|
||||
"debug está desactivado: los avisos irán a los correos reales de los clientes. ¿Ejecutar el barrido?",
|
||||
`debug está desactivado: los avisos irán a los correos reales de los clientes. ` +
|
||||
`¿Ejecutar el barrido de ${providerLabel}?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
@@ -112,10 +158,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
try {
|
||||
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ debug }),
|
||||
body: JSON.stringify({ debug, providerId: providerId || undefined }),
|
||||
});
|
||||
setNotice(
|
||||
`Enviados ${result.sent} avisos (${result.failed} con error).` +
|
||||
`Enviados ${result.sent} avisos de ${providerLabel} ` +
|
||||
`(${result.failed} con error).` +
|
||||
(result.debug
|
||||
? " Modo debug: fueron al buzón de pruebas y siguen pendientes."
|
||||
: ""),
|
||||
@@ -199,7 +246,9 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
<h2 className="section-title">Barrido manual</h2>
|
||||
<p className="muted small" style={{ marginTop: 4 }}>
|
||||
Usa la fecha actual del servidor como referencia para seleccionar
|
||||
avisos vencidos a 30 y 15 días, y vencidos hace 7 días.
|
||||
avisos vencidos a 30 y 15 días, y vencidos hace 7 días. La
|
||||
compañía elegida filtra también la lista de abajo: se envía
|
||||
exactamente lo que está en pantalla.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -211,18 +260,38 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
||||
{sweeping ? "Enviando…" : "Ejecutar barrido"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="field" style={{ maxWidth: 180, marginTop: 12, marginBottom: 0 }}>
|
||||
<span className="field-label">Ventana (días)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={days}
|
||||
onChange={(e) =>
|
||||
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="row-actions"
|
||||
style={{ marginTop: 12, alignItems: "flex-end", gap: 16 }}
|
||||
>
|
||||
<div className="field" style={{ maxWidth: 180, marginBottom: 0 }}>
|
||||
<span className="field-label">Ventana (días)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={days}
|
||||
onChange={(e) =>
|
||||
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field" style={{ maxWidth: 260, marginBottom: 0 }}>
|
||||
<span className="field-label">Compañía</span>
|
||||
<select
|
||||
className="input"
|
||||
value={providerId}
|
||||
onChange={(e) => setProviderId(e.target.value)}
|
||||
>
|
||||
<option value="">Todas las compañías</option>
|
||||
{providers.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,12 +4,22 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { createPolicy, getLookups, updatePolicy } from "@/lib/api";
|
||||
import type {
|
||||
Currency,
|
||||
LookupsResponse,
|
||||
PolicyDetail,
|
||||
PolicyInput,
|
||||
import {
|
||||
PAYMENT_FREQUENCY_LABELS,
|
||||
type Currency,
|
||||
type LookupsResponse,
|
||||
type PaymentFrequency,
|
||||
type PolicyDetail,
|
||||
type PolicyInput,
|
||||
} from "@/lib/types";
|
||||
import {
|
||||
computeTax,
|
||||
computeTotal,
|
||||
formatRate,
|
||||
resolveTaxRate,
|
||||
surchargeApplies,
|
||||
taxableBase,
|
||||
} from "@/lib/premium";
|
||||
|
||||
function toDateInput(v: string | null | undefined): string {
|
||||
if (!v) return "";
|
||||
@@ -36,9 +46,16 @@ type V = {
|
||||
policyFrom: string;
|
||||
policyTo: string;
|
||||
netPremium: string;
|
||||
surcharge: string;
|
||||
policyFee: string;
|
||||
brokerFee: string;
|
||||
commission: string;
|
||||
/** Blank means "use the computed figure". Only ever holds a value once the
|
||||
* operator overrides it, so a later change to prima neta keeps flowing
|
||||
* through instead of being frozen by a value the form itself wrote. */
|
||||
tax: string;
|
||||
total: string;
|
||||
paymentFrequency: PaymentFrequency | "";
|
||||
currency: Currency;
|
||||
liquidated: boolean;
|
||||
liquidationNumber: string;
|
||||
@@ -58,9 +75,13 @@ function initial(p?: PolicyDetail): V {
|
||||
policyFrom: toDateInput(p?.policyFrom),
|
||||
policyTo: toDateInput(p?.policyTo),
|
||||
netPremium: p?.netPremium != null ? String(p.netPremium) : "",
|
||||
surcharge: p?.surcharge != null ? String(p.surcharge) : "",
|
||||
policyFee: p?.policyFee != null ? String(p.policyFee) : "",
|
||||
brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "",
|
||||
commission: p?.commission != null ? String(p.commission) : "",
|
||||
tax: p?.tax != null ? String(p.tax) : "",
|
||||
total: p?.total != null ? String(p.total) : "",
|
||||
paymentFrequency: p?.paymentFrequency ?? "",
|
||||
currency: (p?.currency as Currency) ?? "MXN",
|
||||
liquidated: p?.liquidated ?? false,
|
||||
liquidationNumber: p?.liquidationNumber ?? "",
|
||||
@@ -101,6 +122,27 @@ export function PolicyForm({
|
||||
setV((p) => ({ ...p, [k]: val }));
|
||||
}
|
||||
|
||||
// IVA and Total are the only two figures the form derives. Everything else,
|
||||
// the recargo included, is keyed by hand — the carrier quotes the financing
|
||||
// charge, we do not compute it.
|
||||
const selectedType = lookups?.types.find((t) => t.id === v.policyTypeId);
|
||||
const taxRate = resolveTaxRate(policy?.taxRate, selectedType?.taxRate);
|
||||
const parts = {
|
||||
netPremium: v.netPremium,
|
||||
// A recargo on an annual policy is a data-entry mistake, so it is dropped
|
||||
// from the arithmetic as well as disabled in the UI. Otherwise switching
|
||||
// ANNUAL after typing one would leave it silently inflating the IVA.
|
||||
surcharge: surchargeApplies(v.paymentFrequency || null) ? v.surcharge : "",
|
||||
policyFee: v.policyFee,
|
||||
};
|
||||
const computedTax = computeTax(parts, taxRate);
|
||||
const computedTotal = computeTotal(parts, taxRate);
|
||||
// Blank field = take the computed figure. A typed one wins, so staff can key
|
||||
// the carrier's rounding verbatim when it disagrees with ours by a centavo.
|
||||
const effectiveTax = v.tax.trim() === "" ? computedTax : Number(v.tax);
|
||||
const effectiveTotal = v.total.trim() === "" ? computedTotal : Number(v.total);
|
||||
const showSurcharge = surchargeApplies(v.paymentFrequency || null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!customerId) {
|
||||
@@ -118,9 +160,17 @@ export function PolicyForm({
|
||||
policyFrom: s(v.policyFrom),
|
||||
policyTo: s(v.policyTo),
|
||||
netPremium: numOrUndef(v.netPremium),
|
||||
surcharge: showSurcharge ? numOrUndef(v.surcharge) : undefined,
|
||||
policyFee: numOrUndef(v.policyFee),
|
||||
brokerFee: numOrUndef(v.brokerFee),
|
||||
commission: numOrUndef(v.commission),
|
||||
// The derived figures are persisted, not recomputed on read: the printed
|
||||
// policy is the record of truth and a later rate change must not silently
|
||||
// restate what was issued. `taxRate` rides along for the same reason.
|
||||
tax: Number.isFinite(effectiveTax) ? effectiveTax : undefined,
|
||||
taxRate,
|
||||
total: Number.isFinite(effectiveTotal) ? effectiveTotal : undefined,
|
||||
paymentFrequency: v.paymentFrequency || undefined,
|
||||
currency: v.currency,
|
||||
liquidated: v.liquidated,
|
||||
liquidationNumber: s(v.liquidationNumber),
|
||||
@@ -208,7 +258,7 @@ export function PolicyForm({
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia y prima</h2>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia</h2>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Emisión</span>
|
||||
@@ -225,21 +275,84 @@ export function PolicyForm({
|
||||
<input className="input" type="date" value={v.policyTo}
|
||||
onChange={(e) => set("policyTo", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Forma de pago</span>
|
||||
<select className="select" value={v.paymentFrequency}
|
||||
onChange={(e) =>
|
||||
set("paymentFrequency", e.target.value as PaymentFrequency | "")
|
||||
}>
|
||||
<option value="">—</option>
|
||||
{(
|
||||
Object.keys(PAYMENT_FREQUENCY_LABELS) as PaymentFrequency[]
|
||||
).map((f) => (
|
||||
<option key={f} value={f}>{PAYMENT_FREQUENCY_LABELS[f]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 4 }}>Primas</h2>
|
||||
<p className="muted" style={{ fontSize: 12, marginBottom: 14 }}>
|
||||
IVA y prima total se calculan solos sobre (prima neta + recargo +
|
||||
derecho de póliza). Puede sobrescribirlos si la póliza impresa
|
||||
redondea distinto.
|
||||
</p>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Prima neta</span>
|
||||
<input className="input" type="number" step="0.01" value={v.netPremium}
|
||||
onChange={(e) => set("netPremium", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Recargo</span>
|
||||
<input className="input" type="number" step="0.01" value={v.surcharge}
|
||||
disabled={!showSurcharge}
|
||||
onChange={(e) => set("surcharge", e.target.value)} />
|
||||
<span className="field-hint">
|
||||
{showSurcharge
|
||||
? "Lo cotiza la aseguradora — se captura a mano."
|
||||
: "No aplica en pago anual ni de contado."}
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Derecho de póliza</span>
|
||||
<input className="input" type="number" step="0.01" value={v.policyFee}
|
||||
onChange={(e) => set("policyFee", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">IVA ({formatRate(taxRate)})</span>
|
||||
<input className="input" type="number" step="0.01"
|
||||
placeholder={computedTax.toFixed(2)} value={v.tax}
|
||||
onChange={(e) => set("tax", e.target.value)} />
|
||||
<span className="field-hint">
|
||||
Calculado: {computedTax.toFixed(2)} sobre base{" "}
|
||||
{taxableBase(parts).toFixed(2)}
|
||||
{selectedType?.taxRate == null &&
|
||||
policy?.taxRate == null &&
|
||||
" · tasa por omisión, configúrela en Catálogos"}
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Prima total</span>
|
||||
<input className="input" type="number" step="0.01"
|
||||
placeholder={computedTotal.toFixed(2)} value={v.total}
|
||||
onChange={(e) => set("total", e.target.value)} />
|
||||
<span className="field-hint">
|
||||
Calculado: {computedTotal.toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Comisión</span>
|
||||
<input className="input" type="number" step="0.01" value={v.commission}
|
||||
onChange={(e) => set("commission", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Honorarios</span>
|
||||
<input className="input" type="number" step="0.01" value={v.brokerFee}
|
||||
onChange={(e) => set("brokerFee", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
PolicyOcrBatchDetail,
|
||||
PolicyOcrConfirmDocument,
|
||||
PolicyOcrCoverage,
|
||||
PolicyOcrCustomerSuggestion,
|
||||
PolicyOcrDocument,
|
||||
PolicyOcrReviewInput,
|
||||
} from "@/lib/types";
|
||||
@@ -180,8 +181,11 @@ export function PolicyOcrReview({ id }: { id: string }) {
|
||||
{STATUS_LABEL[batch.status] ?? batch.status}
|
||||
</p>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" href="/polizas">
|
||||
Volver a pólizas
|
||||
{/* Back to the capture screen this batch was uploaded from, not to
|
||||
the policy list — same as the statement review screen, which
|
||||
returns to /recibos. */}
|
||||
<Link className="btn btn-ghost" href="/polizas/captura">
|
||||
Volver a captura
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
@@ -272,6 +276,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
policyDate: doc.extractedPolicyDate?.slice(0, 10) ?? "",
|
||||
currency: doc.extractedCurrency ?? "USD",
|
||||
netPremium: doc.extractedNetPremium ?? "",
|
||||
policyFee: doc.extractedPolicyFee ?? "",
|
||||
tax: doc.extractedTax ?? "",
|
||||
total: doc.extractedTotal ?? "",
|
||||
premiumPayment: doc.extractedPremiumPayment ?? "",
|
||||
coveragePeriodDays: doc.extractedCoveragePeriodDays?.toString() ?? "",
|
||||
@@ -310,6 +316,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
policyDate: v.policyDate || undefined,
|
||||
currency,
|
||||
netPremium: numOrUndef(v.netPremium),
|
||||
policyFee: numOrUndef(v.policyFee),
|
||||
tax: numOrUndef(v.tax),
|
||||
total: numOrUndef(v.total),
|
||||
premiumPayment: trimOrUndef(v.premiumPayment),
|
||||
coveragePeriodDays: numOrUndef(v.coveragePeriodDays),
|
||||
@@ -332,6 +340,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
policyDate: reviewInput.policyDate,
|
||||
currency: (currency as "MXN" | "USD" | "EUR" | undefined) ?? undefined,
|
||||
netPremium: reviewInput.netPremium,
|
||||
policyFee: reviewInput.policyFee,
|
||||
tax: reviewInput.tax,
|
||||
total: reviewInput.total,
|
||||
premiumPayment: reviewInput.premiumPayment,
|
||||
coveragePeriodDays: reviewInput.coveragePeriodDays,
|
||||
@@ -351,17 +361,19 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
const locked = doc.status === "POSTED" || doc.status === "REJECTED";
|
||||
const matchedExisting = !!doc.matchedPolicy;
|
||||
const candidates = doc.matchCandidates ?? [];
|
||||
const suggestions: PolicyOcrCustomerSuggestion[] = doc.customerSuggestions ?? [];
|
||||
|
||||
return (
|
||||
<article className="card" style={{ padding: 16 }}>
|
||||
<header className="row" style={{ gap: 12, alignItems: "center" }}>
|
||||
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>
|
||||
<span className="page-sub">Página {doc.pageNumber}</span>
|
||||
{doc.extractedPolicyNumber && (
|
||||
<strong style={{ marginLeft: 8 }}>{doc.extractedPolicyNumber}</strong>
|
||||
)}
|
||||
{/* No hand-rolled separators or margins here: `.row` is a flex
|
||||
container and its gap does the spacing. A literal "· " would leave
|
||||
a dot floating in that gap. */}
|
||||
{doc.extractedPolicyNumber && <strong>{doc.extractedPolicyNumber}</strong>}
|
||||
{doc.extractedInsuredName && (
|
||||
<span className="page-sub">· {doc.extractedInsuredName}</span>
|
||||
<span className="page-sub">{doc.extractedInsuredName}</span>
|
||||
)}
|
||||
{/* Read-only: the parser names the type, the confirm step resolves it
|
||||
to a policy_types row. Reassigning it is the policy screen's job,
|
||||
@@ -495,7 +507,25 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
onChange={(e) => set("netPremium", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Total">
|
||||
<Field label="Derecho de póliza">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={v.policyFee}
|
||||
onChange={(e) => set("policyFee", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="IVA">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={v.tax}
|
||||
onChange={(e) => set("tax", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Prima total">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
@@ -670,6 +700,34 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* Name suggestions, never a preselection. The office writes
|
||||
* customers surname-first and carriers print them given-name-first,
|
||||
* so without this the reviewer retypes a name the parser already
|
||||
* read. One click fills the picker above; nothing is chosen until
|
||||
* they click. Kept outside the <Field> label — a label must not
|
||||
* wrap other interactive controls.
|
||||
*/}
|
||||
{!policyId && !customerId && !locked && canReview && suggestions.length > 0 && (
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||
<span className="page-sub">Sugerencias por nombre:</span>
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.customerId}
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
setCustomerId(s.customerId);
|
||||
setCustomerName(s.customerName);
|
||||
}}
|
||||
>
|
||||
{s.customerName}
|
||||
{s.tier === "PARTIAL" && <span className="page-sub"> · parcial</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -390,6 +390,9 @@ function StatementLayout({ result }: { result: ReportRunResult }) {
|
||||
<div className="statement">
|
||||
<header className="statement-head">
|
||||
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
|
||||
{header.folder && header.folder !== "—" ? (
|
||||
<p className="muted">Carpeta {String(header.folder)}</p>
|
||||
) : null}
|
||||
{Boolean(header.address) && (
|
||||
<p className="muted">{String(header.address)}</p>
|
||||
)}
|
||||
|
||||
+84
-6
@@ -31,6 +31,7 @@ import type {
|
||||
UpdateBankAccountInput,
|
||||
ResolveOutstandingInput,
|
||||
ReviewDocumentInput,
|
||||
SplitPartInput,
|
||||
StatementBatch,
|
||||
StatementBatchDetail,
|
||||
StatementDocument,
|
||||
@@ -80,6 +81,11 @@ import type {
|
||||
TransactionDomain,
|
||||
TrustFilter,
|
||||
UserRow,
|
||||
CashReceipt,
|
||||
CashReceiptInput,
|
||||
CompanyHeader,
|
||||
OnboardInput,
|
||||
OnboardResult,
|
||||
} from "./types";
|
||||
|
||||
// Resolve the API origin at runtime, not build time — so one built image serves
|
||||
@@ -215,10 +221,23 @@ export function listCustomers(
|
||||
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
/** Lowest folder number no open account holds — the suggestion for a new customer. */
|
||||
export function getNextFolderNumber(): Promise<{ folderNumber: number }> {
|
||||
return apiFetch<{ folderNumber: number }>("/customers/folder/next");
|
||||
}
|
||||
|
||||
export function getCustomer(id: string): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
||||
}
|
||||
|
||||
/** Open an account in one step; see OnboardInput. */
|
||||
export function onboardCustomer(input: OnboardInput): Promise<OnboardResult> {
|
||||
return apiFetch<OnboardResult>("/customers/onboard", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>("/customers", {
|
||||
method: "POST",
|
||||
@@ -615,8 +634,13 @@ export function getBillingFacets(): Promise<BillingFacets> {
|
||||
return apiFetch<BillingFacets>("/billing/facets");
|
||||
}
|
||||
|
||||
export function getStatement(customerId: string): Promise<Statement> {
|
||||
return apiFetch<Statement>(`/billing/customers/${customerId}`);
|
||||
/** `year` omitted reads the current period; earlier years come from an archive. */
|
||||
export function getStatement(
|
||||
customerId: string,
|
||||
year?: number,
|
||||
): Promise<Statement> {
|
||||
const q = year === undefined ? "" : `?year=${year}`;
|
||||
return apiFetch<Statement>(`/billing/customers/${customerId}${q}`);
|
||||
}
|
||||
|
||||
/** Append a new ledger movement. Booked movements are never edited — fix
|
||||
@@ -1370,17 +1394,26 @@ export function listStatementDocuments(
|
||||
}
|
||||
|
||||
/** Multi-file upload — one batch is usually several multi-page scans. */
|
||||
export async function uploadStatementBatch(
|
||||
export function uploadStatementBatch(
|
||||
files: File[],
|
||||
serviceKind: ServiceKind,
|
||||
label?: string,
|
||||
): Promise<StatementBatch> {
|
||||
const body = new FormData();
|
||||
for (const f of files) body.append("files", f, f.name);
|
||||
const qs = new URLSearchParams({ serviceKind });
|
||||
if (label) qs.set("label", label);
|
||||
return postStatementFiles(`/statements/batches?${qs}`, files);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_ORIGIN}/statements/batches?${qs}`, {
|
||||
/** More scans for a batch kept open for one check. */
|
||||
export function addStatementFiles(batchId: string, files: File[]): Promise<StatementBatch> {
|
||||
return postStatementFiles(`/statements/batches/${batchId}/files`, files);
|
||||
}
|
||||
|
||||
async function postStatementFiles(path: string, files: File[]): Promise<StatementBatch> {
|
||||
const body = new FormData();
|
||||
for (const f of files) body.append("files", f, f.name);
|
||||
|
||||
const res = await fetch(`${API_ORIGIN}${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body,
|
||||
@@ -1408,6 +1441,17 @@ export function reviewStatementDocument(
|
||||
});
|
||||
}
|
||||
|
||||
/** Split a receipt across customers; an empty list undoes the split. */
|
||||
export function setStatementSplits(
|
||||
id: string,
|
||||
parts: SplitPartInput[],
|
||||
): Promise<StatementDocument> {
|
||||
return apiFetch(`/statements/documents/${id}/splits`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ parts }),
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectStatementDocument(id: string): Promise<StatementDocument> {
|
||||
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
|
||||
}
|
||||
@@ -1432,6 +1476,40 @@ export function statementPageUrl(documentId: string): string {
|
||||
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ EFE cash receipt book */
|
||||
|
||||
export function getNextCashFolio(): Promise<{ folio: number }> {
|
||||
return apiFetch("/cash-receipts/next-folio");
|
||||
}
|
||||
|
||||
export function listCashReceipts(q: { query?: string; page?: number } = {}): Promise<{
|
||||
items: CashReceipt[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (q.query) params.set("query", q.query);
|
||||
if (q.page) params.set("page", String(q.page));
|
||||
return apiFetch(`/cash-receipts?${params}`);
|
||||
}
|
||||
|
||||
export function getCashReceipt(folio: number): Promise<CashReceipt & { company: CompanyHeader }> {
|
||||
return apiFetch(`/cash-receipts/${folio}`);
|
||||
}
|
||||
|
||||
export function createCashReceipt(input: CashReceiptInput): Promise<CashReceipt> {
|
||||
return apiFetch("/cash-receipts", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export function setCashReceiptDeposit(id: string, depositedAt?: string): Promise<CashReceipt> {
|
||||
return apiFetch(`/cash-receipts/${id}/deposit`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ depositedAt }),
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
|
||||
|
||||
export function getPolicyOcrStatus(): Promise<{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PaymentFrequency } from "./types";
|
||||
|
||||
/**
|
||||
* Client-side twin of apps/api/src/policies/premium.ts. Duplicated rather than
|
||||
* shared because the API and the web app do not share a package today, and
|
||||
* both need it: the form computes IVA and Total live as the operator types,
|
||||
* the API stores what it is sent.
|
||||
*
|
||||
* base = prima neta + recargo + derecho de póliza
|
||||
* IVA = round(base * tasa)
|
||||
* Total = base + IVA
|
||||
*
|
||||
* The recargo is inside the taxable base — that is what reconciles the Access
|
||||
* books (policy 7006785 prints IVA 52.03 on 610.86 + 8.55 + 31.00; leaving the
|
||||
* recargo out gives 51.35, which matches nothing on the page).
|
||||
*/
|
||||
|
||||
/** Applied when neither the policy nor its type carries a rate. The single
|
||||
* row both legacy IMPUESTOS tables held. */
|
||||
export const DEFAULT_TAX_RATE = 0.08;
|
||||
|
||||
/** Paying in more than one exhibición is what earns a recargo. A null
|
||||
* frequency (every migrated policy) is treated as "unknown, allow it": the
|
||||
* legacy recargo figures are real and hiding the field would hide them. */
|
||||
export function surchargeApplies(
|
||||
frequency: PaymentFrequency | null | undefined,
|
||||
): boolean {
|
||||
return frequency !== "ANNUAL" && frequency !== "SINGLE";
|
||||
}
|
||||
|
||||
export function num(v: string | number | null | undefined): number {
|
||||
if (v === null || v === undefined || v === "") return 0;
|
||||
const n = typeof v === "number" ? v : Number(String(v).trim());
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** Half-up to cents, matching how the printed policy rounds. */
|
||||
export function round2(n: number): number {
|
||||
return Math.round((n + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
export interface PremiumParts {
|
||||
netPremium: string | number | null | undefined;
|
||||
surcharge: string | number | null | undefined;
|
||||
policyFee: string | number | null | undefined;
|
||||
}
|
||||
|
||||
export function taxableBase(p: PremiumParts): number {
|
||||
return round2(num(p.netPremium) + num(p.surcharge) + num(p.policyFee));
|
||||
}
|
||||
|
||||
export function computeTax(p: PremiumParts, rate: number): number {
|
||||
return round2(taxableBase(p) * rate);
|
||||
}
|
||||
|
||||
export function computeTotal(p: PremiumParts, rate: number): number {
|
||||
return round2(taxableBase(p) + computeTax(p, rate));
|
||||
}
|
||||
|
||||
/** Rate ladder: what the policy was issued at, else its line of business, else
|
||||
* the default. Keeps an old policy reading back at its original rate after
|
||||
* somebody edits the catalog. */
|
||||
export function resolveTaxRate(
|
||||
policyRate: string | number | null | undefined,
|
||||
policyTypeRate: string | number | null | undefined,
|
||||
): number {
|
||||
for (const candidate of [policyRate, policyTypeRate]) {
|
||||
if (candidate === null || candidate === undefined || candidate === "") continue;
|
||||
const n = Number(candidate);
|
||||
if (Number.isFinite(n) && n >= 0) return n;
|
||||
}
|
||||
return DEFAULT_TAX_RATE;
|
||||
}
|
||||
|
||||
/** 0.08 -> "8%". Rates are stored as fractions but read as percentages. */
|
||||
export function formatRate(rate: number): string {
|
||||
const pct = round2(rate * 100);
|
||||
return `${pct}%`;
|
||||
}
|
||||
@@ -3,6 +3,24 @@
|
||||
|
||||
export type Currency = "USD" | "MXN";
|
||||
|
||||
/** How the premium is split into payments. Anything other than ANNUAL/SINGLE
|
||||
* is what earns a recargo. Null on every migrated policy — the original ETL
|
||||
* dropped Access's FORMA PAGO column entirely. */
|
||||
export type PaymentFrequency =
|
||||
| "ANNUAL"
|
||||
| "SEMIANNUAL"
|
||||
| "QUARTERLY"
|
||||
| "MONTHLY"
|
||||
| "SINGLE";
|
||||
|
||||
export const PAYMENT_FREQUENCY_LABELS: Record<PaymentFrequency, string> = {
|
||||
ANNUAL: "Anual",
|
||||
SEMIANNUAL: "Semestral",
|
||||
QUARTERLY: "Trimestral",
|
||||
MONTHLY: "Mensual",
|
||||
SINGLE: "Contado",
|
||||
};
|
||||
|
||||
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
||||
|
||||
export type Ability =
|
||||
@@ -174,6 +192,8 @@ export interface IngestFile {
|
||||
present: boolean;
|
||||
size: number | null;
|
||||
modifiedAt: string | null;
|
||||
/** Year of a prior-period archive (`2025.accdb`); null on the four fixed sources. */
|
||||
periodYear: number | null;
|
||||
}
|
||||
|
||||
export interface BackupFile {
|
||||
@@ -200,6 +220,7 @@ export interface CustomerListItem {
|
||||
name: string;
|
||||
/** Legacy table the name was recovered from; null when it came from DATGRAL. */
|
||||
nameSource: string | null;
|
||||
folderNumber: number | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
email: string | null;
|
||||
@@ -289,6 +310,16 @@ export interface Installment {
|
||||
paidDate: string | null;
|
||||
checkNumber: string | null;
|
||||
isCash: boolean;
|
||||
/** Per-payment premium breakdown — a policy paid in several exhibiciones
|
||||
* prices each one separately. `amount` is what was actually collected and
|
||||
* can differ from `total` by rounding; it is not derived from these. */
|
||||
netPremium: string | null;
|
||||
surcharge: string | null;
|
||||
policyFee: string | null;
|
||||
tax: string | null;
|
||||
taxRate: string | null;
|
||||
total: string | null;
|
||||
commission: string | null;
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
@@ -398,10 +429,14 @@ export interface PolicyInput {
|
||||
policyTo?: string;
|
||||
coveragePeriodDays?: number;
|
||||
netPremium?: number;
|
||||
surcharge?: number;
|
||||
policyFee?: number;
|
||||
brokerFee?: number;
|
||||
commission?: number;
|
||||
tax?: number;
|
||||
taxRate?: number;
|
||||
total?: number;
|
||||
paymentFrequency?: PaymentFrequency;
|
||||
currency?: Currency;
|
||||
observations?: string;
|
||||
notes?: string;
|
||||
@@ -419,6 +454,13 @@ export interface InstallmentInput {
|
||||
paidDate?: string;
|
||||
checkNumber?: string;
|
||||
isCash?: boolean;
|
||||
netPremium?: number;
|
||||
surcharge?: number;
|
||||
policyFee?: number;
|
||||
tax?: number;
|
||||
taxRate?: number;
|
||||
total?: number;
|
||||
commission?: number;
|
||||
}
|
||||
export interface VehicleInput {
|
||||
make?: string;
|
||||
@@ -469,6 +511,10 @@ export interface PolicyTypeRow {
|
||||
id: string;
|
||||
name: string;
|
||||
shortDescription: string | null;
|
||||
/** IVA fraction for this line of business, 0.08 = 8%. Null means "not
|
||||
* configured" and the form falls back to DEFAULT_TAX_RATE — it does NOT
|
||||
* mean the line is untaxed. Serialized as a decimal string by Prisma. */
|
||||
taxRate: string | null;
|
||||
_count?: { policies: number };
|
||||
}
|
||||
export interface AdjusterRow {
|
||||
@@ -567,10 +613,14 @@ export interface PolicyDetail {
|
||||
policyTo: string | null;
|
||||
coveragePeriodDays: number | null;
|
||||
netPremium: string | null;
|
||||
surcharge: string | null;
|
||||
policyFee: string | null;
|
||||
brokerFee: string | null;
|
||||
commission: string | null;
|
||||
tax: string | null;
|
||||
taxRate: string | null;
|
||||
total: string | null;
|
||||
paymentFrequency: PaymentFrequency | null;
|
||||
currency: string | null;
|
||||
observations: string | null;
|
||||
notes: string | null;
|
||||
@@ -994,6 +1044,8 @@ export interface BillingFacets {
|
||||
|
||||
export interface StatementSummary {
|
||||
currency: LedgerCurrency;
|
||||
/** Balance carried in from before the statement year — legacy's BALANCE FORWARD. */
|
||||
opening: string;
|
||||
charges: string;
|
||||
credits: string;
|
||||
balance: string;
|
||||
@@ -1007,6 +1059,7 @@ export interface StatementSummary {
|
||||
export interface StatementDomainRow {
|
||||
domain: TransactionDomain;
|
||||
currency: LedgerCurrency;
|
||||
opening: string;
|
||||
charges: string;
|
||||
credits: string;
|
||||
balance: string;
|
||||
@@ -1042,6 +1095,15 @@ export interface Statement {
|
||||
propertyCount: number;
|
||||
policyCount: number;
|
||||
};
|
||||
/** Calendar year the statement covers; movements are scoped to it. */
|
||||
year: number;
|
||||
/**
|
||||
* Periods this customer actually has, newest first. The current year is
|
||||
* always present; each earlier year comes from an imported archive. Offering
|
||||
* anything outside this list would render an empty statement that reads as
|
||||
* "no activity" rather than "not imported".
|
||||
*/
|
||||
availableYears: number[];
|
||||
summary: StatementSummary[];
|
||||
byDomain: StatementDomainRow[];
|
||||
byType: StatementTypeRow[];
|
||||
@@ -1052,6 +1114,7 @@ export interface CustomerDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
nameSource: string | null;
|
||||
folderNumber: number | null;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
city: string | null;
|
||||
@@ -1077,12 +1140,16 @@ export interface CustomerDetail {
|
||||
properties: Property[];
|
||||
policies: Policy[];
|
||||
transactions: Transaction[];
|
||||
/** Calendar year `transactions` covers. */
|
||||
transactionYear: number;
|
||||
transactionSummary: TransactionSummaryRow[];
|
||||
}
|
||||
|
||||
/** Editable customer fields — shared by the create/edit form and the API. */
|
||||
export interface CustomerInput {
|
||||
name: string;
|
||||
/** null clears it. */
|
||||
folderNumber?: number | null;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
city?: string;
|
||||
@@ -1348,6 +1415,8 @@ export interface StatementBatchDetail extends StatementBatch {
|
||||
byStatus: Partial<Record<StatementDocumentStatus, number>>;
|
||||
/** Sum of the amounts still awaiting posting. */
|
||||
pendingTotal: string;
|
||||
/** Check the batch was last posted against, to prefill the next confirm. */
|
||||
lastCheckNumber: string | null;
|
||||
}
|
||||
|
||||
export interface StatementDocument {
|
||||
@@ -1371,6 +1440,22 @@ export interface StatementDocument {
|
||||
property: { id: string; addressLine1: string | null };
|
||||
} | null;
|
||||
postedTransactionId: string | null;
|
||||
/** Sin fondos for this receipt alone. */
|
||||
outstanding: boolean;
|
||||
/** Empty unless the receipt is shared by several customers. */
|
||||
splits: StatementSplit[];
|
||||
}
|
||||
|
||||
export interface StatementSplit {
|
||||
id: string;
|
||||
sequence: number;
|
||||
amount: string;
|
||||
customer: { id: string; name: string };
|
||||
}
|
||||
|
||||
export interface SplitPartInput {
|
||||
customerId: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ReviewDocumentInput {
|
||||
@@ -1381,6 +1466,7 @@ export interface ReviewDocumentInput {
|
||||
matchedPropertyServiceId?: string;
|
||||
matchedCustomerId?: string;
|
||||
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
|
||||
outstanding?: boolean;
|
||||
}
|
||||
|
||||
/** Check-level fields shared by every line posted from a batch. */
|
||||
@@ -1396,6 +1482,8 @@ export interface ConfirmBatchInput {
|
||||
export interface ConfirmBatchResult {
|
||||
posted: number;
|
||||
total: string;
|
||||
/** How many of the posted rows went in sin fondos. */
|
||||
outstanding: number;
|
||||
checkNumber: string;
|
||||
}
|
||||
|
||||
@@ -1477,6 +1565,18 @@ export interface PolicyOcrMatchCandidate {
|
||||
policyNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer whose name resembles the printed insured name. A suggestion,
|
||||
* not a match — the API never preselects one.
|
||||
*/
|
||||
export interface PolicyOcrCustomerSuggestion {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
/** `EXACT` = same name tokens in any order; `PARTIAL` = one contains the other. */
|
||||
tier: "EXACT" | "PARTIAL";
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface PolicyOcrDocument {
|
||||
id: string;
|
||||
pageNumber: number;
|
||||
@@ -1496,6 +1596,10 @@ export interface PolicyOcrDocument {
|
||||
extractedNetPremium: string | null;
|
||||
extractedPolicyFee: string | null;
|
||||
extractedBrokerFee: string | null;
|
||||
/** IVA off A.N.A.'s `TAX` cell. Null on GMX — its certificate carries no
|
||||
* premium, so no tax either. `LOCAL TAX` is not folded in; a non-zero one
|
||||
* shows up in `matchNote`. */
|
||||
extractedTax: string | null;
|
||||
extractedTotal: string | null;
|
||||
extractedCoveragesJson: PolicyOcrCoverage[] | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
@@ -1512,6 +1616,7 @@ export interface PolicyOcrDocument {
|
||||
} | null;
|
||||
matchedCustomer: { id: string; name: string } | null;
|
||||
matchCandidates: PolicyOcrMatchCandidate[] | null;
|
||||
customerSuggestions: PolicyOcrCustomerSuggestion[] | null;
|
||||
matchNote: string | null;
|
||||
}
|
||||
|
||||
@@ -1529,6 +1634,7 @@ export interface PolicyOcrReviewInput {
|
||||
netPremium?: number;
|
||||
policyFee?: number;
|
||||
brokerFee?: number;
|
||||
tax?: number;
|
||||
total?: number;
|
||||
premiumPayment?: string;
|
||||
coveragePeriodDays?: number;
|
||||
@@ -1555,6 +1661,7 @@ export interface PolicyOcrConfirmDocument {
|
||||
netPremium?: number;
|
||||
policyFee?: number;
|
||||
brokerFee?: number;
|
||||
tax?: number;
|
||||
total?: number;
|
||||
premiumPayment?: string;
|
||||
coveragePeriodDays?: number;
|
||||
@@ -1574,3 +1681,68 @@ export interface PolicyOcrConfirmResult {
|
||||
policies: string[];
|
||||
postedTransactions: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ EFE cash receipt book */
|
||||
|
||||
export interface CashReceipt {
|
||||
id: string;
|
||||
folio: number;
|
||||
receivedAt: string;
|
||||
amount: string;
|
||||
currency: Currency;
|
||||
exchangeRate: string | null;
|
||||
concept: string;
|
||||
depositedAt: string | null;
|
||||
createdAt: string;
|
||||
customer: { id: string; name: string; folderNumber: number | null };
|
||||
preparedBy: { name: string };
|
||||
/** The C<folio> credit. Voiding it voids the receipt. */
|
||||
transaction: { id: string; voidedAt: string | null; amount: string; currency: Currency };
|
||||
}
|
||||
|
||||
/** Office details the printed receipt is headed with. */
|
||||
export interface CompanyHeader {
|
||||
name: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
cityState: string;
|
||||
mailingAddress: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
taxId: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
export interface CashReceiptInput {
|
||||
customerId: string;
|
||||
folio?: number;
|
||||
receivedAt: string;
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
exchangeRate?: number;
|
||||
concept: string;
|
||||
depositedAt?: string;
|
||||
}
|
||||
|
||||
/** One-screen account opening: cliente + propiedad + servicios + saldo inicial. */
|
||||
export interface OnboardInput {
|
||||
customer: CustomerInput;
|
||||
property?: { addressLine1?: string; addressLine2?: string; phone1?: string };
|
||||
services?: { kind: ServiceKind; accountNumber?: string; meterNumber?: string }[];
|
||||
openingPayment?: {
|
||||
method: "CASH" | "CHECK";
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
receivedAt: string;
|
||||
checkNumber?: string;
|
||||
exchangeRate?: number;
|
||||
concept?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OnboardResult {
|
||||
customer: { id: string; name: string; folderNumber: number | null };
|
||||
property: { id: string } | null;
|
||||
receipt: { folio: number } | null;
|
||||
payment: { id: string } | null;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Delete unused images from the target host after a successful deploy.
|
||||
*
|
||||
* This exists because nothing else reclaims them. Every build.yml run pushes a
|
||||
* new api + web image, every deploy pulls both onto the host, and the previous
|
||||
* pair is left behind untagged-but-present forever. On galactus that reached
|
||||
* 63 images / 83.85GB (79.26GB of it unused) and filled the 98GB root
|
||||
* filesystem to 100% on 2026-08-20 — which surfaced as "re-import is broken",
|
||||
* because the Operaciones REIMPORT job leads with a mysqldump that could no
|
||||
* longer write its safety backup.
|
||||
*
|
||||
* Two things keep this from eating a live deployment:
|
||||
*
|
||||
* - Docker never prunes an image that a container references, running or
|
||||
* stopped. The five images the prod stacks use are therefore untouchable
|
||||
* for as long as their containers exist.
|
||||
* - `until` gives a grace window on top of that, so a rollback target stays
|
||||
* on disk instead of forcing a re-pull from the registry.
|
||||
*
|
||||
* TRAP: `until` filters on the image's CREATION time, not when the host pulled
|
||||
* it. Rolling back to an old tag pulls an image that is already older than the
|
||||
* window, so the grace period does NOT protect it — the running-container rule
|
||||
* is what does. That is why this step must run AFTER the app stack is deployed
|
||||
* and verified, never before.
|
||||
*
|
||||
* Required env:
|
||||
* PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
|
||||
* Optional:
|
||||
* KEEP_HOURS grace window in hours (default 168 = 7 days)
|
||||
*
|
||||
* TLS: Portainer here is self-signed; the caller sets
|
||||
* NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
|
||||
*/
|
||||
|
||||
function required(name) {
|
||||
const v = process.env[name];
|
||||
if (!v) {
|
||||
console.error(`missing required env: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
|
||||
const API_KEY = required("PORTAINER_API_KEY");
|
||||
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
|
||||
const KEEP_HOURS = process.env.KEEP_HOURS || "168";
|
||||
|
||||
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
|
||||
|
||||
// `dangling: ["false"]` is what makes this `docker image prune -a` rather than
|
||||
// the default, which only collects untagged layers. The tagged-but-superseded
|
||||
// api/web images are the whole problem, and the default filter walks straight
|
||||
// past them.
|
||||
const FILTERS = JSON.stringify({
|
||||
dangling: ["false"],
|
||||
until: [`${KEEP_HOURS}h`],
|
||||
});
|
||||
|
||||
function human(bytes) {
|
||||
if (!bytes) return "0B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let n = bytes;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${n.toFixed(i === 0 ? 0 : 2)}${units[i]}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = `${DOCKER}/images/prune?filters=${encodeURIComponent(FILTERS)}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY },
|
||||
});
|
||||
const body = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`prune -> HTTP ${res.status} ${body.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
let report;
|
||||
try {
|
||||
report = JSON.parse(body);
|
||||
} catch {
|
||||
throw new Error(`prune returned non-JSON: ${body.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const deleted = report.ImagesDeleted ?? [];
|
||||
const reclaimed = report.SpaceReclaimed ?? 0;
|
||||
console.log(
|
||||
`pruned images older than ${KEEP_HOURS}h and unused by any container`,
|
||||
);
|
||||
console.log(` entries removed : ${deleted.length}`);
|
||||
console.log(` space reclaimed : ${human(reclaimed)}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Non-fatal by contract: the step that calls this sets continue-on-error, so
|
||||
// housekeeping never turns a good deploy red. Exit non-zero anyway so the
|
||||
// failure is visible in the run rather than swallowed.
|
||||
console.error(`::warning::image prune FAILED: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
+26
-1
@@ -148,7 +148,9 @@ carries tables nothing writes teaches the next reader the wrong thing.
|
||||
| 3.1 | **Liquidación batch workflow** | ~70% of the fields already wired end to end. **226 policies pending.** Needs the ramo-parameterized pending report + `POST /policies/liquidate-batch` under a new `policy:liquidate` (MANAGER). Smallest remaining piece of step 12 | INSURANCE §2 |
|
||||
| 3.2 | **Certificate rendering** | The report half is buildable now off the same `format: "letter"` machinery as `aviso-renovacion`. Portal delivery waits on steps 8/9. Whole section waits on 1.2 | INSURANCE §3 |
|
||||
| 3.3 | **Carrier API integration** | Blocked on 1.3. ANA's SOAP service is real with a known operation list; GMX publishes nothing machine-readable and writes the larger half of this book | INSURANCE §4 |
|
||||
| 3.4 | **Customer-number recycling** | Not started. `Customer.customerNumber` **does not exist in the schema**. Backfill needs care: ~140 utilities rows and all insurance-only customers carry synthetic `rownum_N`/`insrow_N` placeholders, not real `NUM id`s. Last unbuilt piece of step 11 | RECEIPT §4 |
|
||||
| 3.4 | ~~**Customer-number recycling**~~ | **BUILT 2026-10-12** (`b016e53`) as `customers.folderNumber` ("Carpeta"): a label, not a key, unique among non-archived customers, lowest free suggested. Backfilled from the utilities NUMid; insurance-only customers start blank | RECEIPT §4 |
|
||||
| 3.5 | **Portal login by email for every customer** | Not started (deferred 2026-10-12). Every customer with an email gets access automatically: email → verification code → session. Token carries the email and resolves the customer set per request, so it survives the cutover REIMPORT. 30 emails are shared by 68 customers → account picker after verify. 107 active customers have no email → no access until the office adds one. Unlocks the 284 customers with no NUMid. In `my-jorgecuadros-web`: `verification_codes` is keyed `(email, numid)`, ~68 files authorize on `$tokenData['numid']` — route them through one helper | meeting 2026-10-08 |
|
||||
| 3.6 | **Remove NUMid + password portal login** | Not started (deferred 2026-10-12, lands with or after 3.5). Delete `login.php`'s NUMid/password path and the legacy `PASSWD` reads; the email code becomes the only way in. Assumes no store-installed mobile app still calls `login.php` | meeting 2026-10-08 |
|
||||
|
||||
Note 3.3 partly overlaps what [`POLICY_OCR.md`](POLICY_OCR.md) already does —
|
||||
an OCR path that turns a carrier PDF into a `Policy` row covers some of what
|
||||
@@ -179,6 +181,29 @@ Each of these is a known, deliberate stopping point rather than a bug.
|
||||
be added.
|
||||
- No `SKIPPED_NO_EMAIL` worklist (see 1.9).
|
||||
|
||||
**Captura de pólizas — desglose de primas** (built 2026-08-18)
|
||||
- **IVA y prima total no existen en los datos legacy.** En Access eran
|
||||
controles calculados sin campo, así que las 2,378 pólizas migradas leen
|
||||
`tax` y `total` en null hasta que alguien las edite. No es recuperable: no
|
||||
hay de dónde.
|
||||
- **`LOCAL TAX` de A.N.A. no se captura.** El IVA (`TAX`) sí — se guarda desde
|
||||
2026-08-18 — pero `LOCAL TAX` es un gravamen distinto sin columna destino y
|
||||
**no** se suma al IVA: sumarlo daría una cifra que ya no divide de vuelta a
|
||||
una tasa. Imprime 0.00 en todas las pólizas vistas hasta hoy; una distinta
|
||||
de cero levanta la nota *"impuesto local N no capturado"* y significa que
|
||||
`total` no cuadra contra `netPremium + policyFee + tax`.
|
||||
- **`Policy.taxRate` queda en null por la ruta OCR.** A.N.A. imprime el monto
|
||||
del IVA, no la tasa, y despejarla a la inversa inventaría una tasa que el
|
||||
documento nunca declaró. El formulario resuelve una desde el ramo.
|
||||
- **El recargo no se valida contra la forma de pago en datos migrados.** El
|
||||
formulario lo deshabilita en ANUAL/CONTADO, pero
|
||||
`backfill_policy_premium_breakdown.py` solo advierte cuando encuentra una
|
||||
póliza anual con recargo; no la corrige.
|
||||
- **Las parcialidades 3 y 4 no llevan desglose.** Access solo dibujó la fila
|
||||
de dinero dos veces, así que una póliza trimestral capturada hoy sí puede
|
||||
llenar las cuatro a mano, pero no hay nada legacy que migrar a las dos
|
||||
últimas.
|
||||
|
||||
**Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md)
|
||||
- **GMX and A.N.A. only.** The dispatcher is a `[provider, pattern]` table plus
|
||||
a parser map, so a third carrier is one function and two entries — but no
|
||||
|
||||
@@ -440,6 +440,12 @@ take a `policyType` select param. The workflow is *not* MULT-only: the legacy
|
||||
|
||||
Params: ramo (with an "todos" option), aseguradora, date range on `policyFrom`.
|
||||
Columns: póliza, cliente, ramo, aseguradora, vigencia, prima neta, forma de pago.
|
||||
|
||||
ℹ️ `forma de pago` became a real column on 2026-08-18 (`Policy.paymentFrequency`).
|
||||
It is **null on every policy migrated before that date** — the original ETL
|
||||
marked Access's `FORMA PAGO` consumed and then never wrote it anywhere — so the
|
||||
report must render null as "—" rather than assuming annual. Running
|
||||
`backfill_policy_premium_breakdown.py` recovers it from the staged Parquet.
|
||||
Totals: count + prima neta sum per currency (**never collapse MXN and USD** —
|
||||
same constraint as the billing module).
|
||||
|
||||
|
||||
@@ -134,6 +134,55 @@ customers do occur (one group policy bound by two related parties), and
|
||||
picking arbitrarily would silently book the wrong coverage against the wrong
|
||||
person.
|
||||
|
||||
### Name suggestions on the zero-hit path
|
||||
|
||||
When the policy number finds nothing — the new-policy case, where a human has
|
||||
to pick a customer anyway — `name-matcher.ts` ranks the customer book against
|
||||
the printed insured name and the review screen offers the top three as
|
||||
one-click buttons above the picker. They are written to
|
||||
`policy_ocr_documents.customerSuggestions`, deliberately **not** to
|
||||
`matchCandidates`, so a name hint can never be read as a policy-number hit.
|
||||
Nothing sets `matchedCustomerId`; the rule above is unchanged.
|
||||
|
||||
The problem is only ordering: the office books customers surname-first
|
||||
(`WAGONER, PAMELA`) and carriers print them given-name-first
|
||||
(`PAMELA DENISE WAGONER`), so a string compare never hits while a **token-set**
|
||||
compare does. Names are normalized (accents folded, so OCR's `MUNOZ` reaches
|
||||
the book's `MUÑOZ`; initials, `DE`/`LA`/`Y`, `JR`, `S.A. DE C.V.` and any token
|
||||
containing a digit dropped — ANA prints the phone hard against the name as
|
||||
`Ph.3102001538`). Two tiers:
|
||||
|
||||
| Tier | Rule |
|
||||
|---|---|
|
||||
| `EXACT` | identical token sets, any order |
|
||||
| `PARTIAL` | one set contains the other, ≥2 shared tokens, **and** the surname is present |
|
||||
|
||||
Both thresholds come from measuring the real book (1536 customers):
|
||||
|
||||
- 1487 distinct token sets, so `EXACT` cross-person collisions are ~0
|
||||
- loosen to surname + first given name and 131 customers (8.5%) collide —
|
||||
the book holds `MCWILLIAMS, BRIAN MICHAEL` *and* `MCWILLIAMS, BRIAN`
|
||||
- 185 surnames are shared by 524 customers, so one token is never evidence;
|
||||
hence the ≥2 floor and the explicit surname requirement, which is what stops
|
||||
`JERRY MARILYN` reaching `ESTRADA, JERRY & MARILYN` on given names alone
|
||||
|
||||
Replaying every book row as a carrier would print it (given-name-first, joint
|
||||
spouse dropped): 97.9% top-ranked correct, 0.9% no suggestion, 1.2% a
|
||||
different row — and all but two of those are the same human on a duplicate or
|
||||
variant row (`MOLNAR, JANOS` vs `MOLNAR, JANOS`, `IBARRA, ISMAEL &`). The
|
||||
two genuine wrong-person cases are `CUADROS, JORGE JR` against three
|
||||
`CUADROS, JORGE H.`, and they appear as a tie in the list rather than as a
|
||||
single answer.
|
||||
|
||||
A blob is refused outright (>8 tokens or >80 characters): GMX's especificación
|
||||
has no field labels and the parser has been seen handing its whole first page
|
||||
over as `insuredName`, which would find a surname somewhere in the prose.
|
||||
`(SIN NOMBRE)` — 14 rows the migration left — is skipped on both sides.
|
||||
|
||||
**Not used for utility statements.** There the registrant genuinely is not the
|
||||
customer (the `CATT, RANDY` finding above), so the same trick would be wrong,
|
||||
not merely noisy.
|
||||
|
||||
## GMX ships two unrelated documents for the same policy
|
||||
|
||||
The office downloads both from the same portal, and either can land in a
|
||||
@@ -316,6 +365,27 @@ A.N.A.'s faces do print one — the `DISCOUNT / PREMIUM / POLICY FEE / TAX /
|
||||
LOCAL TAX / TOTAL` row is on the same page — so an ANA document reaches the
|
||||
review queue with `netPremium` populated and `postPremium` already ticked.
|
||||
|
||||
Four of those six cells are stored: `PREMIUM` → `netPremium`, `POLICY FEE` →
|
||||
`policyFee`, `TAX` → `tax` (`extractedTax` on the document, `Policy.tax` on
|
||||
confirm), `TOTAL` → `total`. `DISCOUNT` and `LOCAL TAX` are reported as notes
|
||||
instead:
|
||||
|
||||
- **`DISCOUNT`** has no column, and it prints as a bare `-` when unused, which
|
||||
is what makes the row positional rather than "find six amounts".
|
||||
- **`LOCAL TAX`** is a separate levy and is deliberately **not** summed into
|
||||
`tax`. Folding it in would produce an IVA figure that no longer divides back
|
||||
to a rate, which is the reason to store it at all. It reads 0.00 on every
|
||||
A.N.A. policy seen so far; a non-zero one raises
|
||||
*"impuesto local N no capturado"* and means `total` will not reconcile
|
||||
against `netPremium + policyFee + tax`.
|
||||
|
||||
`Policy.taxRate` is left null by confirm. A.N.A. prints the IVA **amount**, not
|
||||
the rate, and back-dividing one would mint a rate the document never stated —
|
||||
the capture form resolves it from the line of business instead
|
||||
(`PolicyType.taxRate`, see `apps/api/src/policies/premium.ts`). The figures do
|
||||
agree: 298.61 + 30.00 taxed at 8% is 26.29, totalling 354.90, asserted in
|
||||
`policy-parser.spec.ts`.
|
||||
|
||||
Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
|
||||
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
|
||||
and free text, and normalising them would lose the distinction.
|
||||
@@ -457,6 +527,17 @@ term, the excluded sections, the column-position split on the driver's policy,
|
||||
its different section order, and — for both faces — that a doubled or tripled
|
||||
input yields one set of coverages and one driver rather than one per copy.
|
||||
|
||||
`apps/api/src/policy-ocr/name-matcher.spec.ts` — 21 cases on the customer name
|
||||
suggestions, every fixture name lifted from the real book: the reversed name,
|
||||
the printed middle name, the exact row outranking the row that merely contains
|
||||
it, the joint account reached from one spouse (and refused when only given
|
||||
names are printed), the Spanish double surname with the comma in either place,
|
||||
the 54 rows with no comma at all, the `(SIN NOMBRE)` placeholder, a bare shared
|
||||
surname, and the page-sized blob. Four more in
|
||||
`policy-matcher.service.spec.ts` pin the wiring: suggestions on the zero-hit
|
||||
and unreadable-number paths, no book read at all when the policy number hits,
|
||||
and one book read across a batch.
|
||||
|
||||
Four of the GMX cases are regression tests for ways the parser can silently attach
|
||||
the *wrong* value rather than none — a neighbouring coverage's prose read as
|
||||
a deductible, the page-level `DEDUCIBLES:` paragraph read as one, a coverage
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Recovers the premium breakdown the original policy transform dropped.
|
||||
|
||||
`transform_policies.py` modeled prima neta, derecho de póliza and comisión and
|
||||
nothing else, which lost two things from every migrated policy:
|
||||
|
||||
1. RECARGO — the financing surcharge on a policy paid in more than one
|
||||
exhibición, plus the whole second money row (`p_neta_2`, `recargo_2`,
|
||||
`d_pol_2`, `com_2`) that a semestral policy carries because each payment
|
||||
is priced separately. These were not deleted, they were swept into
|
||||
`policies.coveragesJson` as loose strings alongside the real coverages —
|
||||
unqueryable, and mislabeled as coverage amounts.
|
||||
|
||||
2. FORMA PAGO — dropped outright. The column was marked "consumed" by the
|
||||
transform's coverage sweep but never written to any column, so it exists
|
||||
nowhere in the platform database. It is the field that decides whether a
|
||||
recargo is legitimate on a row at all, so it cannot be inferred back from
|
||||
the money.
|
||||
|
||||
The transform has been fixed in the same commit, so a full `run_all.py` now
|
||||
produces all of this directly. This script exists for a database that must not
|
||||
be re-imported: it reads the same staged Parquet and patches in place.
|
||||
|
||||
What it does NOT do: invent IVA or the printed TOTAL. Those were never columns
|
||||
in the home tables — they were unbound calculated controls on the Access form —
|
||||
so there is genuinely nothing to recover, and both stay null until a human
|
||||
edits the policy. The app computes them (apps/api/src/policies/premium.ts).
|
||||
|
||||
Idempotent, and never overwrites a non-null value: a figure a human has since
|
||||
corrected in the app wins over the legacy one.
|
||||
|
||||
./.venv/bin/python backfill_policy_premium_breakdown.py --env dev
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from dbenv import connect
|
||||
from sync import parse_mode
|
||||
|
||||
STG = Path(__file__).parent / "output" / "stg_seguros"
|
||||
LEGACY_DB = "SEGUROS 16_be"
|
||||
NULL = "∅"
|
||||
|
||||
# Legacy column -> what it is, per source table. Only the tables that actually
|
||||
# carry a breakdown appear; the auto tables have no recargo and no second row.
|
||||
HOME_TABLES = ("mult", "incendio", "m_empr")
|
||||
|
||||
# Keys the transform used to dump into coveragesJson that are now real columns.
|
||||
# Stripped once migrated so the blob stops pretending they are coverages.
|
||||
MIGRATED_COVERAGE_KEYS = (
|
||||
"recargo", "recargo_2", "p_neta_2", "d_pol_2", "com_2",
|
||||
)
|
||||
|
||||
_FREQ = {
|
||||
"ANNUAL": "ANNUAL",
|
||||
"ANUAL": "ANNUAL",
|
||||
"SEMESTRAL": "SEMIANNUAL",
|
||||
"TRIMESTRAL": "QUARTERLY",
|
||||
"MENSUAL": "MONTHLY",
|
||||
"CONTADO": "SINGLE",
|
||||
}
|
||||
|
||||
|
||||
def s(v):
|
||||
if v is None or pd.isna(v):
|
||||
return None
|
||||
v = str(v).strip()
|
||||
return None if v in ("", NULL) else v
|
||||
|
||||
|
||||
def dec(v):
|
||||
v = s(v)
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(v.replace(",", ""))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def freq(v):
|
||||
return _FREQ.get((s(v) or "").upper())
|
||||
|
||||
|
||||
def load(name):
|
||||
df = pd.read_parquet(STG / f"{name}.parquet").sort_values("_row_num").reset_index(drop=True)
|
||||
for c in df.columns:
|
||||
if c != "_row_num":
|
||||
df[c] = df[c].astype("string").str.strip()
|
||||
return df
|
||||
|
||||
|
||||
def main():
|
||||
env, _sync_mode = parse_mode()
|
||||
|
||||
# Fails closed rather than reporting a clean run over nothing: an empty
|
||||
# staging directory and a policy set with no recargo look identical from
|
||||
# the database side, and "0 rows updated" would read as success.
|
||||
if not STG.exists():
|
||||
print(f"[policy-premium] staged Parquet missing at {STG} — run extract/load first.")
|
||||
return 3
|
||||
|
||||
conn = connect(env)
|
||||
c = conn.cursor()
|
||||
print(f"[policy-premium] target env: {env}")
|
||||
|
||||
# Every policy that came from the insurance ETL, keyed by provenance. The
|
||||
# id is needed to reach the installments, coveragesJson to strip the keys.
|
||||
c.execute(
|
||||
"SELECT legacySourceTable, legacyId, id, coveragesJson "
|
||||
"FROM policies WHERE legacySourceDb = %s AND legacyId IS NOT NULL",
|
||||
(LEGACY_DB,),
|
||||
)
|
||||
by_key = {(t, lid): (pid, cov) for t, lid, pid, cov in c.fetchall()}
|
||||
print(f" {len(by_key)} migrated polic(ies) in the target database")
|
||||
|
||||
pol_updates = [] # (surcharge, paymentFrequency, coveragesJson, policyId)
|
||||
inst_updates = [] # (netPremium, surcharge, policyFee, commission, policyId, seq)
|
||||
seen_tables = 0
|
||||
|
||||
for table in HOME_TABLES + (
|
||||
"tabla_autos", "tabla_autos_ampl", "tabla_autos_limit",
|
||||
"tabla_autos_ampl_r", "tabla_autos_rc_r", "mca2", "licencias",
|
||||
):
|
||||
path = STG / f"{table}.parquet"
|
||||
if not path.exists():
|
||||
continue
|
||||
seen_tables += 1
|
||||
df = load(table)
|
||||
home = table in HOME_TABLES
|
||||
|
||||
for _, row in df.iterrows():
|
||||
key = (table, str(int(row["_row_num"])))
|
||||
hit = by_key.get(key)
|
||||
if not hit:
|
||||
continue
|
||||
pid, cov_raw = hit
|
||||
|
||||
surcharge = dec(row.get("recargo")) if home else None
|
||||
frequency = freq(row.get("forma_pago"))
|
||||
|
||||
# Strip the now-modeled keys out of the coverage blob. Rewritten
|
||||
# only when something actually changes, so a policy whose blob a
|
||||
# human has edited is left byte-identical.
|
||||
cov_new = None
|
||||
if cov_raw:
|
||||
try:
|
||||
cov = json.loads(cov_raw) if isinstance(cov_raw, str) else cov_raw
|
||||
except (TypeError, ValueError):
|
||||
cov = None
|
||||
if isinstance(cov, dict):
|
||||
kept = {k: v for k, v in cov.items() if k not in MIGRATED_COVERAGE_KEYS}
|
||||
if len(kept) != len(cov):
|
||||
cov_new = json.dumps(kept, ensure_ascii=False) if kept else None
|
||||
|
||||
if surcharge is not None or frequency is not None or cov_new is not None:
|
||||
pol_updates.append((surcharge, frequency, cov_new, cov_new is not None, pid))
|
||||
|
||||
# Per-payment breakdown. Slot 1 is the unsuffixed money row, slot 2
|
||||
# the _2 twin; the auto tables have a single slot and no recargo.
|
||||
if home:
|
||||
slots = [
|
||||
(1, "p_neta", "recargo", "d_pol", "com"),
|
||||
(2, "p_neta_2", "recargo_2", "d_pol_2", "com_2"),
|
||||
]
|
||||
else:
|
||||
pn = "prima1" if table == "mca2" else "prima_neta"
|
||||
dp = "d_poliza1" if table == "mca2" else "d_poliza"
|
||||
slots = [(1, pn, None, dp, None)]
|
||||
|
||||
for seq, pn, rc, dp, cm in slots:
|
||||
vals = (
|
||||
dec(row.get(pn)) if pn else None,
|
||||
dec(row.get(rc)) if rc else None,
|
||||
dec(row.get(dp)) if dp else None,
|
||||
dec(row.get(cm)) if cm else None,
|
||||
)
|
||||
if all(v is None for v in vals):
|
||||
continue
|
||||
inst_updates.append((*vals, pid, seq))
|
||||
|
||||
if not seen_tables:
|
||||
print(f"[policy-premium] no policy tables staged under {STG} — nothing to do.")
|
||||
return 3
|
||||
|
||||
# COALESCE on every target: a column a human has already filled in the app
|
||||
# keeps its value, the legacy figure only lands where there is a hole.
|
||||
for surcharge, frequency, cov_new, rewrite_cov, pid in pol_updates:
|
||||
c.execute(
|
||||
"UPDATE policies SET "
|
||||
" surcharge = COALESCE(surcharge, %s), "
|
||||
" paymentFrequency = COALESCE(paymentFrequency, %s), "
|
||||
" coveragesJson = IF(%s, %s, coveragesJson) "
|
||||
"WHERE id = %s",
|
||||
(surcharge, frequency, 1 if rewrite_cov else 0, cov_new, pid),
|
||||
)
|
||||
|
||||
for netp, surch, fee, comm, pid, seq in inst_updates:
|
||||
c.execute(
|
||||
"UPDATE policy_payment_installments SET "
|
||||
" netPremium = COALESCE(netPremium, %s), "
|
||||
" surcharge = COALESCE(surcharge, %s), "
|
||||
" policyFee = COALESCE(policyFee, %s), "
|
||||
" commission = COALESCE(commission, %s) "
|
||||
"WHERE policyId = %s AND sequence = %s",
|
||||
(netp, surch, fee, comm, pid, seq),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
print(f" policies : {len(pol_updates)} row(s) touched")
|
||||
print(f" installments: {len(inst_updates)} row(s) touched")
|
||||
|
||||
# --- validation ---------------------------------------------------------
|
||||
c.execute("SELECT COUNT(*) FROM policies WHERE surcharge IS NOT NULL AND surcharge <> 0")
|
||||
n_surch = c.fetchone()[0]
|
||||
c.execute("SELECT COUNT(*) FROM policies WHERE paymentFrequency IS NOT NULL")
|
||||
n_freq = c.fetchone()[0]
|
||||
c.execute(
|
||||
"SELECT COUNT(*) FROM policies "
|
||||
"WHERE paymentFrequency IN ('ANNUAL','SINGLE') AND surcharge IS NOT NULL AND surcharge <> 0"
|
||||
)
|
||||
n_bad = c.fetchone()[0]
|
||||
c.execute(
|
||||
"SELECT COUNT(*) FROM policies WHERE coveragesJson IS NOT NULL "
|
||||
"AND JSON_EXTRACT(coveragesJson, '$.recargo') IS NOT NULL"
|
||||
)
|
||||
n_left = c.fetchone()[0]
|
||||
|
||||
print(f" -> policies with a recargo : {n_surch}")
|
||||
print(f" -> policies with a forma pago : {n_freq}")
|
||||
print(f" -> recargo still in coverages : {n_left}")
|
||||
|
||||
# A surcharge on an annual policy contradicts the rule the capture form
|
||||
# enforces, so it is worth surfacing rather than leaving for someone to
|
||||
# find in a total. It is a warning, not a failure: the books are the books.
|
||||
if n_bad:
|
||||
print(f" !! {n_bad} annual/contado polic(ies) carry a non-zero recargo — review by hand")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main() or 0)
|
||||
@@ -18,6 +18,7 @@ the four Access source files.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# The folder holding the four Access source files. Overridable via INGEST_DIR so
|
||||
@@ -95,3 +96,59 @@ SOURCES = {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --- prior-period archives ----------------------------------------------
|
||||
#
|
||||
# Legacy ran a year-end *corte*: it summed the closing year, wrote that total
|
||||
# back as each customer's Jan-1 BALANCE FORWARD, and started the next year
|
||||
# clean. Access keeps the closed year as a whole-database snapshot named for
|
||||
# the period it holds — `2025.accdb` is UTILITIES as it stood when 2025 was
|
||||
# cut — and the office archives one per year.
|
||||
#
|
||||
# Only the ledger is staged out of a snapshot. Everything else in it (DATMEX,
|
||||
# PROFILE, EFECTIVO, ...) is a year-stale copy of a table the live
|
||||
# UTILITIES.accdb already provides, and staging all ~50 of them would triple
|
||||
# the extract time to import data we would then have to ignore. DATGRAL comes
|
||||
# along solely to check that a NUMid still means the same customer it did that
|
||||
# year; see the recycle guard in transform_transactions.py.
|
||||
#
|
||||
# The cash side is deliberately NOT taken from the snapshot: `EFECTIVO` is a
|
||||
# lifetime journal, so the snapshot's copy is a subset of the live one and
|
||||
# importing it would double-book every prior-year receipt.
|
||||
PERIOD_FILE_RE = re.compile(r"^(\d{4})\.accdb$", re.IGNORECASE)
|
||||
PERIOD_TABLES = {"datos2", "DATGRAL"}
|
||||
|
||||
|
||||
def period_schema(year: int) -> str:
|
||||
return f"stg_period_{year}"
|
||||
|
||||
|
||||
def discover_periods(root: Path) -> dict[str, dict]:
|
||||
"""Find every `YYYY.accdb` archive sitting in the ingest folder.
|
||||
|
||||
Discovery is by filename because that is the whole upload contract: the
|
||||
operator drops `2025.accdb` on the Operaciones page and the period is 2025.
|
||||
Nothing inside the file names the year — a snapshot's `datos2` looks
|
||||
identical to the live one — so the name is the only declaration of intent
|
||||
we get, and it is what the allowlist on the upload endpoint enforces.
|
||||
"""
|
||||
found: dict[str, dict] = {}
|
||||
if not root.is_dir():
|
||||
return found
|
||||
for path in sorted(root.iterdir()):
|
||||
m = PERIOD_FILE_RE.match(path.name)
|
||||
if not m:
|
||||
continue
|
||||
year = int(m.group(1))
|
||||
found[f"period_{year}"] = {
|
||||
"path": path,
|
||||
"schema": period_schema(year),
|
||||
"exclude": set(),
|
||||
"include": set(PERIOD_TABLES),
|
||||
"period_year": year,
|
||||
}
|
||||
return found
|
||||
|
||||
|
||||
SOURCES.update(discover_periods(SOURCE_ROOT))
|
||||
@@ -40,6 +40,17 @@ def stage_source(source_name: str, source_cfg: dict, sink) -> None:
|
||||
tables = extract.list_tables(cnxn)
|
||||
excluded = source_cfg["exclude"]
|
||||
|
||||
# A source may name the only tables it is worth staging. Prior-period
|
||||
# archives do: they are whole-database snapshots, but everything in them
|
||||
# except the ledger is a year-stale copy of a live table, so staging the
|
||||
# rest costs minutes per file to produce data nothing reads.
|
||||
include = source_cfg.get("include")
|
||||
if include is not None:
|
||||
missing = include - set(tables)
|
||||
if missing:
|
||||
print(f" [WARN] {source_name}: missing expected table(s) {sorted(missing)}", file=sys.stderr)
|
||||
tables = [t for t in tables if t in include]
|
||||
|
||||
for table_name in tables:
|
||||
if table_name in excluded:
|
||||
print(f" [exclude] {table_name}")
|
||||
|
||||
@@ -59,6 +59,12 @@ STEPS = [
|
||||
# touches.
|
||||
"backfill_statement_match_fields.py",
|
||||
"transform_policies.py",
|
||||
# Premium breakdown (recargo, per-payment figures, forma de pago).
|
||||
# transform_policies.py now writes these directly, so on a full rebuild
|
||||
# this is a no-op that re-asserts them; on a database migrated before the
|
||||
# breakdown existed it is what recovers them out of coveragesJson.
|
||||
# Must follow transform_policies.py, which truncates the installments.
|
||||
"backfill_policy_premium_breakdown.py",
|
||||
"transform_transactions.py",
|
||||
"prune_empty_customers.py",
|
||||
# Seeds the Scotiabank chequera that every SCOTHIA movement is booked into;
|
||||
@@ -78,6 +84,12 @@ SYNC_STEPS = [
|
||||
# touches.
|
||||
"backfill_statement_match_fields.py",
|
||||
"transform_policies.py",
|
||||
# Premium breakdown (recargo, per-payment figures, forma de pago).
|
||||
# transform_policies.py now writes these directly, so on a full rebuild
|
||||
# this is a no-op that re-asserts them; on a database migrated before the
|
||||
# breakdown existed it is what recovers them out of coveragesJson.
|
||||
# Must follow transform_policies.py, which truncates the installments.
|
||||
"backfill_policy_premium_breakdown.py",
|
||||
"transform_transactions.py",
|
||||
# Manual-safe prune: drops legacy-owned empties that the customer upsert
|
||||
# re-creates from Parquet, but leaves manually-added customers alone.
|
||||
|
||||
@@ -166,12 +166,17 @@ def resolve_name(raw, nid, index) -> tuple[str, str | None]:
|
||||
|
||||
# ------------------------------- record builders ---------------------------- #
|
||||
def customer_from_utilities(row, name_index) -> dict:
|
||||
name, name_source = resolve_name(row["nombre"], norm_id(row["num_id"]), name_index)
|
||||
nid = norm_id(row["num_id"])
|
||||
name, name_source = resolve_name(row["nombre"], nid, name_index)
|
||||
return dict(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
nameSource=name_source,
|
||||
nameMissing=int(name == NO_NAME),
|
||||
# The NUMid is the number on the customer's shelf folder. It is copied
|
||||
# into a plain label so the office can reuse it without it ever being
|
||||
# an identity; customer_legacy_refs keeps the NUMid itself.
|
||||
folderNumber=int(nid) if nid and nid.isdigit() else None,
|
||||
addressLine1=s(row["direccion"]),
|
||||
addressLine2=s(row["colonia"]),
|
||||
city=s(row["ciudad"]),
|
||||
@@ -205,6 +210,7 @@ def customer_from_insurance(row, name_index) -> dict:
|
||||
name=name,
|
||||
nameSource=name_source,
|
||||
nameMissing=int(name == NO_NAME),
|
||||
folderNumber=None, # insurance ids are a separate space, not a shelf folder
|
||||
addressLine1=s(row["direccion_1"]),
|
||||
addressLine2=s(row["direccion_2"]),
|
||||
city=s(row["ciudad"]),
|
||||
@@ -228,7 +234,7 @@ def customer_from_insurance(row, name_index) -> dict:
|
||||
|
||||
|
||||
_CUST_COLS = [
|
||||
"id", "name", "nameSource", "nameMissing", "addressLine1", "addressLine2", "city", "state", "zipCode",
|
||||
"id", "name", "nameSource", "nameMissing", "folderNumber", "addressLine1", "addressLine2", "city", "state", "zipCode",
|
||||
"country", "phone", "mobile", "fax", "email", "notes", "identificationType",
|
||||
"identificationNumber", "identificationExpiration", "customerSince",
|
||||
"status", "feeAmount", "minimumBalance", "updatedAt",
|
||||
@@ -322,7 +328,7 @@ def main() -> None:
|
||||
remap[rec["id"]] = stable or rec["id"]
|
||||
for rec in customers:
|
||||
rec["id"] = remap[rec["id"]]
|
||||
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),minimumBalance=VALUES(minimumBalance),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
|
||||
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),folderNumber=VALUES(folderNumber),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),minimumBalance=VALUES(minimumBalance),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
|
||||
for ref in refs:
|
||||
cur.execute("INSERT INTO customer_legacy_refs (id,customerId,sourceSystem,sourceTable,legacyId) VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId)",
|
||||
(ref[0], remap[ref[1]], ref[2], ref[3], ref[4]))
|
||||
@@ -374,6 +380,12 @@ def main() -> None:
|
||||
recovered = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) FROM customers WHERE name = %s", (NO_NAME,))
|
||||
still_unnamed = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(folderNumber) FROM customers")
|
||||
with_folder = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM (SELECT folderNumber FROM customers "
|
||||
"WHERE archivedAt IS NULL AND folderNumber IS NOT NULL "
|
||||
"GROUP BY folderNumber HAVING COUNT(*) > 1) d")
|
||||
shared_folders = cur.fetchone()[0]
|
||||
|
||||
print("=== Customer load complete ===")
|
||||
print(f" utilities DATGRAL rows : {len(util)}")
|
||||
@@ -390,6 +402,8 @@ def main() -> None:
|
||||
print(f" from {src:16} : {n}")
|
||||
print(f" of which via the linked insurance record : {from_ins_side}")
|
||||
print(f" still {NO_NAME} : {still_unnamed}")
|
||||
print(f" folder numbers : {with_folder} (one per numeric utilities NUMid)")
|
||||
print(f" held by >1 open customer (must be 0) : {shared_folders}")
|
||||
if not sync_mode:
|
||||
# Full-load invariants only: sync upserts into an already-loaded (and
|
||||
# pruned) table, so these exact counts don't hold.
|
||||
|
||||
@@ -18,6 +18,17 @@ Design (validated against staged data):
|
||||
policies are skipped and counted (required FK).
|
||||
- Payment slots: c_1er_pago is the first amount, pago_subsec the recurring
|
||||
amount for slots 2-4; efectivo is a cash flag, no_cheque the check ref.
|
||||
- Premium breakdown: a policy paid in more than one exhibicion prices EACH
|
||||
payment separately, which is why the home tables carry the whole money row
|
||||
twice (p_neta/recargo/d_pol/com and their _2 twins). The unsuffixed set is
|
||||
the policy header, the suffixed one belongs to payment 2, and both are
|
||||
written per installment as well. This used to be lost: `recargo` and the
|
||||
_2 columns fell into coveragesJson as loose strings and forma_pago was
|
||||
marked consumed but never written anywhere at all.
|
||||
- IVA and the printed TOTAL are NOT in Access for the home tables. They were
|
||||
unbound calculated controls on the form, so there is nothing to migrate;
|
||||
the app computes them (apps/api/src/policies/premium.ts) from
|
||||
(p_neta + recargo + d_pol) * rate.
|
||||
- Any source column not explicitly modeled (coverage amounts: edificio,
|
||||
contenidos, robo, cristales, ...) is preserved verbatim in coveragesJson,
|
||||
so nothing is lost in consolidation.
|
||||
@@ -93,20 +104,55 @@ def truthy(v):
|
||||
return (s(v) or "0").lower() in {"1", "-1", "true", "si", "sí", "yes", "x"}
|
||||
|
||||
|
||||
# Access FORMA PAGO -> PaymentFrequency. The whole staged corpus holds exactly
|
||||
# four spellings (ANNUAL 1851, SEMESTRAL 29, semestral 2, CONTADO 1); anything
|
||||
# else is left null rather than guessed, because the value decides whether a
|
||||
# recargo is legitimate on the row.
|
||||
_FREQ = {
|
||||
"ANNUAL": "ANNUAL",
|
||||
"ANUAL": "ANNUAL",
|
||||
"SEMESTRAL": "SEMIANNUAL",
|
||||
"TRIMESTRAL": "QUARTERLY",
|
||||
"MENSUAL": "MONTHLY",
|
||||
"CONTADO": "SINGLE",
|
||||
}
|
||||
|
||||
|
||||
def freq(v):
|
||||
return _FREQ.get((s(v) or "").upper())
|
||||
|
||||
|
||||
def slot_dec(row, slot, key):
|
||||
"""One figure of a payment's premium breakdown, or None when the source
|
||||
table has no such column. Deliberately not zero: a zero d_pol on the second
|
||||
payment is a real figure in the books and must stay distinguishable from
|
||||
'this table never had that column'."""
|
||||
col = slot.get(key)
|
||||
return dec(row.get(col)) if col else None
|
||||
|
||||
|
||||
# --- per-table config ------------------------------------------------------- #
|
||||
# fields: policy column -> source column. installments: list of slot dicts.
|
||||
# vehicles: 'trip_underscore' | 'single' | 'mca2' | None. drivers: 'mca2' |
|
||||
# 'licencias' | None.
|
||||
# `pneta`/`recarg`/`dpol`/`com` on a slot are that payment's own share of the
|
||||
# premium. Only the first two payments have one in Access — the form only ever
|
||||
# drew the money row twice — so slots 3 and 4 carry none and keep just the
|
||||
# amount actually collected.
|
||||
HOME_INST = [
|
||||
dict(seq=1, amt="c_1er_pago", cu="moned", d="fecha_pago", ck="no_cheque", cash="efectivo"),
|
||||
dict(seq=2, amt="pago_subsec", cu="moned_2", d="fecha_pago_2", ck="no_cheque_2", cash="efectivo_2"),
|
||||
dict(seq=1, amt="c_1er_pago", cu="moned", d="fecha_pago", ck="no_cheque", cash="efectivo",
|
||||
pneta="p_neta", recarg="recargo", dpol="d_pol", com="com"),
|
||||
dict(seq=2, amt="pago_subsec", cu="moned_2", d="fecha_pago_2", ck="no_cheque_2", cash="efectivo_2",
|
||||
pneta="p_neta_2", recarg="recargo_2", dpol="d_pol_2", com="com_2"),
|
||||
dict(seq=3, amt="pago_subsec", cu="moned_3", d="fecha_pago_3", ck="no_cheque_3", cash="efectivo_3"),
|
||||
dict(seq=4, amt="pago_subsec", cu="moned_4", d="fecha_pago_4", ck="no_cheque_4", cash="efectivo_4"),
|
||||
]
|
||||
HOME_FIELDS = dict(polno="no_poliza", agent="agent", comp="comp", desde="desde", hasta="hasta",
|
||||
forma="forma_pago", curcol="moned", pneta="p_neta", dpol="d_pol", com="com",
|
||||
forma="forma_pago", curcol="moned", pneta="p_neta", recarg="recargo",
|
||||
dpol="d_pol", com="com",
|
||||
liquidada="liquidada", numliq="num_liquidacion", fliq="f_liquida1", renov="renovacion")
|
||||
AUTO_SINGLE_INST = [dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo")]
|
||||
AUTO_SINGLE_INST = [dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo",
|
||||
pneta="prima_neta", dpol="d_poliza")]
|
||||
|
||||
CONFIGS = {
|
||||
"incendio": dict(ptype="INCENDIO", idcol="num_id", fields={**HOME_FIELDS, "curcol": "moneda"},
|
||||
@@ -157,7 +203,8 @@ CONFIGS = {
|
||||
forma="forma_pago", curcol="moneda", pneta="prima_neta", dpol="d_poliza",
|
||||
total="total", liquidada="liquidada", numliq="num_liquidacion",
|
||||
fliq="f_liquida1", renov="renovacion"),
|
||||
inst=[dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo")],
|
||||
inst=[dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque",
|
||||
cash="efectivo", pneta="prima_neta", dpol="d_poliza")],
|
||||
veh=None, drv="licencias"),
|
||||
}
|
||||
|
||||
@@ -200,6 +247,10 @@ def main():
|
||||
consumed = {cfg["idcol"], *F.values()}
|
||||
for slot in cfg["inst"]:
|
||||
consumed |= {slot["amt"], slot["cu"], slot["d"], slot["ck"], slot["cash"]}
|
||||
# The per-payment premium columns are now modeled, so they must
|
||||
# leave the coveragesJson sweep — otherwise every recargo would be
|
||||
# written twice, once as a column and once as a fake coverage.
|
||||
consumed |= {slot[k] for k in ("pneta", "recarg", "dpol", "com") if slot.get(k)}
|
||||
|
||||
for _, row in df.iterrows():
|
||||
cid = cust.get(norm_id(row[cfg["idcol"]]))
|
||||
@@ -237,9 +288,11 @@ def main():
|
||||
dt(row.get(F.get("desde", ""))) if F.get("desde") else None,
|
||||
dt(row.get(F.get("hasta", ""))) if F.get("hasta") else None,
|
||||
dec(row.get(F.get("pneta", ""))) if F.get("pneta") else None,
|
||||
dec(row.get(F.get("recarg", ""))) if F.get("recarg") else None,
|
||||
dec(row.get(F.get("dpol", ""))) if F.get("dpol") else None,
|
||||
dec(row.get(F.get("com", ""))) if F.get("com") else None,
|
||||
dec(row.get(F.get("total", ""))) if F.get("total") else None,
|
||||
freq(row.get(F.get("forma", ""))) if F.get("forma") else None,
|
||||
cur(row.get(F.get("curcol", ""))) if F.get("curcol") else "MXN",
|
||||
s(row.get("observaciones")),
|
||||
json.dumps(cov, ensure_ascii=False) if cov else None,
|
||||
@@ -255,9 +308,12 @@ def main():
|
||||
pdate = dt(row.get(slot["d"]))
|
||||
if amt is None and pdate is None:
|
||||
continue
|
||||
# Slot breakdown, where the source table has one.
|
||||
insts.append((str(uuid.uuid4()), pid, slot["seq"], amt,
|
||||
cur(row.get(slot["cu"])), pdate, s(row.get(slot["ck"])),
|
||||
1 if truthy(row.get(slot["cash"])) else 0))
|
||||
1 if truthy(row.get(slot["cash"])) else 0,
|
||||
slot_dec(row, slot, "pneta"), slot_dec(row, slot, "recarg"),
|
||||
slot_dec(row, slot, "dpol"), slot_dec(row, slot, "com")))
|
||||
|
||||
# vehicles
|
||||
def add_vehicle(make, model, body, engine, plate, year=None, state=None):
|
||||
@@ -328,16 +384,19 @@ def main():
|
||||
1 if truthy(r["concluido"]) else 0, s(r["resolucion"])))
|
||||
|
||||
pol_cols = ("id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate,"
|
||||
"policyFrom,policyTo,netPremium,policyFee,commission,total,currency,observations,"
|
||||
"policyFrom,policyTo,netPremium,surcharge,policyFee,commission,total,paymentFrequency,"
|
||||
"currency,observations,"
|
||||
"coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb,"
|
||||
"legacySourceTable,legacyId,updatedAt")
|
||||
ph = ",".join(["%s"] * 23)
|
||||
ph = ",".join(["%s"] * 25)
|
||||
pol_upsert = (
|
||||
f"INSERT INTO policies ({pol_cols}) VALUES ({ph}) ON DUPLICATE KEY UPDATE "
|
||||
"customerId=VALUES(customerId),policyNumber=VALUES(policyNumber),policyTypeId=VALUES(policyTypeId),"
|
||||
"insuranceProviderId=VALUES(insuranceProviderId),agentName=VALUES(agentName),policyDate=VALUES(policyDate),"
|
||||
"policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),policyFee=VALUES(policyFee),"
|
||||
"commission=VALUES(commission),total=VALUES(total),currency=VALUES(currency),observations=VALUES(observations),"
|
||||
"policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),"
|
||||
"surcharge=VALUES(surcharge),policyFee=VALUES(policyFee),"
|
||||
"commission=VALUES(commission),total=VALUES(total),paymentFrequency=VALUES(paymentFrequency),"
|
||||
"currency=VALUES(currency),observations=VALUES(observations),"
|
||||
"coveragesJson=VALUES(coveragesJson),liquidated=VALUES(liquidated),liquidationNumber=VALUES(liquidationNumber),"
|
||||
"liquidationDate=VALUES(liquidationDate),updatedAt=VALUES(updatedAt),archivedAt=NULL")
|
||||
|
||||
@@ -399,8 +458,9 @@ def main():
|
||||
|
||||
|
||||
c.executemany("INSERT INTO policy_payment_installments "
|
||||
"(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", insts)
|
||||
"(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash,"
|
||||
"netPremium,surcharge,policyFee,commission) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", insts)
|
||||
c.executemany("INSERT INTO vehicles (id,customerId,policyId,make,model,modelYear,bodyType,"
|
||||
"engineNumber,licensePlate,stateCode,legacySourceTable,legacyId) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", vehicles)
|
||||
|
||||
@@ -164,18 +164,21 @@ def main():
|
||||
services.append((str(uuid.uuid4()), pid, kind, account, meter, route,
|
||||
due, 1 if active else 0, notes))
|
||||
|
||||
# WATER (primary + extra meters)
|
||||
# WATER — the CESPT account only. DATMEX has no water meter column.
|
||||
if s(row["agua"]) or flag("water1", False):
|
||||
svc("WATER", account=s(row["agua"]), meter=s(row["medidor"]),
|
||||
route=s(row["ruta"]), due=s(row["diagua"]), active=flag("water1"))
|
||||
for mcol, rcol in (("medidor2", "ruta2"), ("medidor3", "ruta3")):
|
||||
if s(row[mcol]) or s(row[rcol]):
|
||||
svc("WATER", meter=s(row[mcol]), route=s(row[rcol]),
|
||||
notes="secondary meter", active=flag("water1"))
|
||||
# ELECTRIC (rpu account + extras); luz_tipo as note
|
||||
for rc in ("rpu", "rpu2", "rpu3"):
|
||||
if s(row[rc]):
|
||||
svc("ELECTRIC", account=s(row[rc]),
|
||||
svc("WATER", account=s(row["agua"]), due=s(row["diagua"]),
|
||||
active=flag("water1"))
|
||||
# ELECTRIC — rpu account + the CFE meter and route beside it. MEDIDOR/
|
||||
# RUTA are the electric meter, not water: they are filled on exactly
|
||||
# the 1113 rows that carry an RPU (100% overlap), the meters are CFE's
|
||||
# alphanumeric `53MT58` form, and every legacy query that reads them is
|
||||
# a LUZ one (LUZ, FALTANTES DE LUZ, "luz ruta 325 y 329"). They used to
|
||||
# land on the WATER row, leaving every electric service without a meter.
|
||||
# Paired by suffix: medidor2/ruta2 belong to rpu2. luz_tipo as note.
|
||||
for rc, mc, rtc in (("rpu", "medidor", "ruta"), ("rpu2", "medidor2", "ruta2"),
|
||||
("rpu3", "medidor3", "ruta3")):
|
||||
if s(row[rc]) or s(row[mc]):
|
||||
svc("ELECTRIC", account=s(row[rc]), meter=s(row[mc]), route=s(row[rtc]),
|
||||
notes=s(row["luz_tipo"]) if rc == "rpu" else None,
|
||||
active=flag("electric"))
|
||||
# GAS — the column mixes two things: an account/meter number for 160 of
|
||||
|
||||
@@ -90,6 +90,62 @@ def load(src, name):
|
||||
return df
|
||||
|
||||
|
||||
def verify_corte(c, year: int) -> None:
|
||||
"""Assert legacy's corte identity: SUM(period Y) == BALANCE FORWARD(Y+1).
|
||||
|
||||
This is the whole reason a year can be shown on its own. Legacy closed each
|
||||
year by summing it and writing that total back as every customer's Jan-1
|
||||
opening row for the next one, so if an archive is the right file, complete,
|
||||
and attached to the right customers, its per-customer total lands exactly on
|
||||
the next year's BALANCE FORWARD. A truncated export, a file dropped under
|
||||
the wrong year, or a botched customer match all break the identity loudly
|
||||
here instead of quietly six months from now.
|
||||
|
||||
Reported, never fatal. Legacy publishes on its own schedule, so a handful of
|
||||
customers legitimately drift between the snapshot and the cut — the run that
|
||||
established this reconciled 1,160 of 1,170.
|
||||
"""
|
||||
c.execute(
|
||||
"""
|
||||
SELECT sums.customerId, sums.total, bf.amount
|
||||
FROM (
|
||||
SELECT customerId, ROUND(SUM(amount), 2) AS total
|
||||
FROM transactions
|
||||
WHERE legacySourceTable = %s AND voidedAt IS NULL
|
||||
GROUP BY customerId
|
||||
) sums
|
||||
LEFT JOIN (
|
||||
SELECT t.customerId, ROUND(SUM(t.amount), 2) AS amount
|
||||
FROM transactions t
|
||||
JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE tt.nameEn = 'BALANCE FORWARD' AND t.voidedAt IS NULL
|
||||
AND t.transactionDate >= %s AND t.transactionDate < %s
|
||||
GROUP BY t.customerId
|
||||
) bf ON bf.customerId = sums.customerId
|
||||
""",
|
||||
(f"datos2@{year}", f"{year + 1}-01-01", f"{year + 1}-01-02"),
|
||||
)
|
||||
rows = c.fetchall()
|
||||
matched = mismatched = 0
|
||||
missing = 0
|
||||
drift = Decimal(0)
|
||||
for _cid, total, amount in rows:
|
||||
if amount is None:
|
||||
missing += 1
|
||||
continue
|
||||
if abs(Decimal(str(total)) - Decimal(str(amount))) < Decimal("0.02"):
|
||||
matched += 1
|
||||
else:
|
||||
mismatched += 1
|
||||
drift += abs(Decimal(str(total)) - Decimal(str(amount)))
|
||||
checked = matched + mismatched
|
||||
pct = (100 * matched / checked) if checked else 0
|
||||
print(
|
||||
f" corte {year} -> BF {year + 1}: {matched}/{checked} match ({pct:.1f}%)"
|
||||
f", {mismatched} off by {drift:,.2f}, {missing} with no BF row"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
env, sync_mode = parse_mode()
|
||||
conn = connect(env)
|
||||
@@ -230,9 +286,13 @@ def main():
|
||||
message=s(r["conepto"]), check=s(r[check_col]) if check_col else None,
|
||||
src_db="UTILITIES", src_tbl=legacy_tbl, legacy=str(int(r["_row_num"])))
|
||||
|
||||
def billing(name, legacy_tbl):
|
||||
def billing(name, legacy_tbl, *, src="stg_utilities", skip_numids=None):
|
||||
"""Load a DATOS2-shaped billing ledger.
|
||||
|
||||
`src` names the staging schema, so a prior-period archive
|
||||
(stg_period_2025) loads through this same path: the snapshot's `datos2`
|
||||
is the identical eleven-column shape, one year older.
|
||||
|
||||
NOPAGO is the legacy "still owed" flag. The website reads it directly —
|
||||
`account.statement.php` splits the statement on `NOPAGO = 0` vs
|
||||
`NOPAGO = 1` and renders the latter as the "Outstanding Bills Requiring
|
||||
@@ -241,10 +301,13 @@ def main():
|
||||
Only these three tables carry it (76 rows set in DATOS2 today); the
|
||||
EFECTIVO/FM3 cash streams have no such column and stay 0.
|
||||
"""
|
||||
nonlocal skip_cust, skip_date
|
||||
df = load("stg_utilities", name)
|
||||
nonlocal skip_cust, skip_date, skip_recycled
|
||||
df = load(src, name)
|
||||
for _, r in df.iterrows():
|
||||
cid = util_cust.get(norm_id(r["numid"]))
|
||||
numid = norm_id(r["numid"])
|
||||
if skip_numids and numid in skip_numids:
|
||||
skip_recycled += 1; continue
|
||||
cid = util_cust.get(numid)
|
||||
if not cid:
|
||||
skip_cust += 1; continue
|
||||
td = dt(r["date"])
|
||||
@@ -257,6 +320,53 @@ def main():
|
||||
legacy=str(int(r["_row_num"])),
|
||||
outstanding=1 if s(r["nopago"]) == "1" else 0)
|
||||
|
||||
skip_recycled = 0
|
||||
recycle_report: list[tuple[int, str, str, str]] = []
|
||||
|
||||
def period_numid_guard(year: int) -> set[str]:
|
||||
"""NUMids whose prior-period owner is not today's customer.
|
||||
|
||||
Prior-period rows attach by NUMid and nothing else, so a number the
|
||||
office retired and reissued would file one customer's ledger under
|
||||
another's name — the one error this feature must never make, because it
|
||||
shows a stranger's charges to whoever holds the number now.
|
||||
|
||||
Reuse is real but rare: comparing each archive's DATGRAL against the
|
||||
live one, 13 names moved since 2025 and 40 since 2024. Most are the same
|
||||
customer re-described — a typo fixed (VIKIE -> VICKIE), a spouse added
|
||||
or dropped (STEWART, ALAN R. -> STEWART, ALAN & JENNIFER). A few are
|
||||
genuinely a different household (STRONKS, BOB -> SWEET, DONALD E.).
|
||||
|
||||
Sharing any word of three or more characters separates the two cleanly:
|
||||
a rename keeps the surname, a reissue keeps nothing. Names are compared
|
||||
legacy-to-legacy, archive DATGRAL against live DATGRAL, deliberately not
|
||||
against `customers.name` — that column has been through the blank-name
|
||||
recovery pass, and comparing to it reported 121 drifts where there are
|
||||
13, every extra one a false positive that would have discarded good
|
||||
history.
|
||||
"""
|
||||
try:
|
||||
arch = load(f"stg_period_{year}", "datgral")
|
||||
live = load("stg_utilities", "datgral")
|
||||
except (FileNotFoundError, OSError):
|
||||
return set()
|
||||
|
||||
def toks(v) -> set[str]:
|
||||
return {w for w in "".join(ch if ch.isalnum() else " " for ch in (s(v) or "").upper()).split() if len(w) >= 3}
|
||||
|
||||
live_names = {norm_id(r["num_id"]): s(r["nombre"]) for _, r in live.iterrows()}
|
||||
blocked: set[str] = set()
|
||||
for _, r in arch.iterrows():
|
||||
numid = norm_id(r["num_id"])
|
||||
was, now = s(r["nombre"]), live_names.get(numid)
|
||||
if not numid or not was or not now:
|
||||
continue
|
||||
if toks(was) & toks(now):
|
||||
continue
|
||||
blocked.add(numid)
|
||||
recycle_report.append((year, numid, was, now))
|
||||
return blocked
|
||||
|
||||
def iva():
|
||||
nonlocal skip_cust
|
||||
df = load("stg_utilities", "iva_2015")
|
||||
@@ -286,6 +396,31 @@ def main():
|
||||
billing("fee_anual", "FEE ANUAL")
|
||||
billing("fee15", "fee15")
|
||||
iva()
|
||||
|
||||
# --- prior periods -----------------------------------------------------
|
||||
#
|
||||
# Legacy kept each closed year in its own table and opened the next one with
|
||||
# a Jan-1 BALANCE FORWARD carrying the closing total. The platform has one
|
||||
# `transactions` table, so the period a row belongs to has to travel with
|
||||
# the row: it rides in legacySourceTable as `datos2@2025`.
|
||||
#
|
||||
# That tag, not the date, is what a year view should filter on. The archives
|
||||
# are not cleanly bounded — 2025's ledger carries ten undated rows and two
|
||||
# dated into 2026 — and legacy itself never filtered by date either: its
|
||||
# reader is `SELECT ... FROM \`2025\``. Keying on provenance reproduces the
|
||||
# legacy period exactly and strands nothing.
|
||||
#
|
||||
# The tag also keeps the unique key safe. legacyId is a positional row
|
||||
# ordinal, so every archive restarts it at 0 and would collide with the live
|
||||
# `datos2` row-for-row if they shared a source-table name.
|
||||
periods = sorted(
|
||||
int(d.name.rsplit("_", 1)[1])
|
||||
for d in STG.glob("stg_period_*")
|
||||
if d.is_dir() and d.name.rsplit("_", 1)[1].isdigit()
|
||||
)
|
||||
for year in periods:
|
||||
billing("datos2", f"datos2@{year}", src=f"stg_period_{year}",
|
||||
skip_numids=period_numid_guard(year))
|
||||
# Same record shape in the seguros DB. Labelled for consistency in the
|
||||
# platform's own UI; unverifiable against the site, which only ever reads
|
||||
# domain='UTILITY', so no customer-facing behaviour depends on it.
|
||||
@@ -334,6 +469,7 @@ def main():
|
||||
print(f" skipped (unresolved customer): {skip_cust}")
|
||||
print(f" skipped (unparseable date) : {skip_date}")
|
||||
print(f" skipped (EFECTIVO_BACKUP dup): {skip_dupe}")
|
||||
print(f" skipped (reissued NUMid) : {skip_recycled}")
|
||||
print(f" -> transactions : {count('transactions')}")
|
||||
print(f" by domain : {dict(by_dom)}")
|
||||
for src, n in by_src:
|
||||
@@ -342,6 +478,17 @@ def main():
|
||||
print(f" -> exchange_rates : {count('exchange_rates')}")
|
||||
print(f" orphan transactions (bad customer FK): {orphans}")
|
||||
assert orphans == 0, "transaction customer FK invariant failed"
|
||||
|
||||
if recycle_report:
|
||||
print(f" ! reissued NUMids, prior-period rows NOT imported: {len(recycle_report)}")
|
||||
for year, numid, was, now in recycle_report[:8]:
|
||||
print(f" {year} NUMid {numid}: '{was}' -> '{now}'")
|
||||
if len(recycle_report) > 8:
|
||||
print(f" ... {len(recycle_report) - 8} more")
|
||||
|
||||
for year in periods:
|
||||
verify_corte(c, year)
|
||||
|
||||
print(" validation: OK")
|
||||
conn.close()
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jorgecuadros-platform",
|
||||
"version": "1.0.20",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.20",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"types": "generated/client/index.d.ts",
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- Ranked customers whose name matches the printed insured name, for the
|
||||
-- documents whose policy number found nothing and therefore need a customer
|
||||
-- picked by hand. Kept in its own column rather than folded into
|
||||
-- `matchCandidates`, which the review screen reads as policy-number hits —
|
||||
-- a name is a suggestion and must never be able to masquerade as a match.
|
||||
ALTER TABLE `policy_ocr_documents`
|
||||
ADD COLUMN `customerSuggestions` JSON NULL;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
-- Premium breakdown the Access capture form had and this schema did not:
|
||||
-- RECARGO, IVA and PRIMA TOTAL on the policy header, the same six figures per
|
||||
-- payment on the installments, and the FORMA PAGO that decides whether a
|
||||
-- surcharge applies at all.
|
||||
--
|
||||
-- IVA and TOTAL were never columns in Access — they were unbound calculated
|
||||
-- controls on the form — so there is nothing to backfill for them here and
|
||||
-- every migrated row stays null until somebody edits the policy. RECARGO and
|
||||
-- the per-installment figures DO exist in the legacy data; they are currently
|
||||
-- stranded inside `policies.coveragesJson` (the migration swept every column
|
||||
-- it did not model into that blob) and are recovered by
|
||||
-- `migration/backfill_policy_premium_breakdown.py`, not by this migration.
|
||||
|
||||
ALTER TABLE `policy_types`
|
||||
ADD COLUMN `taxRate` DECIMAL(6, 4) NULL;
|
||||
|
||||
ALTER TABLE `policies`
|
||||
ADD COLUMN `surcharge` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `tax` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `taxRate` DECIMAL(6, 4) NULL,
|
||||
ADD COLUMN `paymentFrequency` ENUM('ANNUAL', 'SEMIANNUAL', 'QUARTERLY', 'MONTHLY', 'SINGLE') NULL;
|
||||
|
||||
ALTER TABLE `policy_payment_installments`
|
||||
ADD COLUMN `netPremium` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `surcharge` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `policyFee` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `tax` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `taxRate` DECIMAL(6, 4) NULL,
|
||||
ADD COLUMN `total` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `commission` DECIMAL(12, 2) NULL;
|
||||
|
||||
-- Seed the rate the books actually use. The legacy IMPUESTOS and
|
||||
-- IMPUESTOS_AUTOS tables each held exactly one row, both 0.0800, covering the
|
||||
-- home and auto lines respectively; applying it to every existing type
|
||||
-- reproduces current behaviour rather than changing it. Types created later
|
||||
-- start null and fall back to the API default.
|
||||
UPDATE `policy_types` SET `taxRate` = 0.0800 WHERE `taxRate` IS NULL;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
-- A.N.A. prints IVA on the policy face and the parser already read it, but
|
||||
-- `ParsedPolicy` had no field for it, so the figure only ever reached a review
|
||||
-- note and the confirmed Policy was written with `tax` null. This gives it a
|
||||
-- column, matching the premium fields beside it.
|
||||
--
|
||||
-- GMX stays null: its certificate carries no premium at all, so there is no
|
||||
-- tax on it to read either.
|
||||
ALTER TABLE `policy_ocr_documents`
|
||||
ADD COLUMN `extractedTax` DECIMAL(12, 2) NULL;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
-- The office files every customer in a numbered paper folder and hands the
|
||||
-- number back out once an account is closed. That number has been the legacy
|
||||
-- utilities NUMid; it becomes a plain label here so reusing it never touches
|
||||
-- an identity. Unique among non-archived customers only, enforced by the API.
|
||||
ALTER TABLE `customers` ADD COLUMN `folderNumber` INTEGER NULL;
|
||||
|
||||
CREATE INDEX `customers_folderNumber_idx` ON `customers`(`folderNumber`);
|
||||
|
||||
-- Existing customers start with their utilities NUMid. Insurance-only
|
||||
-- customers have none and get one from the office.
|
||||
UPDATE `customers` c
|
||||
JOIN (
|
||||
SELECT `customerId`, MIN(CAST(`legacyId` AS UNSIGNED)) AS n
|
||||
FROM `customer_legacy_refs`
|
||||
WHERE `sourceSystem` = 'utilities'
|
||||
AND `sourceTable` = 'DATGRAL'
|
||||
AND `legacyId` REGEXP '^[0-9]+$'
|
||||
GROUP BY `customerId`
|
||||
) r ON r.`customerId` = c.`id`
|
||||
SET c.`folderNumber` = r.n;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
-- Two asks from the 2026-10-08 meeting, both on the OCR receipt queue:
|
||||
-- * sin fondos per receipt, not only per check (`outstanding`);
|
||||
-- * one receipt paid by several customers (411 + 523 sharing a water bill),
|
||||
-- posted as one charge per part.
|
||||
-- AlterTable
|
||||
ALTER TABLE `statement_documents` ADD COLUMN `outstanding` BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `statement_document_splits` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`documentId` VARCHAR(191) NOT NULL,
|
||||
`sequence` INTEGER NOT NULL,
|
||||
`customerId` VARCHAR(191) NOT NULL,
|
||||
`amount` DECIMAL(12, 2) NOT NULL,
|
||||
`postedTransactionId` VARCHAR(191) NULL,
|
||||
|
||||
UNIQUE INDEX `statement_document_splits_postedTransactionId_key`(`postedTransactionId`),
|
||||
UNIQUE INDEX `statement_document_splits_documentId_sequence_key`(`documentId`, `sequence`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_documentId_fkey` FOREIGN KEY (`documentId`) REFERENCES `statement_documents`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_postedTransactionId_fkey` FOREIGN KEY (`postedTransactionId`) REFERENCES `transactions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- EFE cash receipt book (2026-10-08 meeting). Each receipt posts its own ledger
|
||||
-- credit, reference C<folio>, in the same database transaction.
|
||||
-- CreateTable
|
||||
CREATE TABLE `cash_receipts` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`folio` INTEGER NOT NULL,
|
||||
`customerId` VARCHAR(191) NOT NULL,
|
||||
`receivedAt` DATETIME(3) NOT NULL,
|
||||
`amount` DECIMAL(12, 2) NOT NULL,
|
||||
`currency` ENUM('USD', 'MXN') NOT NULL,
|
||||
`exchangeRate` DECIMAL(10, 4) NULL,
|
||||
`concept` TEXT NOT NULL,
|
||||
`preparedById` VARCHAR(191) NOT NULL,
|
||||
`depositedAt` DATETIME(3) NULL,
|
||||
`transactionId` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `cash_receipts_folio_key`(`folio`),
|
||||
UNIQUE INDEX `cash_receipts_transactionId_key`(`transactionId`),
|
||||
INDEX `cash_receipts_customerId_idx`(`customerId`),
|
||||
INDEX `cash_receipts_receivedAt_idx`(`receivedAt`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_preparedById_fkey` FOREIGN KEY (`preparedById`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_transactionId_fkey` FOREIGN KEY (`transactionId`) REFERENCES `transactions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -89,6 +89,13 @@ model Customer {
|
||||
// can sort nameless records last — ordering by `name` alone puts them first,
|
||||
// since "(" sorts before every letter.
|
||||
nameMissing Boolean @default(false)
|
||||
// The number on the customer's paper folder on the office shelf. A label,
|
||||
// not an identity: nothing joins on it. The office hands a number back out
|
||||
// once an account is closed, so it is unique only among customers that are
|
||||
// not archived — enforced by CustomersService, not by an index, because
|
||||
// Prisma cannot model a partial/functional unique and would drop one.
|
||||
// Imported customers start with their utilities NUMid.
|
||||
folderNumber Int?
|
||||
addressLine1 String?
|
||||
addressLine2 String?
|
||||
city String?
|
||||
@@ -123,11 +130,14 @@ model Customer {
|
||||
transactions Transaction[]
|
||||
|
||||
statementDocuments StatementDocument[]
|
||||
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
||||
statementSplits StatementDocumentSplit[]
|
||||
cashReceipts CashReceipt[]
|
||||
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
||||
|
||||
emailNotificationLogs EmailNotificationLog[]
|
||||
accountStatusHistory AccountStatusHistory[]
|
||||
|
||||
@@index([folderNumber])
|
||||
@@map("customers")
|
||||
}
|
||||
|
||||
@@ -158,10 +168,31 @@ model InsuranceProvider {
|
||||
@@map("insurance_providers")
|
||||
}
|
||||
|
||||
/// How the premium is split into payments. Drives whether a surcharge
|
||||
/// applies at all: the legacy books only ever charge `recargo` on a policy
|
||||
/// paid in more than one exhibición, never on an annual one. Values come from
|
||||
/// the Access `FORMA PAGO` column (ANNUAL / SEMESTRAL / CONTADO) plus the
|
||||
/// quarterly option Jorge sells today but never recorded in Access.
|
||||
enum PaymentFrequency {
|
||||
ANNUAL
|
||||
SEMIANNUAL
|
||||
QUARTERLY
|
||||
MONTHLY
|
||||
/// Legacy "CONTADO" — the whole premium in one payment, no schedule.
|
||||
SINGLE
|
||||
}
|
||||
|
||||
model PolicyType {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
shortDescription String?
|
||||
/// IVA rate charged on this line of business, as a fraction (0.08 = 8%).
|
||||
/// Replaces the legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables, which
|
||||
/// held exactly one rate each — per line of business, editable without a
|
||||
/// deploy, because the rate is a tax rule and tax rules change. Null falls
|
||||
/// back to DEFAULT_TAX_RATE in the API rather than to "no tax", so a type
|
||||
/// nobody has configured still computes the same 8% the books use today.
|
||||
taxRate Decimal? @db.Decimal(6, 4)
|
||||
policies Policy[]
|
||||
|
||||
@@map("policy_types")
|
||||
@@ -185,10 +216,32 @@ model Policy {
|
||||
policyTo DateTime?
|
||||
coveragePeriodDays Int? @default(365)
|
||||
netPremium Decimal? @db.Decimal(12, 2)
|
||||
/// "Recargo" — the financing surcharge for paying in installments. Entered
|
||||
/// by hand, never derived: it is quoted by the carrier, not computed here.
|
||||
/// Only ever set when `paymentFrequency` is not ANNUAL/SINGLE, and it IS
|
||||
/// part of the taxable base (verified against the Access books: policy
|
||||
/// 7006785 only reconciles as (610.86 + 8.55 + 31.00) * 0.08 = 52.03).
|
||||
surcharge Decimal? @db.Decimal(12, 2)
|
||||
policyFee Decimal? @db.Decimal(12, 2)
|
||||
brokerFee Decimal? @db.Decimal(12, 2)
|
||||
commission Decimal? @db.Decimal(12, 2)
|
||||
/// IVA. Access never stored this — it was an unbound calculated control on
|
||||
/// the form — so every legacy row starts null and is filled going forward.
|
||||
/// Stored rather than computed on read because the printed policy is the
|
||||
/// record of truth and its rounding must survive a later rate change.
|
||||
tax Decimal? @db.Decimal(12, 2)
|
||||
/// The rate actually applied when `tax` was written, as a fraction. Kept on
|
||||
/// the row so a policy issued at 8% still reads back as 8% after somebody
|
||||
/// edits the PolicyType to a new rate.
|
||||
taxRate Decimal? @db.Decimal(6, 4)
|
||||
/// Prima total = netPremium + surcharge + policyFee + tax. Populated by the
|
||||
/// capture form from now on. NOTE the legacy rows: `total` is 0 or null on
|
||||
/// all but 2 of 2378 migrated policies, so list/sort code must keep using
|
||||
/// netPremium as the headline (see policies.service.ts).
|
||||
total Decimal? @db.Decimal(12, 2)
|
||||
/// ANNUAL on all but 31 legacy rows — and the migration used to drop the
|
||||
/// column entirely, so every pre-2026 policy reads null here.
|
||||
paymentFrequency PaymentFrequency?
|
||||
currency Currency @default(MXN)
|
||||
observations String? @db.Text
|
||||
notes String? @db.Text
|
||||
@@ -267,6 +320,23 @@ model PolicyPaymentInstallment {
|
||||
checkNumber String?
|
||||
isCash Boolean @default(false)
|
||||
|
||||
// Per-payment premium breakdown. A policy paid in more than one exhibición
|
||||
// prices EACH payment separately — its own net premium, its own surcharge,
|
||||
// its own IVA — which is why the Access form printed the whole money row
|
||||
// twice (P NETA / RECARGO / D POL / IVA / TOTAL / COM, once per pago) and
|
||||
// why these cannot live on the policy header alone. `amount` stays the
|
||||
// authoritative figure actually collected: it is what the cheque was
|
||||
// written for and it drifts from `total` by a peso or two in the books
|
||||
// (policy 7006785: amount 702.73 vs total 702.44), so it is deliberately
|
||||
// NOT recomputed from this breakdown.
|
||||
netPremium Decimal? @db.Decimal(12, 2)
|
||||
surcharge Decimal? @db.Decimal(12, 2)
|
||||
policyFee Decimal? @db.Decimal(12, 2)
|
||||
tax Decimal? @db.Decimal(12, 2)
|
||||
taxRate Decimal? @db.Decimal(6, 4)
|
||||
total Decimal? @db.Decimal(12, 2)
|
||||
commission Decimal? @db.Decimal(12, 2)
|
||||
|
||||
@@map("policy_payment_installments")
|
||||
}
|
||||
|
||||
@@ -430,25 +500,30 @@ model PolicyOcrDocument {
|
||||
provider String?
|
||||
|
||||
// Extracted header fields, all staff-editable in review.
|
||||
extractedPolicyNumber String?
|
||||
extractedInsuredName String?
|
||||
extractedAdditionalInsured String?
|
||||
extractedAgentName String?
|
||||
extractedLegalAddress String? @db.Text
|
||||
extractedZip String?
|
||||
extractedPolicyFrom DateTime?
|
||||
extractedPolicyTo DateTime?
|
||||
extractedPolicyDate DateTime?
|
||||
extractedCurrency String?
|
||||
extractedNetPremium Decimal? @db.Decimal(12, 2)
|
||||
extractedPolicyFee Decimal? @db.Decimal(12, 2)
|
||||
extractedBrokerFee Decimal? @db.Decimal(12, 2)
|
||||
extractedTotal Decimal? @db.Decimal(12, 2)
|
||||
extractedPolicyNumber String?
|
||||
extractedInsuredName String?
|
||||
extractedAdditionalInsured String?
|
||||
extractedAgentName String?
|
||||
extractedLegalAddress String? @db.Text
|
||||
extractedZip String?
|
||||
extractedPolicyFrom DateTime?
|
||||
extractedPolicyTo DateTime?
|
||||
extractedPolicyDate DateTime?
|
||||
extractedCurrency String?
|
||||
extractedNetPremium Decimal? @db.Decimal(12, 2)
|
||||
extractedPolicyFee Decimal? @db.Decimal(12, 2)
|
||||
extractedBrokerFee Decimal? @db.Decimal(12, 2)
|
||||
/// IVA off A.N.A.'s `TAX` cell. Null on GMX, whose certificate carries no
|
||||
/// premium at all. The adjacent `LOCAL TAX` is a separate levy with no
|
||||
/// column of its own and is NOT summed in — it would make the figure stop
|
||||
/// dividing back to a rate; the parser reports a non-zero one as a note.
|
||||
extractedTax Decimal? @db.Decimal(12, 2)
|
||||
extractedTotal Decimal? @db.Decimal(12, 2)
|
||||
/// Per-coverage rows from the GMX "Material damages" / "Additional risk"
|
||||
/// tables and ANA's numbered risk sections — preserved verbatim so a
|
||||
/// missing premium receipt still leaves the coverages auditable.
|
||||
extractedCoveragesJson Json?
|
||||
extractedPremiumPayment String?
|
||||
extractedCoveragesJson Json?
|
||||
extractedPremiumPayment String?
|
||||
/// Printed term length. ANA sells 3- and 4-day tourist policies, so
|
||||
/// leaving `Policy.coveragePeriodDays` at its 365 default would overstate
|
||||
/// a weekend policy by a year.
|
||||
@@ -456,31 +531,38 @@ model PolicyOcrDocument {
|
||||
/// `ParsedVehicle[]` off ANA's ITEM/YEAR/MAKE/BODY/SERIAL/PLATES table.
|
||||
/// Written to `Vehicle` rows on confirm; kept here so the review screen
|
||||
/// shows what was read before anything is applied.
|
||||
extractedVehiclesJson Json?
|
||||
extractedVehiclesJson Json?
|
||||
/// `ParsedDriver[]` — the insured on ANA's automobile face, the numbered
|
||||
/// POLICY HOLDER list on its driver's policy. Written to `InsuredDriver`
|
||||
/// rows on confirm.
|
||||
extractedDriversJson Json?
|
||||
extractedDriversJson Json?
|
||||
/// The `PolicyType.name` the parser read the product as ("AUTO",
|
||||
/// "LICENCIAS", "MULT"). A NAME, not an id — the parser never touches the
|
||||
/// database, so confirm resolves it against `policy_types` and leaves
|
||||
/// `Policy.policyTypeId` null if there is no such row.
|
||||
extractedPolicyTypeName String?
|
||||
extractedPolicyTypeName String?
|
||||
|
||||
// Match by `Policy.policyNumber` → existing Policy / Customer.
|
||||
matchedPolicyId String?
|
||||
matchedPolicy Policy? @relation("PolicyOcrDocumentPolicy", fields: [matchedPolicyId], references: [id])
|
||||
matchedCustomerId String?
|
||||
matchedCustomer Customer? @relation("PolicyOcrDocumentCustomer", fields: [matchedCustomerId], references: [id])
|
||||
matchedPolicyId String?
|
||||
matchedPolicy Policy? @relation("PolicyOcrDocumentPolicy", fields: [matchedPolicyId], references: [id])
|
||||
matchedCustomerId String?
|
||||
matchedCustomer Customer? @relation("PolicyOcrDocumentCustomer", fields: [matchedCustomerId], references: [id])
|
||||
/// All policies carrying the same number, with their customer. One is
|
||||
/// normal; >1 means the policy number is shared across customers and a
|
||||
/// human must pick.
|
||||
matchCandidates Json?
|
||||
matchCandidates Json?
|
||||
/// `CustomerNameSuggestion[]` — customers whose name matches the printed
|
||||
/// insured name, ranked. A SUGGESTION, never a match: it is deliberately
|
||||
/// kept out of `matchCandidates` so the review screen cannot mistake a
|
||||
/// name hint for a policy-number hit, and it never sets
|
||||
/// `matchedCustomerId`. Only populated when the policy number found
|
||||
/// nothing, which is exactly when staff have to pick a customer by hand.
|
||||
customerSuggestions Json?
|
||||
/// Text, not VARCHAR(191): this carries the parser's whole note trail, and
|
||||
/// a multi-section ANA policy runs past 191 characters routinely. Silently
|
||||
/// truncating it drops the tail notes, which are the ones that say what
|
||||
/// could NOT be read.
|
||||
matchNote String? @db.Text
|
||||
matchNote String? @db.Text
|
||||
|
||||
reviewedById String?
|
||||
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
|
||||
@@ -650,6 +732,26 @@ model StatementBatch {
|
||||
}
|
||||
|
||||
/// One statement — one customer, one period — after splitting the batch.
|
||||
/// One customer's share of a split receipt — a water bill two neighbours
|
||||
/// share, an HOA fee spread across a building. Free amounts, no stored
|
||||
/// percentages: the office splits unevenly as often as not.
|
||||
model StatementDocumentSplit {
|
||||
id String @id @default(uuid())
|
||||
documentId String
|
||||
document StatementDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
/// 1-based; also the `#n` in the posted row's captureRef.
|
||||
sequence Int
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
amount Decimal @db.Decimal(12, 2)
|
||||
|
||||
postedTransactionId String? @unique
|
||||
postedTransaction Transaction? @relation("StatementDocumentSplitTransaction", fields: [postedTransactionId], references: [id])
|
||||
|
||||
@@unique([documentId, sequence])
|
||||
@@map("statement_document_splits")
|
||||
}
|
||||
|
||||
model StatementDocument {
|
||||
id String @id @default(uuid())
|
||||
batchId String
|
||||
@@ -695,6 +797,15 @@ model StatementDocument {
|
||||
postedTransactionId String? @unique
|
||||
postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id])
|
||||
|
||||
/// Sin fondos for this receipt alone: posted as outstanding even when the
|
||||
/// rest of the check is funded.
|
||||
outstanding Boolean @default(false)
|
||||
|
||||
/// When set, the receipt is paid for by several customers and posts one
|
||||
/// charge per part instead of one to `matchedCustomer`. The parts always sum
|
||||
/// to `extractedAmount`.
|
||||
splits StatementDocumentSplit[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([batchId, pageNumber])
|
||||
@@ -773,7 +884,9 @@ model Transaction {
|
||||
|
||||
/// Set only on OCR-posted rows — the statement page this came from.
|
||||
statementDocument StatementDocument?
|
||||
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
||||
statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
|
||||
cashReceipt CashReceipt?
|
||||
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
||||
|
||||
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
||||
@@index([customerId, transactionDate])
|
||||
@@ -894,6 +1007,7 @@ model User {
|
||||
|
||||
statementBatches StatementBatch[] @relation("StatementBatchUploader")
|
||||
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
|
||||
cashReceipts CashReceipt[] @relation("CashReceiptPreparer")
|
||||
|
||||
policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader")
|
||||
policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer")
|
||||
@@ -1014,8 +1128,8 @@ enum EmailNotificationStatus {
|
||||
/// we store it always, so a customer reply quoting an old email can be traced
|
||||
/// to the exact letter that was sent.
|
||||
model EmailNotificationLog {
|
||||
id String @id @default(uuid())
|
||||
sendDate DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
sendDate DateTime @default(now())
|
||||
notificationType EmailNotificationType
|
||||
/// Per-type discriminator, null where the type has none:
|
||||
/// ACCOUNT_STATUS → 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO")
|
||||
@@ -1033,7 +1147,7 @@ model EmailNotificationLog {
|
||||
/// resolve the owner through `Property.customerId`, so this stays set on
|
||||
/// job 4 too. Null only on skipped rows where the lookup itself failed.
|
||||
customerId String?
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
customerName String
|
||||
customerEmail String
|
||||
/// Subject line of the email we attempted to send.
|
||||
@@ -1041,16 +1155,16 @@ model EmailNotificationLog {
|
||||
/// For PAYMENT_CONFIRMATION: the per-customer URL the PHP code built and
|
||||
/// fetched (kept verbatim so the legacy format is reproducible). Null on
|
||||
/// the other three jobs — the body is built inline.
|
||||
bodyRequestUrl String? @db.Text
|
||||
bodyRequestUrl String? @db.Text
|
||||
/// The HTML body that was sent (or that would have been sent, for SKIPPED
|
||||
/// rows). Stored verbatim so audit/customer-service can read the exact
|
||||
/// letter that went out without re-running the render.
|
||||
bodySnapshot String @db.Text
|
||||
bodySnapshot String @db.Text
|
||||
/// True when `debug` was passed — the recipient was overridden to the
|
||||
/// admin address and no real customer received the mail. Kept here so a
|
||||
/// "where did all these emails go" investigation finds the answer in one
|
||||
/// place instead of "who ran what with what flags" archaeology.
|
||||
debug Boolean @default(false)
|
||||
debug Boolean @default(false)
|
||||
/// SES SendEmail MessageId, when we actually got one back. Null on
|
||||
/// failures, skipped rows, and dev/mock transport.
|
||||
providerMessageId String?
|
||||
@@ -1058,7 +1172,7 @@ model EmailNotificationLog {
|
||||
/// insert so a verbose SES bounce payload can't blow the column.
|
||||
providerResponse String?
|
||||
status EmailNotificationStatus
|
||||
error String? @db.Text
|
||||
error String? @db.Text
|
||||
|
||||
@@index([sendDate])
|
||||
@@index([notificationType, sendDate])
|
||||
@@ -1176,3 +1290,35 @@ model AppSetting {
|
||||
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
/// The EFE cash receipt book (legacy EFE form / EFECTIVO table): the paper the
|
||||
/// office hands a customer who pays in cash. A journal, not a ledger — the
|
||||
/// money reaches the customer's balance through `transaction`, posted with it
|
||||
/// as `C<folio>` exactly as legacy posted to DATOS2. Voiding that row voids
|
||||
/// the receipt.
|
||||
model CashReceipt {
|
||||
id String @id @default(uuid())
|
||||
/// Printed receipt number. Continues the legacy book's sequence.
|
||||
folio Int @unique
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
receivedAt DateTime
|
||||
amount Decimal @db.Decimal(12, 2)
|
||||
currency Currency
|
||||
/// Set when dollars are credited to a pesos ledger, as legacy did; the
|
||||
/// posted row then carries amount × rate in MXN.
|
||||
exchangeRate Decimal? @db.Decimal(10, 4)
|
||||
concept String @db.Text
|
||||
/// ELABORÓ — whoever was logged in.
|
||||
preparedById String
|
||||
preparedBy User @relation("CashReceiptPreparer", fields: [preparedById], references: [id])
|
||||
/// FECHA DEPÓSITO — filled in when the cash reaches the bank.
|
||||
depositedAt DateTime?
|
||||
transactionId String @unique
|
||||
transaction Transaction @relation(fields: [transactionId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([customerId])
|
||||
@@index([receivedAt])
|
||||
@@map("cash_receipts")
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Corte (year-end cut) audit — READ ONLY. Writes nothing, voids nothing.
|
||||
*
|
||||
* Legacy Access ran a corte every year: it moved the year's utility movements
|
||||
* into a per-year table and stamped one BALANCE FORWARD row per customer,
|
||||
* dated Jan 1, carrying the closing balance. The platform inherited the ROWS
|
||||
* (1,170 of them, dated 2026-01-01 — legacy's last cut before the extract) but
|
||||
* not the PROCESS, and BillingService uses those rows as a per-customer floor
|
||||
* (BALANCE_FLOOR_JOIN / NOT_SUPERSEDED in apps/api/src/billing/billing.service.ts).
|
||||
*
|
||||
* This script reports the two populations that floor does not cover:
|
||||
*
|
||||
* A. FLOORLESS customers — no BALANCE FORWARD row at all, so their balance
|
||||
* is a raw lifetime sum. The platform only migrated the CURRENT-year
|
||||
* charge ledger (datos2); the per-year charge tables live in DreamHost
|
||||
* and were never staged. What survives before the cutover is therefore
|
||||
* a cash journal — receipts with no matching charges — so those sums read
|
||||
* as the office owing money it does not owe.
|
||||
*
|
||||
* READ THE COMPOSITION LINE BEFORE ACTING ON THIS. After the prior-period
|
||||
* import the group is mostly insurance-only customers whose rows come from
|
||||
* the seguros database's own EFECTIVO, which is that line's ONLY ledger.
|
||||
* Flooring those deletes receipts instead of removing a double count. The
|
||||
* "floor them, never carry" argument holds for the utilities rows alone.
|
||||
*
|
||||
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt appearing twice, once in
|
||||
* EFECTIVO with folio `N` and once in datos2 with reference `CN`.
|
||||
*
|
||||
* This is NOT an office data-entry defect, which is what it looked like
|
||||
* while the pair count kept growing at ~40/month. EFECTIVO is the paper
|
||||
* receipt book and every receipt in it is POSTED to the datos2 ledger by
|
||||
* design — verified against the live legacy database, 296 of the 297
|
||||
* receipts written in 2026 carry a matching posting. Legacy summed the
|
||||
* ledger alone. The duplication was the migration flattening a journal and
|
||||
* its postings into one table, and since 1.0.26 the application drops the
|
||||
* journal from every balance. Section B now reports what the journal holds
|
||||
* and asserts that none of it still reaches a balance.
|
||||
*
|
||||
* The folio alone neither proves nor disproves a pair, so it is used as a
|
||||
* lead and never as the verdict. Folios are reused, so `C13483` can collide
|
||||
* with an unrelated receipt; folios are also mistyped, so a genuine pair can
|
||||
* carry two different numbers. Detection therefore runs twice — once on the
|
||||
* `CN` cross-reference, once over the C-refs that pass left orphaned, this
|
||||
* time on proximity alone (same customer, within three days) — and BOTH
|
||||
* passes are then judged on the money: identical amount when the two legs
|
||||
* share a currency, or an implied USD->MXN rate inside the band the
|
||||
* exchange_rates table actually observed that year. Anything that fails is
|
||||
* reported apart and must not be counted as duplicated money.
|
||||
*
|
||||
* The second pass is not a refinement. Jorge Jr's own account carries
|
||||
* `C13647` against EFECTIVO folio `13649` — same day, same 3,500.00 — and
|
||||
* POWERS carries `C135808` against `13508`. Folio matching alone reports
|
||||
* both accounts as clean.
|
||||
*
|
||||
* node scripts/corte-audit.mjs # summary + both sections
|
||||
* node scripts/corte-audit.mjs --cutover 2026-01-01
|
||||
* node scripts/corte-audit.mjs --csv-a # per-customer table, section A
|
||||
* node scripts/corte-audit.mjs --csv-b # per-pair table, section B
|
||||
* node scripts/corte-audit.mjs --limit 40 # rows printed per section
|
||||
*
|
||||
* Needs DATABASE_URL. Point it at PROD — a stale copy answers about itself.
|
||||
* On a database imported before the BALANCE FORWARD type was minted those rows
|
||||
* carry typeId NULL instead (see numid.service.ts:80), so the floor is matched
|
||||
* in BOTH shapes here; matching only the type name reports every customer as
|
||||
* floorless on such a copy.
|
||||
*/
|
||||
import pkg from "../packages/database/generated/client/index.js";
|
||||
|
||||
const { PrismaClient } = pkg;
|
||||
|
||||
/** Prisma hands raw DECIMAL back as Decimal|string|null; counts as BigInt. */
|
||||
const d = (v) => (v == null ? 0 : Number(v));
|
||||
const money = (v) => d(v).toFixed(2).padStart(13);
|
||||
/**
|
||||
* Raw DATE/DATETIME columns arrive as JS Date objects. String() would render
|
||||
* them in the host's local zone, which turns a row stored at 2026-01-01 00:00
|
||||
* UTC into "Dec 31" on a US Pacific laptop — the ledger is keyed on UTC dates
|
||||
* everywhere else, so format in UTC and nowhere else.
|
||||
*/
|
||||
const day = (v) => (v == null ? "—" : new Date(v).toISOString().slice(0, 10));
|
||||
|
||||
function arg(args, name, fallback = null) {
|
||||
const i = args.indexOf(name);
|
||||
return i === -1 ? fallback : args[i + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* A row is a balance-forward marker in either of two shapes. Keep in step with
|
||||
* EMPTY_NUMID_SQL in apps/api/src/customers/numid.service.ts.
|
||||
*/
|
||||
const BF_PREDICATE = `(
|
||||
tt.nameEn = 'BALANCE FORWARD'
|
||||
OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1
|
||||
AND t.legacySourceTable = 'datos2')
|
||||
)`;
|
||||
|
||||
/**
|
||||
* The rest of what a balance query drops, over and above the floor. Mirrors
|
||||
* NOT_CASH_JOURNAL and archiveIsHistorySql in billing.service.ts — an audit
|
||||
* that computes a different book than the application is worse than no audit,
|
||||
* because its numbers look authoritative and diff cleanly against yesterday's.
|
||||
*
|
||||
* The database qualifier is not decoration. `SEGUROS 16_be` keeps its own table
|
||||
* called EFECTIVO and that one is the insurance line's only ledger; matching on
|
||||
* the table name alone would report 55,444.95 USD of real receivables as
|
||||
* duplicate cash. See efectivo-is-a-journal-not-a-ledger.
|
||||
*/
|
||||
const NOT_CASH_JOURNAL = `(
|
||||
t.legacySourceDb IS NULL
|
||||
OR t.legacySourceDb <> 'UTILITIES'
|
||||
OR t.legacySourceTable IS NULL
|
||||
OR t.legacySourceTable NOT IN
|
||||
('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015')
|
||||
)`;
|
||||
|
||||
/**
|
||||
* An imported period counts as history below the year start and is dropped at
|
||||
* or above it. Spelled as a positive OR: `NOT (col LIKE ... AND ...)` is NULL
|
||||
* for an app-captured row, which would silently drop every one.
|
||||
*
|
||||
* The bound is the running calendar year, matching currentYearStart() in the
|
||||
* application rather than the cutover — the app's current period is "this
|
||||
* year", whatever cut the data happens to reflect.
|
||||
*/
|
||||
const yearStart = `${new Date().getUTCFullYear()}-01-01`;
|
||||
const ARCHIVE_IS_HISTORY = `(
|
||||
t.legacySourceTable IS NULL
|
||||
OR t.legacySourceTable NOT LIKE 'datos2@%'
|
||||
OR t.transactionDate < '${yearStart}'
|
||||
)`;
|
||||
|
||||
/** Everything a balance drops apart from the floor itself. */
|
||||
const READ_SCOPE = `(${NOT_CASH_JOURNAL} AND ${ARCHIVE_IS_HISTORY})`;
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const limit = Number(arg(args, "--limit", "25"));
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
// ---- cutover -----------------------------------------------------------
|
||||
// Default to the newest balance-forward date actually in the book rather
|
||||
// than to the current year: the cut the data reflects is a fact, not a
|
||||
// preference, and hardcoding 2026 would silently lie on any other copy.
|
||||
const [bfDates] = await prisma.$queryRawUnsafe(`
|
||||
SELECT MAX(t.transactionDate) AS newest, MIN(t.transactionDate) AS oldest,
|
||||
COUNT(*) AS rows_, COUNT(DISTINCT t.customerId) AS custs
|
||||
FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.voidedAt IS NULL AND ${BF_PREDICATE}
|
||||
`);
|
||||
|
||||
const cutover =
|
||||
arg(args, "--cutover") ??
|
||||
(bfDates.newest ? new Date(bfDates.newest).toISOString().slice(0, 10) : null);
|
||||
|
||||
if (!cutover) {
|
||||
console.log("No BALANCE FORWARD rows in this database and no --cutover given.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`corte audit — cutover ${cutover}`);
|
||||
console.log(
|
||||
` balance-forward rows: ${d(bfDates.rows_)} across ${d(bfDates.custs)} customers` +
|
||||
`, dated ${day(bfDates.oldest)}..${day(bfDates.newest)}`,
|
||||
);
|
||||
|
||||
// ---- book totals -------------------------------------------------------
|
||||
const [book] = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
WITH bfloor AS (
|
||||
SELECT t.customerId, MAX(t.transactionDate) AS floorDate
|
||||
FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.voidedAt IS NULL AND ${BF_PREDICATE}
|
||||
GROUP BY t.customerId
|
||||
)
|
||||
SELECT
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS rawMxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS rawUsd,
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||
THEN t.amount ELSE 0 END), 2) AS floorOnlyMxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||
THEN t.amount ELSE 0 END), 2) AS floorOnlyUsd,
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||
AND ${READ_SCOPE} THEN t.amount ELSE 0 END), 2) AS todayMxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||
AND ${READ_SCOPE} THEN t.amount ELSE 0 END), 2) AS todayUsd,
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate >= ?
|
||||
THEN t.amount ELSE 0 END), 2) AS flooredMxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ?
|
||||
THEN t.amount ELSE 0 END), 2) AS flooredUsd
|
||||
FROM transactions t
|
||||
LEFT JOIN bfloor b ON b.customerId = t.customerId
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0
|
||||
`,
|
||||
cutover,
|
||||
cutover,
|
||||
);
|
||||
|
||||
// ---- section A: floorless customers ------------------------------------
|
||||
const floorless = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
WITH nobf AS (
|
||||
SELECT c.id, c.name
|
||||
FROM customers c
|
||||
WHERE EXISTS (SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND ${BF_PREDICATE}
|
||||
)
|
||||
)
|
||||
SELECT n.id, n.name,
|
||||
COUNT(*) AS rows_,
|
||||
SUM(t.transactionDate < ?) AS preRows,
|
||||
SUM(t.transactionDate >= ?) AS postRows,
|
||||
MIN(t.transactionDate) AS firstTx,
|
||||
MAX(t.transactionDate) AS lastTx,
|
||||
SUM(t.domain = 'UTILITY') AS utilRows,
|
||||
SUM(t.domain = 'INSURANCE') AS insRows,
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.outstanding=0 THEN t.amount ELSE 0 END), 2) AS todayMxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' AND t.outstanding=0 THEN t.amount ELSE 0 END), 2) AS todayUsd,
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.outstanding=0 AND t.transactionDate >= ?
|
||||
THEN t.amount ELSE 0 END), 2) AS afterMxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' AND t.outstanding=0 AND t.transactionDate >= ?
|
||||
THEN t.amount ELSE 0 END), 2) AS afterUsd
|
||||
FROM nobf n
|
||||
JOIN transactions t ON t.customerId = n.id AND t.voidedAt IS NULL
|
||||
GROUP BY n.id, n.name
|
||||
ORDER BY ABS(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate < ? THEN t.amount ELSE 0 END)) DESC
|
||||
`,
|
||||
cutover, cutover, cutover, cutover, cutover,
|
||||
);
|
||||
|
||||
// Direction of the pre-cutover history, which is the whole argument for
|
||||
// flooring rather than carrying it: a corte carries a NET, and a net built
|
||||
// from receipts whose charges were never migrated is not one.
|
||||
const [split] = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
WITH nobf AS (
|
||||
SELECT c.id FROM customers c
|
||||
WHERE EXISTS (SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND ${BF_PREDICATE}
|
||||
)
|
||||
)
|
||||
SELECT SUM(t.amount < 0) AS charges, ROUND(SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END), 2) AS chargeMxn,
|
||||
SUM(t.amount > 0) AS credits, ROUND(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 2) AS creditMxn
|
||||
FROM transactions t JOIN nobf n ON n.id = t.customerId
|
||||
WHERE t.voidedAt IS NULL AND t.transactionDate < ?
|
||||
`,
|
||||
cutover,
|
||||
);
|
||||
|
||||
// What the floorless population's balance is actually MADE OF.
|
||||
//
|
||||
// "Floor them, never carry" was written when this group looked like
|
||||
// utilities cash receipts whose charges were never migrated. It is not that
|
||||
// any more. After the prior-period import the group is 99 customers, and
|
||||
// almost all of them are insurance-only — their rows come from the seguros
|
||||
// database's own EFECTIVO, which is that line's ONLY ledger. Nothing posts
|
||||
// it a second time, so flooring it does not remove a double count, it
|
||||
// deletes receipts. Split the two so the remedy is chosen per population
|
||||
// rather than for the group.
|
||||
const [floorlessMix] = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
WITH nobf AS (
|
||||
SELECT c.id FROM customers c
|
||||
WHERE EXISTS (SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND ${BF_PREDICATE}
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' THEN t.customerId END) AS insCusts,
|
||||
ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='MXN'
|
||||
THEN t.amount ELSE 0 END), 2) AS insMxn,
|
||||
ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='USD'
|
||||
THEN t.amount ELSE 0 END), 2) AS insUsd,
|
||||
COUNT(DISTINCT CASE WHEN t.legacySourceDb <> 'SEGUROS 16_be' THEN t.customerId END) AS utilCusts
|
||||
FROM transactions t JOIN nobf n ON n.id = t.customerId
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND t.transactionDate < ?
|
||||
`,
|
||||
cutover,
|
||||
);
|
||||
|
||||
// ---- section B: double-booked receipts ---------------------------------
|
||||
const pairs = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT d.id AS datos2Id, e.id AS efectivoId, c.name,
|
||||
DATE(d.transactionDate) AS datos2Date, DATE(e.transactionDate) AS efectivoDate,
|
||||
d.amount AS datos2Amount, d.currency AS datos2Currency,
|
||||
e.amount AS efectivoAmount, e.currency AS efectivoCurrency,
|
||||
d.reference AS datos2Ref, e.reference AS efectivoRef,
|
||||
fx.lo AS rateLo, fx.hi AS rateHi
|
||||
FROM transactions d
|
||||
JOIN transactions e
|
||||
ON e.customerId = d.customerId
|
||||
AND e.legacySourceTable = 'EFECTIVO'
|
||||
AND e.voidedAt IS NULL
|
||||
AND e.reference = SUBSTRING(d.reference, 2)
|
||||
JOIN customers c ON c.id = d.customerId
|
||||
LEFT JOIN (
|
||||
SELECT YEAR(effectiveDate) AS y, MIN(rate) AS lo, MAX(rate) AS hi
|
||||
FROM exchange_rates GROUP BY YEAR(effectiveDate)
|
||||
) fx ON fx.y = YEAR(d.transactionDate)
|
||||
WHERE d.voidedAt IS NULL
|
||||
AND d.legacySourceTable = 'datos2'
|
||||
AND d.reference REGEXP '^C[0-9]+$'
|
||||
ORDER BY d.transactionDate
|
||||
`,
|
||||
);
|
||||
|
||||
// Pass two — the leads pass one could not follow. Same shape of row, judged
|
||||
// by the same money rules below, so a folio typo costs nothing.
|
||||
const nearby = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT d.id AS datos2Id, e.id AS efectivoId, c.name,
|
||||
DATE(d.transactionDate) AS datos2Date, DATE(e.transactionDate) AS efectivoDate,
|
||||
d.amount AS datos2Amount, d.currency AS datos2Currency,
|
||||
e.amount AS efectivoAmount, e.currency AS efectivoCurrency,
|
||||
d.reference AS datos2Ref, e.reference AS efectivoRef,
|
||||
fx.lo AS rateLo, fx.hi AS rateHi
|
||||
FROM transactions d
|
||||
JOIN transactions e
|
||||
ON e.customerId = d.customerId AND e.legacySourceTable = 'EFECTIVO'
|
||||
AND e.voidedAt IS NULL AND e.amount > 0
|
||||
AND ABS(DATEDIFF(e.transactionDate, d.transactionDate)) <= 3
|
||||
JOIN customers c ON c.id = d.customerId
|
||||
LEFT JOIN (
|
||||
SELECT YEAR(effectiveDate) AS y, MIN(rate) AS lo, MAX(rate) AS hi
|
||||
FROM exchange_rates GROUP BY YEAR(effectiveDate)
|
||||
) fx ON fx.y = YEAR(d.transactionDate)
|
||||
WHERE d.voidedAt IS NULL AND d.legacySourceTable = 'datos2'
|
||||
AND d.reference REGEXP '^C[0-9]+$'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions x
|
||||
WHERE x.customerId = d.customerId AND x.legacySourceTable = 'EFECTIVO'
|
||||
AND x.voidedAt IS NULL AND x.reference = SUBSTRING(d.reference, 2)
|
||||
)
|
||||
ORDER BY d.transactionDate
|
||||
`,
|
||||
);
|
||||
|
||||
const [unpaired] = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT COUNT(*) AS n
|
||||
FROM transactions d
|
||||
WHERE d.voidedAt IS NULL AND d.legacySourceTable = 'datos2'
|
||||
AND d.reference REGEXP '^C[0-9]+$'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions e
|
||||
WHERE e.customerId = d.customerId AND e.legacySourceTable = 'EFECTIVO'
|
||||
AND e.voidedAt IS NULL AND e.reference = SUBSTRING(d.reference, 2)
|
||||
)
|
||||
`,
|
||||
);
|
||||
|
||||
// REGRESSION GUARD. Since 1.0.26 the application drops the whole cash
|
||||
// journal from every balance, so none of section B's rows should reach one
|
||||
// any more. This counts the ones that still do: it is 0 while the exclusion
|
||||
// holds, and goes non-zero the moment someone reintroduces a balance query
|
||||
// that forgets it. A defect list that cannot tell you whether the defect is
|
||||
// still live is just history.
|
||||
const [stillCounted] = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
WITH bfloor AS (
|
||||
SELECT t.customerId, MAX(t.transactionDate) AS floorDate
|
||||
FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.voidedAt IS NULL AND ${BF_PREDICATE}
|
||||
GROUP BY t.customerId
|
||||
)
|
||||
SELECT COUNT(*) AS n,
|
||||
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS mxn,
|
||||
ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS usd
|
||||
FROM transactions t
|
||||
LEFT JOIN bfloor b ON b.customerId = t.customerId
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0
|
||||
AND t.legacySourceDb = 'UTILITIES'
|
||||
AND t.legacySourceTable IN
|
||||
('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015')
|
||||
AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||
AND ${READ_SCOPE}
|
||||
`,
|
||||
);
|
||||
|
||||
// ---- CSV escapes -------------------------------------------------------
|
||||
if (args.includes("--csv-a")) return dumpCsv(floorless);
|
||||
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
|
||||
|
||||
// ---- report ------------------------------------------------------------
|
||||
console.log("\nBOOK (voided and outstanding rows excluded)");
|
||||
console.log(` raw lifetime sum, no floor ${money(book.rawMxn)} MXN ${money(book.rawUsd)} USD`);
|
||||
console.log(` BF floor only (pre-1.0.26) ${money(book.floorOnlyMxn)} MXN ${money(book.floorOnlyUsd)} USD`);
|
||||
console.log(` TODAY, as the app computes it ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`);
|
||||
console.log(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`);
|
||||
console.log(
|
||||
" (the middle line is the floor alone, kept only so older runs of this\n" +
|
||||
" script still diff against something. The app has dropped the cash\n" +
|
||||
" journal and windowed the archives since 1.0.26; USD going to zero on\n" +
|
||||
" the utilities side is correct, that ledger is peso-denominated.)",
|
||||
);
|
||||
|
||||
const preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0);
|
||||
const wouldZero = floorless.filter((r) => d(r.postRows) === 0);
|
||||
const deltaMxn = floorless.reduce((s, r) => s + (d(r.todayMxn) - d(r.afterMxn)), 0);
|
||||
const deltaUsd = floorless.reduce((s, r) => s + (d(r.todayUsd) - d(r.afterUsd)), 0);
|
||||
|
||||
console.log(`\nA. FLOORLESS CUSTOMERS — ${floorless.length}`);
|
||||
console.log(` pre-cutover rows they still count: ${preRowsTotal}`);
|
||||
console.log(` of those rows: ${d(split.charges)} charges (${d(split.chargeMxn).toFixed(2)})` +
|
||||
` vs ${d(split.credits)} credits (${d(split.creditMxn).toFixed(2)})`);
|
||||
console.log(` balance moved by flooring: ${money(-deltaMxn)} MXN ${money(-deltaUsd)} USD`);
|
||||
console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` +
|
||||
` (their balance becomes 0 — an assertion, not a migrated figure)`);
|
||||
console.log(
|
||||
` of the ${floorless.length}: ${d(floorlessMix.insCusts)} carry insurance-line cash` +
|
||||
` (${d(floorlessMix.insMxn).toFixed(2)} MXN / ${d(floorlessMix.insUsd).toFixed(2)} USD)` +
|
||||
`, ${d(floorlessMix.utilCusts)} carry utilities rows`,
|
||||
);
|
||||
console.log(
|
||||
` READ THAT LINE BEFORE FLOORING ANYONE. The seguros EFECTIVO is that\n` +
|
||||
` line's only ledger — nothing posts it twice — so flooring those\n` +
|
||||
` customers deletes receipts rather than removing a double count.\n` +
|
||||
` The argument for flooring holds for the utilities rows alone.`,
|
||||
);
|
||||
console.log(
|
||||
`\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` +
|
||||
` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`,
|
||||
);
|
||||
for (const r of floorless.slice(0, limit)) {
|
||||
console.log(
|
||||
` ${(r.name || "(sin nombre)").slice(0, 30).padEnd(30)}` +
|
||||
` ${String(d(r.preRows)).padStart(4)} ${String(d(r.postRows)).padStart(4)}` +
|
||||
` ${money(r.todayMxn)} ${money(r.afterMxn)} ${day(r.firstTx).padStart(10)}`,
|
||||
);
|
||||
}
|
||||
if (floorless.length > limit) console.log(` ... ${floorless.length - limit} more (--csv-a)`);
|
||||
|
||||
// A folio match is a hypothesis; the money is the evidence. The band is
|
||||
// widened by 10% either side of what exchange_rates observed that year,
|
||||
// because the office keys receipts at its own counter rate, not at a
|
||||
// published one, and a pair should not be called false over a few centavos.
|
||||
const classify = (p) => {
|
||||
const dAmt = d(p.datos2Amount);
|
||||
const eAmt = d(p.efectivoAmount);
|
||||
if (p.datos2Currency === p.efectivoCurrency) {
|
||||
return Math.abs(dAmt - eAmt) < 0.005 ? "confirmed" : "suspect";
|
||||
}
|
||||
if (p.efectivoCurrency !== "USD" || p.datos2Currency !== "MXN") return "suspect";
|
||||
if (!eAmt || !p.rateLo) return "suspect";
|
||||
const implied = dAmt / eAmt;
|
||||
return implied >= d(p.rateLo) * 0.9 && implied <= d(p.rateHi) * 1.1
|
||||
? "confirmed"
|
||||
: "suspect";
|
||||
};
|
||||
for (const p of pairs) {
|
||||
p.pass = "folio";
|
||||
p.verdict = classify(p);
|
||||
}
|
||||
for (const p of nearby) {
|
||||
p.pass = "proximity";
|
||||
p.verdict = classify(p);
|
||||
}
|
||||
pairs.push(...nearby);
|
||||
|
||||
const confirmed = pairs.filter((p) => p.verdict === "confirmed");
|
||||
const suspect = pairs.filter((p) => p.verdict === "suspect");
|
||||
const byCust = new Set(confirmed.map((p) => p.name));
|
||||
const sameCur = confirmed.filter((p) => p.datos2Currency === p.efectivoCurrency);
|
||||
const converted = confirmed.filter((p) => p.efectivoCurrency === "USD" && p.datos2Currency === "MXN");
|
||||
const efecMxn = confirmed.reduce((s, p) => s + (p.efectivoCurrency === "MXN" ? d(p.efectivoAmount) : 0), 0);
|
||||
const efecUsd = confirmed.reduce((s, p) => s + (p.efectivoCurrency === "USD" ? d(p.efectivoAmount) : 0), 0);
|
||||
|
||||
console.log(`\nB. DOUBLE-BOOKED RECEIPTS — ${confirmed.length} confirmed pairs across ${byCust.size} customers`);
|
||||
const byFolio = confirmed.filter((p) => p.pass === "folio").length;
|
||||
console.log(` candidates examined: ${pairs.length} (confirmed ${confirmed.length}, rejected on money ${suspect.length})`);
|
||||
console.log(` found by folio cross-reference: ${byFolio}, by proximity after a folio miss: ${confirmed.length - byFolio}`);
|
||||
console.log(` confirmed same-currency, amount equal to the cent: ${sameCur.length}`);
|
||||
console.log(` confirmed USD receipt posted to datos2 in MXN: ${converted.length}`);
|
||||
console.log(` datos2 C-refs with no EFECTIVO partner at all: ${d(unpaired.n)}`);
|
||||
console.log(` EFECTIVO side of the confirmed pairs: ${money(efecMxn)} MXN ${money(efecUsd)} USD`);
|
||||
console.log(
|
||||
` still reaching a balance after the 1.0.26 exclusion: ${d(stillCounted.n)} rows` +
|
||||
` (${d(stillCounted.mxn).toFixed(2)} MXN / ${d(stillCounted.usd).toFixed(2)} USD)` +
|
||||
`${d(stillCounted.n) === 0 ? " <- 0 is the passing value" : " <- REGRESSION"}`,
|
||||
);
|
||||
console.log(
|
||||
` The rows below still exist and always will; the ledger's own C<folio>\n` +
|
||||
` posting is the copy that counts. This section is now a record of what\n` +
|
||||
` the journal holds, not a list of money being double-counted.`,
|
||||
);
|
||||
console.log(
|
||||
`\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` +
|
||||
` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`,
|
||||
);
|
||||
for (const p of confirmed.slice(0, limit)) {
|
||||
console.log(
|
||||
` ${(p.name || "(sin nombre)").slice(0, 28).padEnd(28)}` +
|
||||
` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` +
|
||||
` ${money(p.datos2Amount)} ${p.datos2Currency}` +
|
||||
` ${money(p.efectivoAmount)} ${p.efectivoCurrency} ${p.datos2Ref}/${p.efectivoRef}`,
|
||||
);
|
||||
}
|
||||
if (confirmed.length > limit) console.log(` ... ${confirmed.length - limit} more (--csv-b)`);
|
||||
|
||||
if (suspect.length) {
|
||||
console.log(`\n REJECTED — folio matched, money did not. Not duplicates on this evidence:`);
|
||||
for (const p of suspect.slice(0, limit)) {
|
||||
const implied =
|
||||
d(p.efectivoAmount) && p.datos2Currency !== p.efectivoCurrency
|
||||
? ` implied ${(d(p.datos2Amount) / d(p.efectivoAmount)).toFixed(2)}`
|
||||
: "";
|
||||
console.log(
|
||||
` ${(p.name || "(sin nombre)").slice(0, 28).padEnd(28)}` +
|
||||
` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` +
|
||||
` ${money(p.datos2Amount)} ${p.datos2Currency}` +
|
||||
` ${money(p.efectivoAmount)} ${p.efectivoCurrency} ${p.datos2Ref}${implied}`,
|
||||
);
|
||||
}
|
||||
if (suspect.length > limit) console.log(` ... ${suspect.length - limit} more (--csv-b)`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"\nNOTE: nothing above has been changed. Section A is a proposal to move the\n" +
|
||||
"floor, not a carried-forward balance: the pre-cutover charge ledger was\n" +
|
||||
"never migrated, so no true opening balance can be computed from this\n" +
|
||||
"database. It exists in the DreamHost per-year tables. Section B is no\n" +
|
||||
"longer an open defect — it was fixed read-side in 1.0.26 — and the line\n" +
|
||||
"that matters there is the regression count, which must stay at 0.",
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function dumpCsv(rows) {
|
||||
if (!rows.length) return;
|
||||
const cols = Object.keys(rows[0]);
|
||||
console.log(cols.join(","));
|
||||
for (const r of rows) {
|
||||
console.log(cols.map((c) => JSON.stringify(r[c] == null ? "" : String(r[c]))).join(","));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user