Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5261c644 | ||
|
|
c239c1a866 | ||
|
|
6468811955 | ||
|
|
ca76f4d57a | ||
|
|
fd5df240bc | ||
|
|
f655fcbb82 | ||
|
|
b016e53893 | ||
|
|
031b13f8c8 | ||
|
|
2cf07ed5ee | ||
|
|
36158ae761 | ||
|
|
fa38ff581e | ||
|
|
c4213aa697 | ||
|
|
f7507f2370 | ||
|
|
2f9a9afc0d | ||
|
|
7e71a993d0 | ||
|
|
aa5867c8ea |
No files matched your search
+2
-1
@@ -22,12 +22,13 @@ OPS_DB_ADMIN_USER=
|
|||||||
OPS_DB_ADMIN_PASSWORD=
|
OPS_DB_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Company info — printed in the header of every report (PDF + browser
|
# 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.
|
# optional; when unset the API falls back to apps/api/assets/company_logo.png.
|
||||||
COMPANY_NAME=Jorge Cuadros & Asociados
|
COMPANY_NAME=Jorge Cuadros & Asociados
|
||||||
COMPANY_ADDRESS_LINE1=
|
COMPANY_ADDRESS_LINE1=
|
||||||
COMPANY_ADDRESS_LINE2=
|
COMPANY_ADDRESS_LINE2=
|
||||||
COMPANY_CITY_STATE=
|
COMPANY_CITY_STATE=
|
||||||
|
COMPANY_MAILING_ADDRESS=
|
||||||
COMPANY_PHONE=
|
COMPANY_PHONE=
|
||||||
COMPANY_EMAIL=
|
COMPANY_EMAIL=
|
||||||
COMPANY_TAX_ID=
|
COMPANY_TAX_ID=
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
# through this workflow at all.
|
# through this workflow at all.
|
||||||
# 4. app (api + web) the new images.
|
# 4. app (api + web) the new images.
|
||||||
# 5. verify ask the running API what it actually is.
|
# 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
|
# 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
|
# 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)"
|
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
||||||
;;
|
;;
|
||||||
esac
|
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",
|
"name": "@jorgecuadros/api",
|
||||||
"version": "1.0.24",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PoliciesModule } from "./policies/policies.module";
|
|||||||
import { PropertiesModule } from "./properties/properties.module";
|
import { PropertiesModule } from "./properties/properties.module";
|
||||||
import { BillingModule } from "./billing/billing.module";
|
import { BillingModule } from "./billing/billing.module";
|
||||||
import { StatementsModule } from "./statements/statements.module";
|
import { StatementsModule } from "./statements/statements.module";
|
||||||
|
import { CashReceiptsModule } from "./cash-receipts/cash-receipts.module";
|
||||||
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
|
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
|
||||||
import { BankModule } from "./bank/bank.module";
|
import { BankModule } from "./bank/bank.module";
|
||||||
import { OpsModule } from "./ops/ops.module";
|
import { OpsModule } from "./ops/ops.module";
|
||||||
@@ -35,6 +36,7 @@ import { AppController } from "./app.controller";
|
|||||||
PropertiesModule,
|
PropertiesModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
StatementsModule,
|
StatementsModule,
|
||||||
|
CashReceiptsModule,
|
||||||
PolicyOcrModule,
|
PolicyOcrModule,
|
||||||
BankModule,
|
BankModule,
|
||||||
OpsModule,
|
OpsModule,
|
||||||
|
|||||||
@@ -149,26 +149,40 @@ describe("balance floor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the source-table exclusion alongside the floor", async () => {
|
it("keeps the source-table exclusion alongside the floor", async () => {
|
||||||
// The two guards answer different questions — one reproduces legacy's
|
// The three guards answer different questions — one windows imported
|
||||||
// DATOS2-only materialization, the other drops superseded history — and
|
// periods, one drops the cash receipt book the ledger already posts, the
|
||||||
// dropping either one changes the customer's balance.
|
// 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"));
|
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
await service.statement("c1");
|
await service.statement("c1");
|
||||||
|
|
||||||
const where = rowsQuery(findMany).where;
|
const where = rowsQuery(findMany).where;
|
||||||
expect(where.OR).toEqual([
|
// 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: null },
|
||||||
{
|
{
|
||||||
legacySourceTable: {
|
legacySourceTable: {
|
||||||
notIn: expect.arrayContaining(["EFECTIVO"]),
|
notIn: expect.arrayContaining(["EFECTIVO"]),
|
||||||
// Imported prior periods are excluded here too. The floor does not
|
|
||||||
// cover them: a customer whose newest BALANCE FORWARD sits inside
|
|
||||||
// an archive floors at that archive's own Jan 1, and every archive
|
|
||||||
// spills a row or two into the following January.
|
|
||||||
not: { startsWith: "datos2@" },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -209,18 +209,6 @@ export const BALANCE_FLOOR_JOIN = Prisma.sql`
|
|||||||
*/
|
*/
|
||||||
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
|
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
|
||||||
|
|
||||||
/**
|
|
||||||
* Source tables excluded from the customer-facing statement.
|
|
||||||
*
|
|
||||||
* 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).
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* `legacySourceTable` of an imported prior period.
|
* `legacySourceTable` of an imported prior period.
|
||||||
*
|
*
|
||||||
@@ -235,7 +223,34 @@ export const periodSourceTable = (year: number) => `datos2@${year}`;
|
|||||||
/** Matches any imported period tag, for discovering which years a customer has. */
|
/** Matches any imported period tag, for discovering which years a customer has. */
|
||||||
export const PERIOD_TABLE_PREFIX = "datos2@";
|
export const PERIOD_TABLE_PREFIX = "datos2@";
|
||||||
|
|
||||||
export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
/**
|
||||||
|
* 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",
|
||||||
"EFECTIVO_BACKUP",
|
"EFECTIVO_BACKUP",
|
||||||
"EFECTIVO FM3",
|
"EFECTIVO FM3",
|
||||||
@@ -243,6 +258,62 @@ export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
|||||||
"IVA 2015",
|
"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()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -412,6 +483,14 @@ export class BillingService {
|
|||||||
async balances(params: BalanceParams) {
|
async balances(params: BalanceParams) {
|
||||||
const { query, page, pageSize, currency, balance, domain, sort } = params;
|
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[] = [];
|
const filters: Prisma.Sql[] = [];
|
||||||
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
||||||
const txFilter = filters.length
|
const txFilter = filters.length
|
||||||
@@ -474,7 +553,7 @@ export class BillingService {
|
|||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
${BALANCE_FLOOR_JOIN}
|
${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
|
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||||
${having}
|
${having}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
@@ -490,7 +569,7 @@ export class BillingService {
|
|||||||
-- Must match the page query's filters exactly, or the total disagrees
|
-- Must match the page query's filters exactly, or the total disagrees
|
||||||
-- with the rows. (The void exclusion was missing here before the
|
-- with the rows. (The void exclusion was missing here before the
|
||||||
-- outstanding work; a voided-only customer inflated the count.)
|
-- 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
|
GROUP BY c.id
|
||||||
${having}
|
${having}
|
||||||
) x
|
) x
|
||||||
@@ -537,11 +616,20 @@ export class BillingService {
|
|||||||
* Two different questions live here and they use different row sets.
|
* Two different questions live here and they use different row sets.
|
||||||
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
|
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
|
||||||
* INVENTORY — what is stored — and count everything not voided. Everything
|
* INVENTORY — what is stored — and count everything not voided. Everything
|
||||||
* under `byCurrency` / `byDomain` is a BALANCE, so it applies NOT_SUPERSEDED
|
* under `byCurrency` / `byDomain` is a BALANCE, so it takes the same scope
|
||||||
* and drops rows an opening balance already accounts for. The four aggregates
|
* `balances()` takes — the opening-balance floor, the cash journal and the
|
||||||
* moved from Prisma groupBy to raw SQL to express that join; groupBy cannot.
|
* 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() {
|
async stats() {
|
||||||
|
const scope = balanceScopeSql();
|
||||||
|
|
||||||
const [movements, ledgerCustomers] = await Promise.all([
|
const [movements, ledgerCustomers] = await Promise.all([
|
||||||
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||||
this.prisma.transaction
|
this.prisma.transaction
|
||||||
@@ -573,7 +661,7 @@ export class BillingService {
|
|||||||
SUM(t.amount > 0) AS creditCount
|
SUM(t.amount > 0) AS creditCount
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
${BALANCE_FLOOR_JOIN}
|
${BALANCE_FLOOR_JOIN}
|
||||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||||
GROUP BY t.currency
|
GROUP BY t.currency
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -589,7 +677,7 @@ export class BillingService {
|
|||||||
SUM(t.amount) AS net, COUNT(*) AS count
|
SUM(t.amount) AS net, COUNT(*) AS count
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
${BALANCE_FLOOR_JOIN}
|
${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
|
GROUP BY t.domain, t.currency
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -610,7 +698,7 @@ export class BillingService {
|
|||||||
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
|
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
${BALANCE_FLOOR_JOIN}
|
${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
|
GROUP BY t.customerId, t.currency
|
||||||
) x
|
) x
|
||||||
GROUP BY currency
|
GROUP BY currency
|
||||||
@@ -784,6 +872,19 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
const isArchive = requested !== thisYear;
|
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
|
// One customer, so the balance floor is a single date rather than the
|
||||||
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
||||||
// the opening balance are already inside it, and showing them would both
|
// the opening balance are already inside it, and showing them would both
|
||||||
@@ -819,26 +920,36 @@ export class BillingService {
|
|||||||
{ legacySourceTable: periodSourceTable(requested) }
|
{ legacySourceTable: periodSourceTable(requested) }
|
||||||
: {
|
: {
|
||||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
// An archive row belongs to this period only as history. Below
|
||||||
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
// the year start it is exactly what `opening` is for, and for the
|
||||||
// (which has no legacySourceTable) silently vanished from the
|
// one customer whose newest BALANCE FORWARD lives *inside* an
|
||||||
// statement while still showing in the movement browser. Rows the app
|
// archive it is the only carry there is — dropping it outright
|
||||||
// books must appear on the customer's statement, so the null case is
|
// understated NUMid 295 by his whole 2025 closing balance, 785.46.
|
||||||
// spelled out.
|
//
|
||||||
|
// 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: [
|
OR: [
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
{
|
{
|
||||||
legacySourceTable: {
|
legacySourceTable: {
|
||||||
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
|
|
||||||
// The archives have to go too, and the floor will not do
|
|
||||||
// it. A customer whose newest BALANCE FORWARD lives inside
|
|
||||||
// an archive floors at that archive's own Jan 1, so all 28
|
|
||||||
// of its rows clear it; and the archives spill rows into
|
|
||||||
// the following January, which clears any floor. Both put a
|
|
||||||
// closed year back into the current one.
|
|
||||||
not: { startsWith: PERIOD_TABLE_PREFIX },
|
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(),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -867,19 +978,6 @@ export class BillingService {
|
|||||||
// opening balance), the earlier rows still have to be *counted* or every
|
// opening balance), the earlier rows still have to be *counted* or every
|
||||||
// balance below is wrong, so they are folded into `opening` rather than
|
// 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.
|
// listed. That is the same thing a BALANCE FORWARD row does, just computed.
|
||||||
//
|
|
||||||
// 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));
|
|
||||||
|
|
||||||
const running = new Map<string, Prisma.Decimal>();
|
const running = new Map<string, Prisma.Decimal>();
|
||||||
/** Balance carried into `yearStart`, per currency. */
|
/** Balance carried into `yearStart`, per currency. */
|
||||||
const opening = new Map<string, Prisma.Decimal>();
|
const opening = new Map<string, Prisma.Decimal>();
|
||||||
|
|||||||
@@ -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,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,
|
IsBoolean,
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Currency } from "@jorgecuadros/database";
|
import { Currency } from "@jorgecuadros/database";
|
||||||
@@ -19,6 +21,8 @@ export class CreateCustomerDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
name!: string;
|
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() addressLine1?: string;
|
||||||
@IsOptional() @IsString() addressLine2?: string;
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
@IsOptional() @IsString() city?: 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ describe("customer file ledger card", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
service: new CustomersService(prisma as never),
|
service: new CustomersService(prisma as never, {} as never),
|
||||||
prisma,
|
prisma,
|
||||||
groupBy,
|
groupBy,
|
||||||
};
|
};
|
||||||
@@ -60,24 +60,25 @@ describe("customer file ledger card", () => {
|
|||||||
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
|
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes imported periods from the totals", async () => {
|
it("counts an archive as history but never as current", async () => {
|
||||||
// The floor alone is not enough: it does not exist for the floorless, and
|
// The floor alone is not enough: a customer floored by an archive clears
|
||||||
// archives carry rows dated past their own period that clear it.
|
// 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"));
|
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
await service.detail("c1");
|
await service.detail("c1");
|
||||||
|
|
||||||
const and = groupBy.mock.calls[0][0].where.AND;
|
const and = groupBy.mock.calls[0][0].where.AND;
|
||||||
expect(and).toEqual(
|
const rule = and.find((c: { OR?: unknown[] }) =>
|
||||||
expect.arrayContaining([
|
JSON.stringify(c).includes("datos2@"),
|
||||||
{
|
);
|
||||||
OR: [
|
expect(rule.OR).toEqual([
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
],
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
},
|
]);
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the cash-source exclusion so it reads like the statement", async () => {
|
it("keeps the cash-source exclusion so it reads like the statement", async () => {
|
||||||
@@ -110,11 +111,12 @@ describe("customer file ledger card", () => {
|
|||||||
await service.detail("c1");
|
await service.detail("c1");
|
||||||
|
|
||||||
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
||||||
expect(include.transactions.where).toMatchObject({
|
expect(include.transactions.where.OR).toEqual([
|
||||||
OR: [
|
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
{ 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 { NumidService } from "./numid.service";
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
import { OnboardCustomerDto } from "./onboard-customer.dto";
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("customers")
|
@Controller("customers")
|
||||||
@@ -45,6 +46,12 @@ export class CustomersController {
|
|||||||
return { candidates: await this.numids.emptyCandidates() };
|
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()
|
@Get()
|
||||||
list(
|
list(
|
||||||
@Query("query") query?: string,
|
@Query("query") query?: string,
|
||||||
@@ -77,6 +84,26 @@ export class CustomersController {
|
|||||||
return c;
|
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")
|
@Patch(":id")
|
||||||
@RequireAbility("customer:update")
|
@RequireAbility("customer:update")
|
||||||
async update(
|
async update(
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { SettingsModule } from "../settings/settings.module";
|
import { SettingsModule } from "../settings/settings.module";
|
||||||
|
import { CashReceiptsModule } from "../cash-receipts/cash-receipts.module";
|
||||||
import { CustomersController } from "./customers.controller";
|
import { CustomersController } from "./customers.controller";
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
import { NumidService } from "./numid.service";
|
import { NumidService } from "./numid.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SettingsModule],
|
imports: [SettingsModule, CashReceiptsModule],
|
||||||
controllers: [CustomersController],
|
controllers: [CustomersController],
|
||||||
providers: [CustomersService, NumidService],
|
providers: [CustomersService, NumidService],
|
||||||
exports: [NumidService],
|
exports: [NumidService],
|
||||||
|
|||||||
@@ -1,31 +1,44 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
import type { OnboardCustomerDto } from "./onboard-customer.dto";
|
||||||
|
import { CashReceiptsService } from "../cash-receipts/cash-receipts.service";
|
||||||
import {
|
import {
|
||||||
BALANCE_FORWARD_TYPE,
|
BALANCE_FORWARD_TYPE,
|
||||||
|
notCashJournal,
|
||||||
PERIOD_TABLE_PREFIX,
|
PERIOD_TABLE_PREFIX,
|
||||||
STATEMENT_EXCLUDED_SOURCE_TABLES,
|
|
||||||
} from "../billing/billing.service";
|
} from "../billing/billing.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps imported prior periods out of a query, NULL-safely.
|
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||||
*
|
*
|
||||||
* A closed year is imported as its own tagged copy of that year's ledger
|
* A closed year is imported as its own tagged copy (`datos2@2025`). Below the
|
||||||
* (`datos2@2025`) and the BALANCE FORWARD rows above it already contain every
|
* year start it is history and counts — for the one customer whose newest
|
||||||
* peso of it. Anything summing a customer's history has to leave the archives
|
* BALANCE FORWARD lives inside an archive it is the only carry there is, and
|
||||||
* out or it counts each closed year twice — see the floor comment below.
|
* 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.
|
||||||
*
|
*
|
||||||
* `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which
|
* Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
|
||||||
* carry no legacySourceTable) need the null branch spelled out or they vanish.
|
* NULL for an app-captured row (no legacySourceTable), dropping every one.
|
||||||
*/
|
*/
|
||||||
const EXCLUDE_ARCHIVES: Prisma.TransactionWhereInput = {
|
const archiveIsHistory = (
|
||||||
|
yearStart: Date,
|
||||||
|
): Prisma.TransactionWhereInput => ({
|
||||||
OR: [
|
OR: [
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
||||||
|
{ transactionDate: { lt: yearStart } },
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
|
||||||
export interface ListParams {
|
export interface ListParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -45,7 +58,10 @@ function toDate(v?: string): Date | null | undefined {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomersService {
|
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. */
|
/** Unified customer list with search + business-line filter, paginated. */
|
||||||
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
||||||
@@ -62,6 +78,7 @@ export class CustomersService {
|
|||||||
{ mobile: { contains: q } },
|
{ mobile: { contains: q } },
|
||||||
{ city: { contains: q } },
|
{ city: { contains: q } },
|
||||||
{ legacyRefs: { some: { legacyId: { contains: q } } } },
|
{ legacyRefs: { some: { legacyId: { contains: q } } } },
|
||||||
|
...(/^\d+$/.test(q) ? [{ folderNumber: Number(q) }] : []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (line === "utility") where.properties = { some: {} };
|
if (line === "utility") where.properties = { some: {} };
|
||||||
@@ -84,6 +101,7 @@ export class CustomersService {
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
nameSource: true,
|
nameSource: true,
|
||||||
|
folderNumber: true,
|
||||||
city: true,
|
city: true,
|
||||||
state: true,
|
state: true,
|
||||||
email: true,
|
email: true,
|
||||||
@@ -100,6 +118,7 @@ export class CustomersService {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
nameSource: r.nameSource,
|
nameSource: r.nameSource,
|
||||||
|
folderNumber: r.folderNumber,
|
||||||
city: r.city,
|
city: r.city,
|
||||||
state: r.state,
|
state: r.state,
|
||||||
email: r.email,
|
email: r.email,
|
||||||
@@ -152,7 +171,15 @@ export class CustomersService {
|
|||||||
// in 2026, datos2@2025 two more — so a date test alone would surface
|
// 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
|
// a closed year's rows in the current year's list, duplicating the
|
||||||
// live ledger's own copy of them for three customers.
|
// live ledger's own copy of them for three customers.
|
||||||
where: { transactionDate: { gte: yearStart }, ...EXCLUDE_ARCHIVES },
|
// 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" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
include: { type: true },
|
include: { type: true },
|
||||||
},
|
},
|
||||||
@@ -197,23 +224,10 @@ export class CustomersService {
|
|||||||
voidedAt: null,
|
voidedAt: null,
|
||||||
outstanding: false,
|
outstanding: false,
|
||||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
// The floor alone would leave the archives out for anyone who has an
|
// The floor alone does not settle the archives: a customer floored by
|
||||||
// opening balance, but 102 customers have none — for them there is no
|
// an archive clears it with every row of that archive, and the rows
|
||||||
// floor at all, and the archives' rows dated past their own period
|
// archives spill into the following January clear any floor.
|
||||||
// clear it even for the rest.
|
AND: [archiveIsHistory(yearStart), notCashJournal()],
|
||||||
AND: [
|
|
||||||
EXCLUDE_ARCHIVES,
|
|
||||||
{
|
|
||||||
OR: [
|
|
||||||
{ legacySourceTable: null },
|
|
||||||
{
|
|
||||||
legacySourceTable: {
|
|
||||||
notIn: [...STATEMENT_EXCLUDED_SOURCE_TABLES],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
@@ -247,6 +261,7 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateCustomerDto) {
|
async create(dto: CreateCustomerDto) {
|
||||||
|
await this.assertFolderFree(dto.folderNumber);
|
||||||
return this.prisma.customer.create({
|
return this.prisma.customer.create({
|
||||||
// App-created rows: nameMissing false (name is required), no legacy
|
// App-created rows: nameMissing false (name is required), no legacy
|
||||||
// provenance — those columns stay null, marking a native record.
|
// provenance — those columns stay null, marking a native record.
|
||||||
@@ -254,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) {
|
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) });
|
return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +361,9 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async restore(id: string) {
|
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({
|
return this.prisma.customer.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { archivedAt: null },
|
data: { archivedAt: null },
|
||||||
@@ -279,9 +373,46 @@ export class CustomersService {
|
|||||||
private async ensureExists(id: string) {
|
private async ensureExists(id: string) {
|
||||||
const found = await this.prisma.customer.findUnique({
|
const found = await this.prisma.customer.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { id: true },
|
select: { id: true, archivedAt: true, folderNumber: true },
|
||||||
});
|
});
|
||||||
if (!found) throw new NotFoundException(`Customer ${id} not found`);
|
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. */
|
/** 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,
|
IsBoolean,
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Currency } from "@jorgecuadros/database";
|
import { Currency } from "@jorgecuadros/database";
|
||||||
@@ -12,6 +14,7 @@ import { Currency } from "@jorgecuadros/database";
|
|||||||
/** Same editable fields as create, all optional. */
|
/** Same editable fields as create, all optional. */
|
||||||
export class UpdateCustomerDto {
|
export class UpdateCustomerDto {
|
||||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
|
@IsOptional() @IsInt() @Min(1) folderNumber?: number | null;
|
||||||
@IsOptional() @IsString() addressLine1?: string;
|
@IsOptional() @IsString() addressLine1?: string;
|
||||||
@IsOptional() @IsString() addressLine2?: string;
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
@IsOptional() @IsString() city?: string;
|
@IsOptional() @IsString() city?: string;
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ describe("renderRenewalEmail", () => {
|
|||||||
expect(result.html).toContain("Calle Uno 123");
|
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", () => {
|
it("uses overdue wording for generation three", () => {
|
||||||
const result = renderRenewalEmail(letter({ generation: 3 }));
|
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>`;
|
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;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
} {
|
} {
|
||||||
|
const includePremium = options.includePremium !== false;
|
||||||
const expired = letter.generation === 3;
|
const expired = letter.generation === 3;
|
||||||
const subject = expired
|
const subject = expired
|
||||||
? `Póliza vencida: ${letter.policyNumber}`
|
? `Póliza vencida: ${letter.policyNumber}`
|
||||||
@@ -51,7 +64,7 @@ export function renderRenewalEmail(letter: RenewalLetterRow): {
|
|||||||
row("Tipo de póliza", letter.policyType),
|
row("Tipo de póliza", letter.policyType),
|
||||||
row("Aseguradora", letter.provider),
|
row("Aseguradora", letter.provider),
|
||||||
row("Fecha de vencimiento", displayDate(letter.policyTo)),
|
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("Cliente", letter.customerName),
|
||||||
row("Correo", letter.customerEmail ?? "No disponible"),
|
row("Correo", letter.customerEmail ?? "No disponible"),
|
||||||
row("Teléfono", phone),
|
row("Teléfono", phone),
|
||||||
|
|||||||
@@ -183,6 +183,50 @@ describe("renewal notices write the shared notification log", () => {
|
|||||||
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
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 () => {
|
it("does not fail a delivered notice when the log write throws", async () => {
|
||||||
const { service, record } = build({});
|
const { service, record } = build({});
|
||||||
record.mockRejectedValue(new Error("log table gone"));
|
record.mockRejectedValue(new Error("log table gone"));
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ class RenewalFlagsDto {
|
|||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SweepRenewalsDto extends RenewalFlagsDto {
|
||||||
|
/** Sweep one aseguradora only (GMX, ANA, …). Omitted = todas. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
providerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
class SendRenewalDto extends RenewalFlagsDto {
|
class SendRenewalDto extends RenewalFlagsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
policyId!: string;
|
policyId!: string;
|
||||||
@@ -43,17 +50,22 @@ export class RenewalsController {
|
|||||||
constructor(private readonly renewals: RenewalsService) {}
|
constructor(private readonly renewals: RenewalsService) {}
|
||||||
|
|
||||||
@Get("pending")
|
@Get("pending")
|
||||||
pending(@Query("days") days?: string) {
|
pending(
|
||||||
|
@Query("days") days?: string,
|
||||||
|
@Query("providerId") providerId?: string,
|
||||||
|
) {
|
||||||
return this.renewals.pending(
|
return this.renewals.pending(
|
||||||
Math.min(365, Math.max(1, Number(days) || 30)),
|
Math.min(365, Math.max(1, Number(days) || 30)),
|
||||||
|
providerId?.trim() || undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("sweep")
|
@Post("sweep")
|
||||||
@RequireAbility("renewal:send")
|
@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, {
|
return this.renewals.sweep((req.user as { id: string }).id, {
|
||||||
debug: dto?.debug,
|
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
|
/** 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
|
* 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> {
|
async scheduledSweep(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.sweep();
|
await this.sweep(undefined, { automatic: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Falló el barrido de renovaciones: ${(error as Error).message}`,
|
`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 today = dateInTimeZone(new Date());
|
||||||
const state = await this.prisma.scheduledJobState.findUnique({
|
const state = await this.prisma.scheduledJobState.findUnique({
|
||||||
where: { name: JOB_NAME },
|
where: { name: JOB_NAME },
|
||||||
@@ -111,6 +118,7 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
item,
|
item,
|
||||||
today,
|
today,
|
||||||
state?.lastSuccessfulAt ?? null,
|
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 debug = !!flags.debug;
|
||||||
|
const providerId = flags.providerId?.trim() || undefined;
|
||||||
|
const includePremium = !flags.automatic;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const state = await this.acquireLock(now);
|
const state = await this.acquireLock(now);
|
||||||
|
|
||||||
@@ -145,6 +165,7 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
cadence,
|
cadence,
|
||||||
today,
|
today,
|
||||||
state.lastSuccessfulAt,
|
state.lastSuccessfulAt,
|
||||||
|
providerId,
|
||||||
);
|
);
|
||||||
eligible += policies.length;
|
eligible += policies.length;
|
||||||
|
|
||||||
@@ -157,13 +178,17 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
await this.recordLog(policy, cadence.generation, "", {
|
await this.recordLog(policy, cadence.generation, "", {
|
||||||
status: "SKIPPED_NO_EMAIL",
|
status: "SKIPPED_NO_EMAIL",
|
||||||
debug,
|
debug,
|
||||||
|
includePremium,
|
||||||
});
|
});
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.deliver(policy, cadence.generation, to, userId, debug);
|
await this.deliver(policy, cadence.generation, to, userId, {
|
||||||
|
debug,
|
||||||
|
includePremium,
|
||||||
|
});
|
||||||
sent++;
|
sent++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push({
|
failures.push({
|
||||||
@@ -182,11 +207,18 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
failed: failures.length,
|
failed: failures.length,
|
||||||
failures,
|
failures,
|
||||||
debug,
|
debug,
|
||||||
|
providerId: providerId ?? null,
|
||||||
};
|
};
|
||||||
// A debug run must not advance `lastSuccessfulAt`: it wrote no
|
// A debug run must not advance `lastSuccessfulAt`: it wrote no
|
||||||
// RenewalNotice rows, so the days it "covered" are still owed, and
|
// RenewalNotice rows, so the days it "covered" are still owed, and
|
||||||
// narrowing tomorrow's window back to a single day would drop them.
|
// 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);
|
void this.audit.log(userId, "renewalNotice.sweep", result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -233,12 +265,14 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
throw new BadRequestException("El cliente no tiene correo registrado.");
|
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(
|
const { sentAt, providerMessageId, addressedTo } = await this.deliver(
|
||||||
policy,
|
policy,
|
||||||
generation,
|
generation,
|
||||||
to,
|
to,
|
||||||
userId,
|
userId,
|
||||||
debug,
|
{ debug, includePremium: true },
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
policyId,
|
policyId,
|
||||||
@@ -270,10 +304,12 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
generation: number,
|
generation: number,
|
||||||
to: string,
|
to: string,
|
||||||
userId?: 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 letter = toRenewalLetterRow(policy, generation);
|
||||||
const message = renderRenewalEmail(letter);
|
const message = renderRenewalEmail(letter, { includePremium });
|
||||||
const addressedTo = debug ? DEBUG_RECIPIENT : to;
|
const addressedTo = debug ? DEBUG_RECIPIENT : to;
|
||||||
|
|
||||||
let result: Awaited<ReturnType<MailService["send"]>>;
|
let result: Awaited<ReturnType<MailService["send"]>>;
|
||||||
@@ -291,6 +327,7 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
status: "FAILED",
|
status: "FAILED",
|
||||||
error: detail,
|
error: detail,
|
||||||
debug,
|
debug,
|
||||||
|
includePremium,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -324,6 +361,7 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
providerResponse: result.response || undefined,
|
providerResponse: result.response || undefined,
|
||||||
sendDate: sentAt,
|
sendDate: sentAt,
|
||||||
debug,
|
debug,
|
||||||
|
includePremium,
|
||||||
});
|
});
|
||||||
void this.audit.log(userId, "renewalNotice.send", {
|
void this.audit.log(userId, "renewalNotice.send", {
|
||||||
policyId: policy.id,
|
policyId: policy.id,
|
||||||
@@ -355,10 +393,15 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
error?: string;
|
error?: string;
|
||||||
sendDate?: Date;
|
sendDate?: Date;
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
|
/** Must match what `deliver` rendered, or `bodySnapshot` shows the
|
||||||
|
* office a letter the customer never received. */
|
||||||
|
includePremium?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const letter = toRenewalLetterRow(policy, generation);
|
const letter = toRenewalLetterRow(policy, generation);
|
||||||
const message = renderRenewalEmail(letter);
|
const message = renderRenewalEmail(letter, {
|
||||||
|
includePremium: outcome.includePremium,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await this.notificationLog.record({
|
await this.notificationLog.record({
|
||||||
notificationType: "RENEWAL_NOTICE",
|
notificationType: "RENEWAL_NOTICE",
|
||||||
@@ -391,11 +434,13 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
cadence: (typeof RENEWAL_CADENCE)[number],
|
cadence: (typeof RENEWAL_CADENCE)[number],
|
||||||
today: Date,
|
today: Date,
|
||||||
lastSuccessfulAt: Date | null,
|
lastSuccessfulAt: Date | null,
|
||||||
|
providerId?: string,
|
||||||
) {
|
) {
|
||||||
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
|
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
|
||||||
return this.prisma.policy.findMany({
|
return this.prisma.policy.findMany({
|
||||||
where: {
|
where: {
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
...(providerId && { insuranceProviderId: providerId }),
|
||||||
policyTo: { gte: window.from, lte: window.to },
|
policyTo: { gte: window.from, lte: window.to },
|
||||||
customer: {
|
customer: {
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface CompanyInfo {
|
|||||||
addressLine2: string;
|
addressLine2: string;
|
||||||
/** "City, State, ZIP, Country" — single line. */
|
/** "City, State, ZIP, Country" — single line. */
|
||||||
cityState: string;
|
cityState: string;
|
||||||
|
/** US postal address, printed after the office address. Optional. */
|
||||||
|
mailingAddress: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
/** Mexican tax ID ("RFC"). Optional. */
|
/** Mexican tax ID ("RFC"). Optional. */
|
||||||
@@ -83,19 +85,23 @@ export function getCompanyInfo(): CompanyInfo {
|
|||||||
}
|
}
|
||||||
cached = {
|
cached = {
|
||||||
name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"),
|
name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"),
|
||||||
addressLine1: envOr(
|
// The office's real locator, as published on jorgecuadros.com. Prod sets
|
||||||
"COMPANY_ADDRESS_LINE1",
|
// no COMPANY_* variables, so these defaults ARE what every printed
|
||||||
"Av. Revolución 1234, Int. 5",
|
// statement shows — they were placeholders until 2026-10.
|
||||||
),
|
addressLine1: envOr("COMPANY_ADDRESS_LINE1", "Quinta Plaza Ste 3-B"),
|
||||||
addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""),
|
addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""),
|
||||||
cityState: envOr(
|
cityState: envOr(
|
||||||
"COMPANY_CITY_STATE",
|
"COMPANY_CITY_STATE",
|
||||||
"Tijuana, Baja California 22000, México",
|
"Rosarito, Baja California, México",
|
||||||
),
|
),
|
||||||
phone: envOr("COMPANY_PHONE", "(664) 000-0000"),
|
mailingAddress: envOr(
|
||||||
email: envOr("COMPANY_EMAIL", "contacto@jorgecuadros.local"),
|
"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", ""),
|
taxId: envOr("COMPANY_TAX_ID", ""),
|
||||||
website: envOr("COMPANY_WEBSITE", "jorgecuadros.local"),
|
website: envOr("COMPANY_WEBSITE", "jorgecuadros.com"),
|
||||||
logoPath,
|
logoPath,
|
||||||
logo,
|
logo,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export function renderPdf(
|
|||||||
company.addressLine1,
|
company.addressLine1,
|
||||||
company.addressLine2,
|
company.addressLine2,
|
||||||
[company.cityState].filter(Boolean).join(" · "),
|
[company.cityState].filter(Boolean).join(" · "),
|
||||||
|
company.mailingAddress,
|
||||||
[company.phone, company.email].filter(Boolean).join(" · "),
|
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||||
company.taxId ? `RFC: ${company.taxId}` : "",
|
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
@@ -363,6 +364,7 @@ export function renderPrintHtml(
|
|||||||
company.addressLine1,
|
company.addressLine1,
|
||||||
company.addressLine2,
|
company.addressLine2,
|
||||||
company.cityState,
|
company.cityState,
|
||||||
|
company.mailingAddress,
|
||||||
[company.phone, company.email].filter(Boolean).join(" · "),
|
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||||
company.taxId ? `RFC: ${company.taxId}` : "",
|
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|||||||
@@ -14,12 +14,20 @@
|
|||||||
* the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash).
|
* the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma, type ServiceKind } from "@jorgecuadros/database";
|
||||||
import {
|
import {
|
||||||
|
BALANCE_FLOOR_JOIN,
|
||||||
BALANCE_FORWARD_TYPE,
|
BALANCE_FORWARD_TYPE,
|
||||||
|
balanceScopeSql,
|
||||||
|
NOT_SUPERSEDED,
|
||||||
|
notCashJournal,
|
||||||
periodSourceTable,
|
periodSourceTable,
|
||||||
} from "../billing/billing.service";
|
} 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 {
|
import {
|
||||||
|
type ColumnDef,
|
||||||
intParam,
|
intParam,
|
||||||
NOT_VOIDED,
|
NOT_VOIDED,
|
||||||
parseDate,
|
parseDate,
|
||||||
@@ -36,19 +44,84 @@ function nameOf(c: { name: string; nameMissing: boolean }): string {
|
|||||||
return c.nameMissing ? "(sin nombre)" : c.name;
|
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 */
|
/* ------------------------------------------------------------------ reports */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LISTADO EN ROJO — overdue customers worklist.
|
* 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 = {
|
const listadoEnRojo: ReportDef = {
|
||||||
slug: "listado-en-rojo",
|
slug: "listado-en-rojo",
|
||||||
title: "Clientes en rojo",
|
title: "Clientes en rojo",
|
||||||
description:
|
description:
|
||||||
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada, " +
|
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada — " +
|
||||||
"ordenados del más antiguo al más reciente.",
|
"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",
|
domain: "estado-cuenta",
|
||||||
legacyName: "LISTADO EN ROJO",
|
legacyName: "LISTADO EN ROJO",
|
||||||
format: "tabular",
|
format: "tabular",
|
||||||
@@ -63,6 +136,45 @@ const listadoEnRojo: ReportDef = {
|
|||||||
],
|
],
|
||||||
defaultValue: "MXN",
|
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",
|
key: "query",
|
||||||
label: "Buscar (nombre o ciudad)",
|
label: "Buscar (nombre o ciudad)",
|
||||||
@@ -71,24 +183,48 @@ const listadoEnRojo: ReportDef = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "id", label: "#", type: "text" },
|
FOLDER_COLUMN,
|
||||||
{ key: "name", label: "Cliente", type: "text" },
|
{ key: "name", label: "Cliente", type: "text" },
|
||||||
{ key: "city", label: "Ciudad", 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: "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) {
|
async run(prisma, p) {
|
||||||
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
||||||
const q = p.query?.trim();
|
const q = p.query?.trim();
|
||||||
const nameFilter = q
|
const since = parseDate(p.since);
|
||||||
? Prisma.sql`AND (c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`
|
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;
|
: Prisma.empty;
|
||||||
|
|
||||||
const bal =
|
const bal =
|
||||||
currency === "USD"
|
currency === "USD"
|
||||||
? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)`
|
? 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)`;
|
: 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<
|
const rows = await prisma.$queryRaw<
|
||||||
Array<{
|
Array<{
|
||||||
@@ -96,38 +232,39 @@ const listadoEnRojo: ReportDef = {
|
|||||||
name: string;
|
name: string;
|
||||||
nameMissing: boolean;
|
nameMissing: boolean;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
movements: bigint | number | string;
|
|
||||||
balance: Prisma.Decimal | null;
|
balance: Prisma.Decimal | null;
|
||||||
lastMovement: Date | null;
|
lastMovement: Date | null;
|
||||||
|
lastPayment: Date | null;
|
||||||
}>
|
}>
|
||||||
>`
|
>`
|
||||||
SELECT c.id, c.name, c.nameMissing, c.city,
|
SELECT c.id, c.name, c.nameMissing, c.city,
|
||||||
COUNT(*) AS movements,
|
|
||||||
${bal} AS balance,
|
${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
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
WHERE t.voidedAt IS NULL ${nameFilter}
|
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
|
GROUP BY c.id, c.name, c.nameMissing, c.city
|
||||||
HAVING ${bal} < -0.005
|
HAVING ${bal} < ${-minOwed}
|
||||||
ORDER BY MAX(t.transactionDate) ASC, c.nameMissing ASC, c.name ASC
|
${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 totalBalance = new Prisma.Decimal(0);
|
||||||
let totalMovs = 0;
|
|
||||||
const out = rows.map((r) => {
|
const out = rows.map((r) => {
|
||||||
const b = r.balance ?? new Prisma.Decimal(0);
|
const b = r.balance ?? new Prisma.Decimal(0);
|
||||||
totalBalance = totalBalance.plus(b);
|
totalBalance = totalBalance.plus(b);
|
||||||
totalMovs += Number(r.movements);
|
|
||||||
return {
|
return {
|
||||||
id: r.id.slice(0, 8),
|
folder: nums.folder(r.id),
|
||||||
name: nameOf(r),
|
name: nameOf(r),
|
||||||
city: r.city ?? "—",
|
city: r.city ?? "—",
|
||||||
movements: Number(r.movements),
|
lastMovement: isoDay(r.lastMovement),
|
||||||
|
lastPayment: isoDay(r.lastPayment),
|
||||||
balance: b.toFixed(2),
|
balance: b.toFixed(2),
|
||||||
lastMovement: r.lastMovement
|
|
||||||
? r.lastMovement.toISOString().slice(0, 10)
|
|
||||||
: "—",
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,31 +272,58 @@ const listadoEnRojo: ReportDef = {
|
|||||||
rows: out,
|
rows: out,
|
||||||
totals: {
|
totals: {
|
||||||
customers: out.length,
|
customers: out.length,
|
||||||
movements: totalMovs,
|
|
||||||
balance: totalBalance.toFixed(2),
|
balance: totalBalance.toFixed(2),
|
||||||
currency,
|
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).
|
* 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
|
* Legacy `PAGO AGUA/LUZ/TEL NO`: every customer with the service, LEFT JOIN
|
||||||
* charge-type transaction in the window counts as "they paid". The
|
* `LOCALIZA PAGO *` — the rows of THAT service's concept dated in the period
|
||||||
* report groups by property so the same customer with two water meters
|
* the operator typed — where nothing was found. So: "whose water did we not
|
||||||
* appears once per property.
|
* 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 = {
|
const pagosNoEfectuados: ReportDef = {
|
||||||
slug: "pagos-no-efectuados",
|
slug: "pagos-no-efectuados",
|
||||||
title: "Pagos no efectuados",
|
title: "Pagos no efectuados",
|
||||||
description:
|
description:
|
||||||
"Clientes con un servicio contratado (agua, luz o teléfono) sin " +
|
"Clientes con el servicio contratado sin un pago de ese servicio " +
|
||||||
"movimientos de cargo en los últimos N días. Heurística basada en el " +
|
"registrado en el periodo. Sin fechas, revisa un ciclo hacia atrás " +
|
||||||
"servicio registrado en la propiedad y la ausencia de cargos en el " +
|
"desde hoy: 31 días, o 62 para luz (bimestral).",
|
||||||
"periodo seleccionado.",
|
|
||||||
domain: "servicios",
|
domain: "servicios",
|
||||||
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
|
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
|
||||||
format: "tabular",
|
format: "tabular",
|
||||||
@@ -168,139 +332,143 @@ const pagosNoEfectuados: ReportDef = {
|
|||||||
key: "serviceKind",
|
key: "serviceKind",
|
||||||
label: "Servicio",
|
label: "Servicio",
|
||||||
kind: "select",
|
kind: "select",
|
||||||
options: [
|
options: SERVICE_OPTIONS,
|
||||||
{ value: "WATER", label: "Agua" },
|
|
||||||
{ value: "ELECTRICITY", label: "Luz" },
|
|
||||||
{ value: "PHONE", label: "Teléfono" },
|
|
||||||
],
|
|
||||||
defaultValue: "WATER",
|
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",
|
key: "inactive",
|
||||||
label: "Días sin movimiento",
|
label: "Clientes inactivos",
|
||||||
kind: "number",
|
|
||||||
defaultValue: "60",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "currency",
|
|
||||||
label: "Moneda",
|
|
||||||
kind: "select",
|
kind: "select",
|
||||||
options: [
|
options: [
|
||||||
{ value: "MXN", label: "MXN" },
|
{ value: "no", label: "Excluir" },
|
||||||
{ value: "USD", label: "USD" },
|
{ value: "yes", label: "Incluir" },
|
||||||
],
|
],
|
||||||
defaultValue: "MXN",
|
defaultValue: "no",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
FOLDER_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
||||||
{ key: "accountNumber", label: "Cuenta / Medidor", type: "text" },
|
{ key: "accountNumber", label: "Cuenta", type: "text" },
|
||||||
{ key: "lastCharge", label: "Último cargo", type: "date" },
|
{ key: "status", label: "Estado", type: "text" },
|
||||||
{ key: "balance", label: "Saldo", type: "money", align: "right" },
|
{ key: "lastPaid", label: "Último pago del servicio", type: "date" },
|
||||||
],
|
],
|
||||||
async run(prisma, p) {
|
async run(prisma, p) {
|
||||||
const kind = (p.serviceKind ?? "WATER") as
|
const kind = (
|
||||||
| "WATER"
|
SERVICE_OPTIONS.some((o) => o.value === p.serviceKind)
|
||||||
| "ELECTRICITY"
|
? p.serviceKind
|
||||||
| "PHONE";
|
: "WATER"
|
||||||
const days = intParam(p, "days", 60, 1, 365);
|
) as ServiceKind;
|
||||||
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
const concept = CONCEPT_BY_KIND[kind]!;
|
||||||
const cutoff = new Date(Date.now() - days * 86400000);
|
// 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<
|
const rows = await prisma.$queryRaw<
|
||||||
Array<{
|
Array<{
|
||||||
customerId: string;
|
customerId: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
nameMissing: boolean;
|
nameMissing: boolean;
|
||||||
propertyId: string;
|
addresses: string | null;
|
||||||
propertyAddress: string | null;
|
accounts: string | null;
|
||||||
accountNumber: string | null;
|
unfunded: bigint | number;
|
||||||
lastCharge: Date | null;
|
lastPaid: Date | null;
|
||||||
balance: Prisma.Decimal | null;
|
|
||||||
}>
|
}>
|
||||||
>`
|
>`
|
||||||
SELECT
|
SELECT
|
||||||
c.id AS customerId,
|
c.id AS customerId,
|
||||||
c.name AS customerName,
|
c.name AS customerName,
|
||||||
c.nameMissing AS nameMissing,
|
c.nameMissing AS nameMissing,
|
||||||
pr.id AS propertyId,
|
GROUP_CONCAT(DISTINCT pr.addressLine1 ORDER BY pr.addressLine1 SEPARATOR ' / ') AS addresses,
|
||||||
pr.addressLine1 AS propertyAddress,
|
GROUP_CONCAT(DISTINCT NULLIF(ps.accountNumber, '') SEPARATOR ', ') AS accounts,
|
||||||
ps.accountNumber AS accountNumber,
|
EXISTS(${conceptRow(1)}) AS unfunded,
|
||||||
(SELECT MAX(t.transactionDate) FROM transactions t
|
(SELECT MAX(t.transactionDate) FROM transactions t
|
||||||
|
JOIN type_transactions tt ON tt.id = t.typeId
|
||||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||||
AND t.amount < 0
|
AND t.outstanding = 0 AND tt.nameEn = ${concept}) AS lastPaid
|
||||||
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
|
|
||||||
FROM property_services ps
|
FROM property_services ps
|
||||||
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
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
|
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
||||||
WHERE ps.kind = ${kind} AND ps.active = 1
|
WHERE ps.kind = ${kind} AND ps.active = 1 ${inactive}
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (${conceptRow(0)})
|
||||||
SELECT 1 FROM transactions t
|
GROUP BY c.id, c.name, c.nameMissing
|
||||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
ORDER BY c.nameMissing ASC, c.name ASC
|
||||||
AND t.amount < 0 AND t.transactionDate >= ${cutoff}
|
|
||||||
)
|
|
||||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 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 out = rows.map((r) => {
|
||||||
const b = r.balance ?? new Prisma.Decimal(0);
|
if (Number(r.unfunded)) unfunded++;
|
||||||
totalBalance = totalBalance.plus(b);
|
|
||||||
return {
|
return {
|
||||||
customerId: r.customerId.slice(0, 8),
|
folder: nums.folder(r.customerId),
|
||||||
customerName: nameOf({
|
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
|
||||||
name: r.customerName,
|
propertyAddress: r.addresses ?? "—",
|
||||||
nameMissing: r.nameMissing,
|
accountNumber: r.accounts ?? "—",
|
||||||
}),
|
status: Number(r.unfunded) ? "Sin fondos" : "Sin pago",
|
||||||
propertyAddress: r.propertyAddress ?? "—",
|
lastPaid: isoDay(r.lastPaid),
|
||||||
accountNumber: r.accountNumber ?? "—",
|
|
||||||
lastCharge: r.lastCharge
|
|
||||||
? r.lastCharge.toISOString().slice(0, 10)
|
|
||||||
: "—",
|
|
||||||
balance: b.toFixed(2),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: out,
|
rows: out,
|
||||||
totals: {
|
totals: {
|
||||||
rows: out.length,
|
clientes: out.length,
|
||||||
balance: totalBalance.toFixed(2),
|
"sin pago": out.length - unfunded,
|
||||||
currency,
|
"sin fondos": unfunded,
|
||||||
},
|
},
|
||||||
subtitle: `Servicio: ${
|
subtitle: `Servicio: ${serviceLabel(kind)} · ${isoDay(from)} a ${isoDay(to)} · ${out.length} clientes sin pago registrado`,
|
||||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
|
||||||
} · ${days} días · ${out.length} propiedades sin cargo reciente`,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FALTANTES DE (AGUA / LUZ / TEL).
|
* What each service must have on file to be paid and matched — the columns
|
||||||
* Data-quality report: properties enrolled in a service that are missing
|
* legacy's FALTANTES DE AGUA/LUZ/TEL read (DATMEX.AGUA; MEDIDOR + RPU;
|
||||||
* the key identifier the legacy system required (account/meter/route).
|
* TELEFONO), in the field `scopedRefField` stores each one in. The first port
|
||||||
* Different `faltante` per service kind in the legacy because the
|
* demanded account AND meter AND route of every kind; water and phone have no
|
||||||
* service's identifier fields differ; here we flag any of the three
|
* route, so every one of them was flagged.
|
||||||
* common identifiers being blank.
|
*/
|
||||||
|
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 = {
|
const faltantes: ReportDef = {
|
||||||
slug: "faltantes",
|
slug: "faltantes",
|
||||||
title: "Faltantes de datos por servicio",
|
title: "Faltantes de datos por servicio",
|
||||||
description:
|
description:
|
||||||
"Calidad de datos: propiedades con un servicio contratado que no " +
|
"Calidad de datos: servicios activos sin el dato que se necesita para " +
|
||||||
"tienen número de cuenta, medidor o ruta registrado. El reporte que " +
|
"pagar el recibo — agua: número de cuenta; luz: RPU y medidor; " +
|
||||||
"en la legacy corría como FALTANTES DE AGUA / LUZ / TEL.",
|
"teléfono: número.",
|
||||||
domain: "servicios",
|
domain: "servicios",
|
||||||
legacyName: "FALTANTES DE AGUA/LUZ/TEL",
|
legacyName: "FALTANTES DE AGUA/LUZ/TEL",
|
||||||
format: "tabular",
|
format: "tabular",
|
||||||
@@ -309,37 +477,34 @@ const faltantes: ReportDef = {
|
|||||||
key: "serviceKind",
|
key: "serviceKind",
|
||||||
label: "Servicio",
|
label: "Servicio",
|
||||||
kind: "select",
|
kind: "select",
|
||||||
options: [
|
options: SERVICE_OPTIONS.filter((o) => REQUIRED_FIELDS[o.value]),
|
||||||
{ value: "WATER", label: "Agua" },
|
|
||||||
{ value: "ELECTRICITY", label: "Luz" },
|
|
||||||
{ value: "PHONE", label: "Teléfono" },
|
|
||||||
],
|
|
||||||
defaultValue: "WATER",
|
defaultValue: "WATER",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
FOLDER_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
||||||
{ key: "missing", label: "Faltante", type: "text" },
|
{ key: "missing", label: "Faltante", type: "text" },
|
||||||
{ key: "dueDay", label: "Día de vencimiento", type: "text" },
|
|
||||||
],
|
],
|
||||||
async run(prisma, p) {
|
async run(prisma, p) {
|
||||||
const kind = (p.serviceKind ?? "WATER") as
|
const kind = (
|
||||||
| "WATER"
|
p.serviceKind && REQUIRED_FIELDS[p.serviceKind as ServiceKind]
|
||||||
| "ELECTRICITY"
|
? p.serviceKind
|
||||||
| "PHONE";
|
: "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<
|
const rows = await prisma.$queryRaw<
|
||||||
Array<{
|
Array<{
|
||||||
customerId: string;
|
customerId: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
nameMissing: boolean;
|
nameMissing: boolean;
|
||||||
propertyId: string;
|
|
||||||
propertyAddress: string | null;
|
propertyAddress: string | null;
|
||||||
dueDay: string | null;
|
|
||||||
missing: string;
|
missing: string;
|
||||||
}>
|
}>
|
||||||
>`
|
>`
|
||||||
@@ -347,44 +512,30 @@ const faltantes: ReportDef = {
|
|||||||
c.id AS customerId,
|
c.id AS customerId,
|
||||||
c.name AS customerName,
|
c.name AS customerName,
|
||||||
c.nameMissing AS nameMissing,
|
c.nameMissing AS nameMissing,
|
||||||
pr.id AS propertyId,
|
|
||||||
pr.addressLine1 AS propertyAddress,
|
pr.addressLine1 AS propertyAddress,
|
||||||
ps.dueDay AS dueDay,
|
CONCAT_WS(', ', ${Prisma.join(
|
||||||
CASE
|
required.map(([field, label]) => Prisma.sql`IF(${blank(field)}, ${label}, NULL)`),
|
||||||
WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta'
|
)}) AS missing
|
||||||
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
|
|
||||||
FROM property_services ps
|
FROM property_services ps
|
||||||
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
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
|
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
||||||
WHERE ps.kind = ${kind} AND ps.active = 1
|
WHERE ps.kind = ${kind} AND ps.active = 1 AND c.status = 1
|
||||||
AND (
|
AND (${Prisma.join(required.map(([field]) => blank(field)), " OR ")})
|
||||||
ps.accountNumber IS NULL OR ps.accountNumber = ''
|
|
||||||
OR ps.meterNumber IS NULL OR ps.meterNumber = ''
|
|
||||||
OR ps.route IS NULL OR ps.route = ''
|
|
||||||
)
|
|
||||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
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) => ({
|
const out = rows.map((r) => ({
|
||||||
customerId: r.customerId.slice(0, 8),
|
folder: nums.folder(r.customerId),
|
||||||
customerName: nameOf({
|
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
|
||||||
name: r.customerName,
|
|
||||||
nameMissing: r.nameMissing,
|
|
||||||
}),
|
|
||||||
propertyAddress: r.propertyAddress ?? "—",
|
propertyAddress: r.propertyAddress ?? "—",
|
||||||
missing: r.missing,
|
missing: r.missing,
|
||||||
dueDay: r.dueDay ?? "—",
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: out,
|
rows: out,
|
||||||
totals: { rows: out.length },
|
totals: { rows: out.length },
|
||||||
subtitle: `Servicio: ${
|
subtitle: `Servicio: ${serviceLabel(kind)} · ${out.length} servicios con datos faltantes`,
|
||||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
|
||||||
} · ${out.length} propiedades con datos faltantes`,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -399,8 +550,8 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
slug: "reporte-de-efectivo",
|
slug: "reporte-de-efectivo",
|
||||||
title: "Reporte de efectivo",
|
title: "Reporte de efectivo",
|
||||||
description:
|
description:
|
||||||
"Recibos de efectivo en el periodo seleccionado. Cubre los abonos " +
|
"Recibos de efectivo en el periodo seleccionado: los recibos EFE de la " +
|
||||||
"provenientes de las tablas legacy EFECTIVO* y los créditos sin " +
|
"plataforma, los de las tablas legacy EFECTIVO* y los créditos sin " +
|
||||||
"número de cheque (efectivo real). El match del reporte original.",
|
"número de cheque (efectivo real). El match del reporte original.",
|
||||||
domain: "chequera",
|
domain: "chequera",
|
||||||
legacyName: "REPORTE DE EFECTIVO",
|
legacyName: "REPORTE DE EFECTIVO",
|
||||||
@@ -421,6 +572,7 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "date", label: "Fecha", type: "date" },
|
{ key: "date", label: "Fecha", type: "date" },
|
||||||
|
FOLDER_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "concept", label: "Concepto", type: "text" },
|
{ key: "concept", label: "Concepto", type: "text" },
|
||||||
{ key: "source", label: "Origen", type: "text" },
|
{ key: "source", label: "Origen", type: "text" },
|
||||||
@@ -435,8 +587,13 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
NOT_VOIDED,
|
NOT_VOIDED,
|
||||||
{ amount: { gt: 0 } },
|
{ amount: { gt: 0 } },
|
||||||
{ currency },
|
{ currency },
|
||||||
|
// A credit opening balance has no check number but is not cash.
|
||||||
|
{ OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] },
|
||||||
{
|
{
|
||||||
OR: [
|
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"] } },
|
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
|
||||||
{
|
{
|
||||||
AND: [
|
AND: [
|
||||||
@@ -466,19 +623,22 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
checkNumber: true,
|
checkNumber: true,
|
||||||
message: true,
|
message: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
|
cashReceipt: { select: { folio: true } },
|
||||||
type: { select: { nameEs: true, nameEn: 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);
|
let total = new Prisma.Decimal(0);
|
||||||
const out = rows.map((r) => {
|
const out = rows.map((r) => {
|
||||||
total = total.plus(r.amount);
|
total = total.plus(r.amount);
|
||||||
return {
|
return {
|
||||||
date: r.transactionDate.toISOString().slice(0, 10),
|
date: r.transactionDate.toISOString().slice(0, 10),
|
||||||
|
folder: nums.folder(r.customer.id),
|
||||||
customerName: nameOf(r.customer),
|
customerName: nameOf(r.customer),
|
||||||
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
|
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),
|
amount: r.amount.toFixed(2),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -540,6 +700,8 @@ const vigente: ReportDef = {
|
|||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "policyNumber", label: "Póliza", type: "text" },
|
{ key: "policyNumber", label: "Póliza", type: "text" },
|
||||||
|
FOLDER_COLUMN,
|
||||||
|
INSURANCE_NO_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "provider", label: "Aseguradora", type: "text" },
|
{ key: "provider", label: "Aseguradora", type: "text" },
|
||||||
{ key: "agent", label: "Agente", type: "text" },
|
{ key: "agent", label: "Agente", type: "text" },
|
||||||
@@ -570,11 +732,12 @@ const vigente: ReportDef = {
|
|||||||
policyTo: true,
|
policyTo: true,
|
||||||
netPremium: true,
|
netPremium: true,
|
||||||
agentName: true,
|
agentName: true,
|
||||||
customer: { select: { name: true, nameMissing: true } },
|
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||||
insuranceProvider: { select: { name: true } },
|
insuranceProvider: { select: { name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||||
let totalPremium = new Prisma.Decimal(0);
|
let totalPremium = new Prisma.Decimal(0);
|
||||||
const out = rows.map((r) => {
|
const out = rows.map((r) => {
|
||||||
const daysTo = r.policyTo
|
const daysTo = r.policyTo
|
||||||
@@ -585,6 +748,8 @@ const vigente: ReportDef = {
|
|||||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||||
return {
|
return {
|
||||||
policyNumber: r.policyNumber,
|
policyNumber: r.policyNumber,
|
||||||
|
folder: nums.folder(r.customer.id),
|
||||||
|
insuranceNo: nums.insurance(r.customer.id),
|
||||||
customerName: nameOf(r.customer),
|
customerName: nameOf(r.customer),
|
||||||
provider: r.insuranceProvider?.name ?? "—",
|
provider: r.insuranceProvider?.name ?? "—",
|
||||||
agent: r.agentName ?? "—",
|
agent: r.agentName ?? "—",
|
||||||
@@ -688,6 +853,8 @@ const avisoRenovacion: ReportDef = {
|
|||||||
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
|
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "policyNumber", label: "Póliza", type: "text" },
|
{ key: "policyNumber", label: "Póliza", type: "text" },
|
||||||
|
FOLDER_COLUMN,
|
||||||
|
INSURANCE_NO_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "provider", label: "Aseguradora", type: "text" },
|
{ key: "provider", label: "Aseguradora", type: "text" },
|
||||||
{ key: "policyTo", label: "Vence", type: "date" },
|
{ key: "policyTo", label: "Vence", type: "date" },
|
||||||
@@ -719,11 +886,16 @@ const avisoRenovacion: ReportDef = {
|
|||||||
select: renewalLetterSelect(generation),
|
select: renewalLetterSelect(generation),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||||
let totalPremium = new Prisma.Decimal(0);
|
let totalPremium = new Prisma.Decimal(0);
|
||||||
let sentCount = 0;
|
let sentCount = 0;
|
||||||
const out = rows.map((r) => {
|
const out = rows.map((r) => {
|
||||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
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++;
|
if (letter.sentAt) sentCount++;
|
||||||
return letter;
|
return letter;
|
||||||
});
|
});
|
||||||
@@ -801,6 +973,7 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
|
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
|
// The source-table exclusion, the balance floor and the year scope below
|
||||||
// are BillingService.statement's, because this report and
|
// are BillingService.statement's, because this report and
|
||||||
@@ -835,25 +1008,28 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
{ legacySourceTable: periodSourceTable(requestedYear) }
|
{ legacySourceTable: periodSourceTable(requestedYear) }
|
||||||
: {
|
: {
|
||||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
|
// Archive rows count as history below the year start (that is
|
||||||
// drops every app-captured row (they have no legacySourceTable) — the
|
// what `opening` is for, and for a customer floored by an archive
|
||||||
// same defect this report's on-screen twin was fixed for.
|
// 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: [
|
OR: [
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
{
|
{
|
||||||
legacySourceTable: {
|
transactionDate: {
|
||||||
notIn: [
|
lt: new Date(Date.UTC(requestedYear, 0, 1)),
|
||||||
"EFECTIVO",
|
},
|
||||||
"EFECTIVO_BACKUP",
|
},
|
||||||
"EFECTIVO FM3",
|
|
||||||
"CHEQUE FM3",
|
|
||||||
"IVA 2015",
|
|
||||||
],
|
],
|
||||||
// Archives out of the current period too — the balance
|
|
||||||
// floor does not exclude them (see the on-screen twin).
|
|
||||||
not: { startsWith: "datos2@" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
// 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(),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -962,6 +1138,7 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
{
|
{
|
||||||
__kind: "header",
|
__kind: "header",
|
||||||
name: nameOf(customer),
|
name: nameOf(customer),
|
||||||
|
folder,
|
||||||
address: customer.addressLine1 ?? "",
|
address: customer.addressLine1 ?? "",
|
||||||
city: [customer.city, customer.state].filter(Boolean).join(", "),
|
city: [customer.city, customer.state].filter(Boolean).join(", "),
|
||||||
phone: customer.phone ?? "",
|
phone: customer.phone ?? "",
|
||||||
@@ -979,7 +1156,7 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
...carried,
|
...carried,
|
||||||
...movements,
|
...movements,
|
||||||
],
|
],
|
||||||
subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
|
subtitle: `Carpeta ${folder} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1017,6 +1194,7 @@ const chequeCount: ReportDef = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
|
FOLDER_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "reference", label: "Referencia", type: "text" },
|
{ key: "reference", label: "Referencia", type: "text" },
|
||||||
{ key: "period", label: "Periodo", type: "text" },
|
{ key: "period", label: "Periodo", type: "text" },
|
||||||
@@ -1046,7 +1224,7 @@ const chequeCount: ReportDef = {
|
|||||||
period: true,
|
period: true,
|
||||||
outstanding: true,
|
outstanding: true,
|
||||||
type: { select: { nameEn: true, nameEs: true } },
|
type: { select: { nameEn: true, nameEs: true } },
|
||||||
customer: { select: { name: true, nameMissing: true } },
|
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1072,8 +1250,10 @@ const chequeCount: ReportDef = {
|
|||||||
}
|
}
|
||||||
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
|
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
|
||||||
|
|
||||||
|
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||||
return {
|
return {
|
||||||
rows: rows.map((r) => ({
|
rows: rows.map((r) => ({
|
||||||
|
folder: nums.folder(r.customer.id),
|
||||||
customerName: nameOf(r.customer),
|
customerName: nameOf(r.customer),
|
||||||
reference: r.reference ?? "—",
|
reference: r.reference ?? "—",
|
||||||
period: r.period ?? "—",
|
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 {
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
IsInt,
|
||||||
@@ -6,7 +8,9 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
||||||
|
|
||||||
export class CreateStatementBatchDto {
|
export class CreateStatementBatchDto {
|
||||||
@@ -27,6 +31,22 @@ export class ReviewDocumentDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(StatementDocumentStatus)
|
@IsEnum(StatementDocumentStatus)
|
||||||
status?: Extract<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">;
|
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,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
|
Put,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@@ -22,7 +23,7 @@ import { RequireAbility } from "../auth/require-ability.decorator";
|
|||||||
import { AuditService } from "../common/audit.service";
|
import { AuditService } from "../common/audit.service";
|
||||||
import type { UploadedFileLike } from "../storage/upload-file";
|
import type { UploadedFileLike } from "../storage/upload-file";
|
||||||
import { StatementsService } from "./statements.service";
|
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).
|
* Statement OCR intake (RECEIPT_CAPTURE_SPEC §2).
|
||||||
@@ -117,6 +118,39 @@ export class StatementsController {
|
|||||||
return batch;
|
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")
|
@Patch("documents/:id")
|
||||||
@RequireAbility("statement:review")
|
@RequireAbility("statement:review")
|
||||||
async review(
|
async review(
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ import type { UploadedFileLike } from "../storage/upload-file";
|
|||||||
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
||||||
import { parseStatement } from "./parsers/statement-parser";
|
import { parseStatement } from "./parsers/statement-parser";
|
||||||
import { StatementMatcherService, scopedRefField } from "./statement-matcher.service";
|
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
|
* Default ledger concept per service kind. The names are the legacy
|
||||||
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
|
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
|
||||||
* per confirm rather than hard-coded as ids, which differ per environment.
|
* 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",
|
ELECTRIC: "ELECTRIC",
|
||||||
WATER: "WATER",
|
WATER: "WATER",
|
||||||
TELEPHONE: "TELEPHONE",
|
TELEPHONE: "TELEPHONE",
|
||||||
@@ -74,6 +74,49 @@ export class StatementsService {
|
|||||||
uploadedById: string,
|
uploadedById: string,
|
||||||
label?: 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 (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
|
||||||
if (!(await this.ocr.available())) {
|
if (!(await this.ocr.available())) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -88,23 +131,24 @@ export class StatementsService {
|
|||||||
"guardar los recibos escaneados.",
|
"guardar los recibos escaneados.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const batch = await this.prisma.statementBatch.create({
|
private startProcessing(
|
||||||
data: { serviceKind, uploadedById, label, fileCount: files.length },
|
batchId: string,
|
||||||
});
|
files: UploadedFileLike[],
|
||||||
|
serviceKind: ServiceKind,
|
||||||
|
afterPage: number,
|
||||||
|
) {
|
||||||
// Buffers are held for the background pass; the request's own copies would
|
// Buffers are held for the background pass; the request's own copies would
|
||||||
// otherwise be garbage once the response is sent.
|
// otherwise be garbage once the response is sent.
|
||||||
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
||||||
void this.process(batch.id, copies, serviceKind).catch(async (err) => {
|
void this.process(batchId, copies, serviceKind, afterPage).catch(async (err) => {
|
||||||
this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`);
|
this.logger.error(`Batch ${batchId} failed: ${(err as Error).message}`);
|
||||||
await this.prisma.statementBatch.update({
|
await this.prisma.statementBatch.update({
|
||||||
where: { id: batch.id },
|
where: { id: batchId },
|
||||||
data: { status: "FAILED", error: (err as Error).message },
|
data: { status: "FAILED", error: (err as Error).message },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return batch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render → OCR → parse → match, one document row per page. */
|
/** Render → OCR → parse → match, one document row per page. */
|
||||||
@@ -112,13 +156,14 @@ export class StatementsService {
|
|||||||
batchId: string,
|
batchId: string,
|
||||||
files: { buffer: Buffer; name?: string }[],
|
files: { buffer: Buffer; name?: string }[],
|
||||||
serviceKind: ServiceKind,
|
serviceKind: ServiceKind,
|
||||||
|
afterPage = 0,
|
||||||
) {
|
) {
|
||||||
await this.prisma.statementBatch.update({
|
await this.prisma.statementBatch.update({
|
||||||
where: { id: batchId },
|
where: { id: batchId },
|
||||||
data: { status: "PROCESSING" },
|
data: { status: "PROCESSING" },
|
||||||
});
|
});
|
||||||
|
|
||||||
let pageNumber = 0;
|
let pageNumber = afterPage;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// The source PDF is kept as well as the page images: it is the artifact
|
// 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
|
// 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 } },
|
where: { batchId: id, status: { in: OPEN } },
|
||||||
_sum: { extractedAmount: true },
|
_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 {
|
return {
|
||||||
...batch,
|
...batch,
|
||||||
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
||||||
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00",
|
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 } },
|
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. */
|
/** Staff correction of an extracted field or of the match itself. */
|
||||||
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) {
|
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) throw new NotFoundException("Documento no encontrado.");
|
||||||
if (doc.status === "POSTED") {
|
if (doc.status === "POSTED") {
|
||||||
throw new BadRequestException("Este documento ya fue registrado.");
|
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
|
// 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
|
// 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,
|
extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
||||||
matchedPropertyServiceId,
|
matchedPropertyServiceId,
|
||||||
matchedCustomerId,
|
matchedCustomerId,
|
||||||
|
outstanding: dto.outstanding ?? undefined,
|
||||||
status: dto.status ?? "MATCHED",
|
status: dto.status ?? "MATCHED",
|
||||||
reviewedById,
|
reviewedById,
|
||||||
reviewedAt: new Date(),
|
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) {
|
async reject(id: string, reviewedById: string) {
|
||||||
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||||
@@ -414,9 +539,10 @@ export class StatementsService {
|
|||||||
where: {
|
where: {
|
||||||
batchId,
|
batchId,
|
||||||
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
|
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
|
||||||
matchedCustomerId: { not: null },
|
OR: [{ matchedCustomerId: { not: null } }, { splits: { some: {} } }],
|
||||||
},
|
},
|
||||||
orderBy: { pageNumber: "asc" },
|
orderBy: { pageNumber: "asc" },
|
||||||
|
include: { splits: { orderBy: { sequence: "asc" } } },
|
||||||
});
|
});
|
||||||
if (!docs.length) {
|
if (!docs.length) {
|
||||||
throw new BadRequestException("No hay documentos listos para registrar.");
|
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));
|
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(
|
const result = await this.billing.createBatch(
|
||||||
{
|
{
|
||||||
domain: "UTILITY",
|
domain: "UTILITY",
|
||||||
@@ -439,36 +596,44 @@ export class StatementsService {
|
|||||||
checkNumber: dto.checkNumber,
|
checkNumber: dto.checkNumber,
|
||||||
currency: dto.currency ?? "MXN",
|
currency: dto.currency ?? "MXN",
|
||||||
typeId,
|
typeId,
|
||||||
lines: docs.map((d) => ({
|
lines: posts.map((p) => ({
|
||||||
customerId: d.matchedCustomerId!,
|
customerId: p.customerId,
|
||||||
// Charges are negative in this ledger: a negative amount is what the
|
// Charges are negative in this ledger: a negative amount is what the
|
||||||
// customer owes. The parser reads the printed (positive) figure, so
|
// customer owes. The parser reads the printed (positive) figure, so
|
||||||
// the sign is applied here, at the single point where a statement
|
// the sign is applied here, at the single point where a statement
|
||||||
// becomes a ledger row.
|
// becomes a ledger row.
|
||||||
amount: -Math.abs(Number(d.extractedAmount)),
|
amount: -Math.abs(p.amount),
|
||||||
reference: d.extractedAccountRef ?? undefined,
|
reference: p.doc.extractedAccountRef ?? undefined,
|
||||||
period: d.extractedPeriod ?? undefined,
|
period: p.doc.extractedPeriod ?? undefined,
|
||||||
outstanding: dto.outstanding ?? false,
|
// 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
|
// `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
|
||||||
// the created rows zip straight back onto the documents that produced them.
|
// the created rows zip straight back onto the records that produced them.
|
||||||
await this.prisma.$transaction(
|
// A split receipt's rows hang off its parts; the document itself keeps no
|
||||||
docs.map((d, i) =>
|
// single posted row.
|
||||||
this.prisma.statementDocument.update({
|
const now = new Date();
|
||||||
where: { id: d.id },
|
await this.prisma.$transaction([
|
||||||
data: {
|
...posts.map((p, i) =>
|
||||||
status: "POSTED",
|
p.splitId
|
||||||
postedTransactionId: result.items[i].id,
|
? this.prisma.statementDocumentSplit.update({
|
||||||
reviewedById,
|
where: { id: p.splitId },
|
||||||
reviewedAt: new Date(),
|
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
|
// 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
|
// hand because the scoped field was blank, writing the reference back means
|
||||||
@@ -479,7 +644,12 @@ export class StatementsService {
|
|||||||
|
|
||||||
await this.closeIfDone(batchId);
|
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. */
|
/** Write a confirmed reference onto a service that had none. */
|
||||||
@@ -524,3 +694,14 @@ export class StatementsService {
|
|||||||
return row?.id;
|
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",
|
"name": "@jorgecuadros/web",
|
||||||
"version": "1.0.24",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4500",
|
"dev": "next dev -p 4500",
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ function Hero({
|
|||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|
||||||
const facts: { label: string; value: string }[] = [
|
const facts: { label: string; value: string }[] = [
|
||||||
|
{ label: "Carpeta", value: data.folderNumber != null ? String(data.folderNumber) : "—" },
|
||||||
{ label: "Cliente desde", value: formatDate(data.customerSince) },
|
{ label: "Cliente desde", value: formatDate(data.customerSince) },
|
||||||
{
|
{
|
||||||
label: "Cuota",
|
label: "Cuota",
|
||||||
|
|||||||
@@ -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 && (
|
{canCreate && (
|
||||||
<Link href="/clientes/nuevo" className="btn btn-primary">
|
<>
|
||||||
+ Nuevo cliente
|
<Link href="/clientes/nuevo" className="btn btn-outline">
|
||||||
|
+ Solo cliente
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/clientes/alta" className="btn btn-primary">
|
||||||
|
+ Alta de cliente
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StatStrip stats={stats} />
|
<StatStrip stats={stats} />
|
||||||
@@ -125,7 +130,7 @@ function ClientesBrowser() {
|
|||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
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"
|
aria-label="Buscar clientes"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,6 +266,8 @@ function CustomerRow({ c }: { c: CustomerListItem }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="cust-sub">
|
<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 && <span>{location}</span>}
|
||||||
{location && contact && <span className="sep">·</span>}
|
{location && contact && <span className="sep">·</span>}
|
||||||
{contact && <span>{contact}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,18 +6,21 @@ import { AppShell } from "@/components/AppShell";
|
|||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
||||||
import {
|
import {
|
||||||
|
addStatementFiles,
|
||||||
confirmStatementBatch,
|
confirmStatementBatch,
|
||||||
discardStatementBatch,
|
discardStatementBatch,
|
||||||
getStatementBatch,
|
getStatementBatch,
|
||||||
listStatementDocuments,
|
listStatementDocuments,
|
||||||
rejectStatementDocument,
|
rejectStatementDocument,
|
||||||
reviewStatementDocument,
|
reviewStatementDocument,
|
||||||
|
setStatementSplits,
|
||||||
statementPageUrl,
|
statementPageUrl,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useCan } from "@/lib/abilities";
|
import { useCan } from "@/lib/abilities";
|
||||||
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
|
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
|
||||||
import type {
|
import type {
|
||||||
ConfirmBatchInput,
|
ConfirmBatchInput,
|
||||||
|
SplitPartInput,
|
||||||
StatementBatchDetail,
|
StatementBatchDetail,
|
||||||
StatementDocument,
|
StatementDocument,
|
||||||
StatementDocumentStatus,
|
StatementDocumentStatus,
|
||||||
@@ -64,6 +67,7 @@ export default function RecibosBatchPage({ params }: { params: { id: string } })
|
|||||||
|
|
||||||
function BatchReview({ id }: { id: string }) {
|
function BatchReview({ id }: { id: string }) {
|
||||||
const canReview = useCan("statement:review");
|
const canReview = useCan("statement:review");
|
||||||
|
const canIngest = useCan("statement:ingest");
|
||||||
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
|
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
|
||||||
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -108,7 +112,7 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const readyCount = docs.filter(
|
const readyCount = docs.filter(
|
||||||
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
(d) => d.status === "MATCHED" && (d.matchedCustomer || d.splits.length > 0),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
async function discard() {
|
async function discard() {
|
||||||
@@ -162,9 +166,14 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
|
|
||||||
<SummaryCard batch={batch} readyCount={readyCount} />
|
<SummaryCard batch={batch} readyCount={readyCount} />
|
||||||
|
|
||||||
|
{canIngest && !processing && batch.status !== "DISCARDED" && (
|
||||||
|
<AddFilesCard batchId={id} onDone={load} setError={setError} />
|
||||||
|
)}
|
||||||
|
|
||||||
{canReview && readyCount > 0 && (
|
{canReview && readyCount > 0 && (
|
||||||
<ConfirmCard
|
<ConfirmCard
|
||||||
batchId={id}
|
batchId={id}
|
||||||
|
lastCheckNumber={batch.lastCheckNumber}
|
||||||
readyCount={readyCount}
|
readyCount={readyCount}
|
||||||
onDone={load}
|
onDone={load}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
@@ -291,16 +300,19 @@ function SummaryCard({
|
|||||||
*/
|
*/
|
||||||
function ConfirmCard({
|
function ConfirmCard({
|
||||||
batchId,
|
batchId,
|
||||||
|
lastCheckNumber,
|
||||||
readyCount,
|
readyCount,
|
||||||
onDone,
|
onDone,
|
||||||
setError,
|
setError,
|
||||||
}: {
|
}: {
|
||||||
batchId: string;
|
batchId: string;
|
||||||
|
lastCheckNumber: string | null;
|
||||||
readyCount: number;
|
readyCount: number;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
setError: (m: string | null) => 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(
|
const [transactionDate, setTransactionDate] = useState(
|
||||||
new Date().toISOString().slice(0, 10),
|
new Date().toISOString().slice(0, 10),
|
||||||
);
|
);
|
||||||
@@ -322,9 +334,9 @@ function ConfirmCard({
|
|||||||
};
|
};
|
||||||
const r = await confirmStatementBatch(batchId, input);
|
const r = await confirmStatementBatch(batchId, input);
|
||||||
setResult(
|
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();
|
onDone();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
|
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
|
||||||
@@ -363,7 +375,7 @@ function ConfirmCard({
|
|||||||
checked={outstanding}
|
checked={outstanding}
|
||||||
onChange={(e) => setOutstanding(e.target.checked)}
|
onChange={(e) => setOutstanding(e.target.checked)}
|
||||||
/>{" "}
|
/>{" "}
|
||||||
Sin fondos (queda pendiente)
|
Todo el cheque sin fondos
|
||||||
</label>
|
</label>
|
||||||
<label className="check">
|
<label className="check">
|
||||||
<input
|
<input
|
||||||
@@ -389,12 +401,65 @@ function ConfirmCard({
|
|||||||
)}
|
)}
|
||||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||||
Se registran como cargos del cliente, por la misma vía que la captura
|
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>
|
</p>
|
||||||
</section>
|
</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({
|
function DocumentRow({
|
||||||
doc,
|
doc,
|
||||||
canReview,
|
canReview,
|
||||||
@@ -411,6 +476,7 @@ function DocumentRow({
|
|||||||
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
|
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
|
||||||
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
|
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
|
||||||
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
|
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
|
||||||
|
const [outstanding, setOutstanding] = useState(doc.outstanding);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -424,6 +490,7 @@ function DocumentRow({
|
|||||||
accountRef: accountRef.trim() || undefined,
|
accountRef: accountRef.trim() || undefined,
|
||||||
amount: amount ? Number(amount) : undefined,
|
amount: amount ? Number(amount) : undefined,
|
||||||
matchedCustomerId: customerId || undefined,
|
matchedCustomerId: customerId || undefined,
|
||||||
|
outstanding,
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
onChange();
|
onChange();
|
||||||
@@ -456,9 +523,21 @@ function DocumentRow({
|
|||||||
<div>
|
<div>
|
||||||
<strong>Página {doc.pageNumber}</strong>{" "}
|
<strong>Página {doc.pageNumber}</strong>{" "}
|
||||||
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
|
<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>}
|
{doc.provider && <span className="page-sub">{doc.provider}</span>}
|
||||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
<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}`}>
|
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
|
||||||
{doc.matchedCustomer.name}
|
{doc.matchedCustomer.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -533,10 +612,18 @@ function DocumentRow({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={outstanding}
|
||||||
|
onChange={(e) => setOutstanding(e.target.checked)}
|
||||||
|
/>{" "}
|
||||||
|
Sin fondos
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={busy || !customerId}
|
disabled={busy || (!customerId && doc.splits.length === 0)}
|
||||||
onClick={() => save("MATCHED")}
|
onClick={() => save("MATCHED")}
|
||||||
>
|
>
|
||||||
Guardar
|
Guardar
|
||||||
@@ -552,9 +639,164 @@ function DocumentRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canReview && !settled && (
|
||||||
|
<SplitEditor doc={doc} onChange={onChange} />
|
||||||
|
)}
|
||||||
|
|
||||||
{err && <div className="state-box state-error">{err}</div>}
|
{err && <div className="state-box state-error">{err}</div>}
|
||||||
</div>
|
</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",
|
ability: "ledger:create",
|
||||||
aliases: ["/recibos"],
|
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: "/estado-cuenta", label: "Estado de cuenta" },
|
||||||
{ href: "/banco", label: "Chequera" },
|
{ href: "/banco", label: "Chequera" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { CustomerDetail, CustomerInput, Currency } from "@/lib/types";
|
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. */
|
/** ISO date (yyyy-mm-dd) for a date input, from an API date string. */
|
||||||
function toDateInput(v: string | null | undefined): string {
|
function toDateInput(v: string | null | undefined): string {
|
||||||
@@ -20,6 +20,7 @@ function numOrUndef(v: string | number | null | undefined): number | undefined {
|
|||||||
|
|
||||||
type Values = {
|
type Values = {
|
||||||
name: string;
|
name: string;
|
||||||
|
folderNumber: string;
|
||||||
addressLine1: string;
|
addressLine1: string;
|
||||||
addressLine2: string;
|
addressLine2: string;
|
||||||
city: string;
|
city: string;
|
||||||
@@ -45,6 +46,7 @@ type Values = {
|
|||||||
function initial(c?: CustomerDetail): Values {
|
function initial(c?: CustomerDetail): Values {
|
||||||
return {
|
return {
|
||||||
name: c?.name ?? "",
|
name: c?.name ?? "",
|
||||||
|
folderNumber: c?.folderNumber != null ? String(c.folderNumber) : "",
|
||||||
addressLine1: c?.addressLine1 ?? "",
|
addressLine1: c?.addressLine1 ?? "",
|
||||||
addressLine2: c?.addressLine2 ?? "",
|
addressLine2: c?.addressLine2 ?? "",
|
||||||
city: c?.city ?? "",
|
city: c?.city ?? "",
|
||||||
@@ -85,6 +87,17 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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]) {
|
function set<K extends keyof Values>(key: K, val: Values[K]) {
|
||||||
setV((prev) => ({ ...prev, [key]: val }));
|
setV((prev) => ({ ...prev, [key]: val }));
|
||||||
}
|
}
|
||||||
@@ -95,6 +108,8 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const payload: CustomerInput = {
|
const payload: CustomerInput = {
|
||||||
name: v.name.trim(),
|
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),
|
addressLine1: s(v.addressLine1),
|
||||||
addressLine2: s(v.addressLine2),
|
addressLine2: s(v.addressLine2),
|
||||||
city: s(v.city),
|
city: s(v.city),
|
||||||
@@ -138,6 +153,10 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
|||||||
<input className="input" required value={v.name}
|
<input className="input" required value={v.name}
|
||||||
onChange={(e) => set("name", e.target.value)} />
|
onChange={(e) => set("name", e.target.value)} />
|
||||||
</Field>
|
</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">
|
<Field label="Correo">
|
||||||
<input className="input" type="email" value={v.email}
|
<input className="input" type="email" value={v.email}
|
||||||
onChange={(e) => set("email", e.target.value)} />
|
onChange={(e) => set("email", e.target.value)} />
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { useCan } from "@/lib/abilities";
|
import { useCan } from "@/lib/abilities";
|
||||||
import { formatDate, formatMoney } from "@/lib/labels";
|
import { formatDate, formatMoney } from "@/lib/labels";
|
||||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
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
|
* 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
|
* 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
|
* inbox. It additionally does NOT mark the notice as sent, so a test send
|
||||||
* leaves the row exactly where it was — pending.
|
* 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 {
|
export interface RenewalLetter {
|
||||||
@@ -46,6 +58,8 @@ export interface RenewalSweepResult {
|
|||||||
failed: number;
|
failed: number;
|
||||||
failures: { policyId: string; generation: number; error: string }[];
|
failures: { policyId: string; generation: number; error: string }[];
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
|
/** Echoed back so the confirmation says which carrier actually ran. */
|
||||||
|
providerId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenewalSendResult {
|
export interface RenewalSendResult {
|
||||||
@@ -68,6 +82,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
const allowed = useCan("renewal:send");
|
const allowed = useCan("renewal:send");
|
||||||
const debug = !!flags.debug;
|
const debug = !!flags.debug;
|
||||||
const [days, setDays] = useState(30);
|
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 [pending, setPending] = useState<RenewalLetter[] | null>(null);
|
||||||
const [pendingError, setPendingError] = useState<string | null>(null);
|
const [pendingError, setPendingError] = useState<string | null>(null);
|
||||||
const [actionError, setActionError] = 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 () => {
|
const refresh = useCallback(async () => {
|
||||||
setPendingError(null);
|
setPendingError(null);
|
||||||
try {
|
try {
|
||||||
|
const params = new URLSearchParams({ days: String(days) });
|
||||||
|
if (providerId) params.set("providerId", providerId);
|
||||||
const data = await apiFetch<RenewalLetter[]>(
|
const data = await apiFetch<RenewalLetter[]>(
|
||||||
`/renewals/pending?days=${days}`,
|
`/renewals/pending?${params.toString()}`,
|
||||||
);
|
);
|
||||||
setPending(data);
|
setPending(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -91,18 +111,44 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
);
|
);
|
||||||
setPending([]);
|
setPending([]);
|
||||||
}
|
}
|
||||||
}, [days]);
|
}, [days, providerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (allowed) refresh();
|
if (allowed) refresh();
|
||||||
}, [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() {
|
async function handleSweep() {
|
||||||
// Only worth confirming when debug is off — that is the case where real
|
// Only worth confirming when debug is off — that is the case where real
|
||||||
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab.
|
// 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) {
|
if (!debug) {
|
||||||
const ok = window.confirm(
|
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;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
@@ -112,10 +158,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
try {
|
try {
|
||||||
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
|
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ debug }),
|
body: JSON.stringify({ debug, providerId: providerId || undefined }),
|
||||||
});
|
});
|
||||||
setNotice(
|
setNotice(
|
||||||
`Enviados ${result.sent} avisos (${result.failed} con error).` +
|
`Enviados ${result.sent} avisos de ${providerLabel} ` +
|
||||||
|
`(${result.failed} con error).` +
|
||||||
(result.debug
|
(result.debug
|
||||||
? " Modo debug: fueron al buzón de pruebas y siguen pendientes."
|
? " 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>
|
<h2 className="section-title">Barrido manual</h2>
|
||||||
<p className="muted small" style={{ marginTop: 4 }}>
|
<p className="muted small" style={{ marginTop: 4 }}>
|
||||||
Usa la fecha actual del servidor como referencia para seleccionar
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -211,7 +260,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
{sweeping ? "Enviando…" : "Ejecutar barrido"}
|
{sweeping ? "Enviando…" : "Ejecutar barrido"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="field" style={{ maxWidth: 180, marginTop: 12, marginBottom: 0 }}>
|
<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>
|
<span className="field-label">Ventana (días)</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -224,6 +277,22 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
{pendingError && <div className="state-box state-error">{pendingError}</div>}
|
{pendingError && <div className="state-box state-error">{pendingError}</div>}
|
||||||
|
|||||||
@@ -390,6 +390,9 @@ function StatementLayout({ result }: { result: ReportRunResult }) {
|
|||||||
<div className="statement">
|
<div className="statement">
|
||||||
<header className="statement-head">
|
<header className="statement-head">
|
||||||
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
|
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
|
||||||
|
{header.folder && header.folder !== "—" ? (
|
||||||
|
<p className="muted">Carpeta {String(header.folder)}</p>
|
||||||
|
) : null}
|
||||||
{Boolean(header.address) && (
|
{Boolean(header.address) && (
|
||||||
<p className="muted">{String(header.address)}</p>
|
<p className="muted">{String(header.address)}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+77
-4
@@ -31,6 +31,7 @@ import type {
|
|||||||
UpdateBankAccountInput,
|
UpdateBankAccountInput,
|
||||||
ResolveOutstandingInput,
|
ResolveOutstandingInput,
|
||||||
ReviewDocumentInput,
|
ReviewDocumentInput,
|
||||||
|
SplitPartInput,
|
||||||
StatementBatch,
|
StatementBatch,
|
||||||
StatementBatchDetail,
|
StatementBatchDetail,
|
||||||
StatementDocument,
|
StatementDocument,
|
||||||
@@ -80,6 +81,11 @@ import type {
|
|||||||
TransactionDomain,
|
TransactionDomain,
|
||||||
TrustFilter,
|
TrustFilter,
|
||||||
UserRow,
|
UserRow,
|
||||||
|
CashReceipt,
|
||||||
|
CashReceiptInput,
|
||||||
|
CompanyHeader,
|
||||||
|
OnboardInput,
|
||||||
|
OnboardResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// Resolve the API origin at runtime, not build time — so one built image serves
|
// 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}` : ""}`);
|
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> {
|
export function getCustomer(id: string): Promise<CustomerDetail> {
|
||||||
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
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> {
|
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
|
||||||
return apiFetch<CustomerDetail>("/customers", {
|
return apiFetch<CustomerDetail>("/customers", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1375,17 +1394,26 @@ export function listStatementDocuments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Multi-file upload — one batch is usually several multi-page scans. */
|
/** Multi-file upload — one batch is usually several multi-page scans. */
|
||||||
export async function uploadStatementBatch(
|
export function uploadStatementBatch(
|
||||||
files: File[],
|
files: File[],
|
||||||
serviceKind: ServiceKind,
|
serviceKind: ServiceKind,
|
||||||
label?: string,
|
label?: string,
|
||||||
): Promise<StatementBatch> {
|
): Promise<StatementBatch> {
|
||||||
const body = new FormData();
|
|
||||||
for (const f of files) body.append("files", f, f.name);
|
|
||||||
const qs = new URLSearchParams({ serviceKind });
|
const qs = new URLSearchParams({ serviceKind });
|
||||||
if (label) qs.set("label", label);
|
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",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body,
|
body,
|
||||||
@@ -1413,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> {
|
export function rejectStatementDocument(id: string): Promise<StatementDocument> {
|
||||||
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
|
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
|
||||||
}
|
}
|
||||||
@@ -1437,6 +1476,40 @@ export function statementPageUrl(documentId: string): string {
|
|||||||
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
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) */
|
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
|
||||||
|
|
||||||
export function getPolicyOcrStatus(): Promise<{
|
export function getPolicyOcrStatus(): Promise<{
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ export interface CustomerListItem {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Legacy table the name was recovered from; null when it came from DATGRAL. */
|
/** Legacy table the name was recovered from; null when it came from DATGRAL. */
|
||||||
nameSource: string | null;
|
nameSource: string | null;
|
||||||
|
folderNumber: number | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -1113,6 +1114,7 @@ export interface CustomerDetail {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
nameSource: string | null;
|
nameSource: string | null;
|
||||||
|
folderNumber: number | null;
|
||||||
addressLine1: string | null;
|
addressLine1: string | null;
|
||||||
addressLine2: string | null;
|
addressLine2: string | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
@@ -1146,6 +1148,8 @@ export interface CustomerDetail {
|
|||||||
/** Editable customer fields — shared by the create/edit form and the API. */
|
/** Editable customer fields — shared by the create/edit form and the API. */
|
||||||
export interface CustomerInput {
|
export interface CustomerInput {
|
||||||
name: string;
|
name: string;
|
||||||
|
/** null clears it. */
|
||||||
|
folderNumber?: number | null;
|
||||||
addressLine1?: string;
|
addressLine1?: string;
|
||||||
addressLine2?: string;
|
addressLine2?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
@@ -1411,6 +1415,8 @@ export interface StatementBatchDetail extends StatementBatch {
|
|||||||
byStatus: Partial<Record<StatementDocumentStatus, number>>;
|
byStatus: Partial<Record<StatementDocumentStatus, number>>;
|
||||||
/** Sum of the amounts still awaiting posting. */
|
/** Sum of the amounts still awaiting posting. */
|
||||||
pendingTotal: string;
|
pendingTotal: string;
|
||||||
|
/** Check the batch was last posted against, to prefill the next confirm. */
|
||||||
|
lastCheckNumber: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatementDocument {
|
export interface StatementDocument {
|
||||||
@@ -1434,6 +1440,22 @@ export interface StatementDocument {
|
|||||||
property: { id: string; addressLine1: string | null };
|
property: { id: string; addressLine1: string | null };
|
||||||
} | null;
|
} | null;
|
||||||
postedTransactionId: string | 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 {
|
export interface ReviewDocumentInput {
|
||||||
@@ -1444,6 +1466,7 @@ export interface ReviewDocumentInput {
|
|||||||
matchedPropertyServiceId?: string;
|
matchedPropertyServiceId?: string;
|
||||||
matchedCustomerId?: string;
|
matchedCustomerId?: string;
|
||||||
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
|
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
|
||||||
|
outstanding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check-level fields shared by every line posted from a batch. */
|
/** Check-level fields shared by every line posted from a batch. */
|
||||||
@@ -1459,6 +1482,8 @@ export interface ConfirmBatchInput {
|
|||||||
export interface ConfirmBatchResult {
|
export interface ConfirmBatchResult {
|
||||||
posted: number;
|
posted: number;
|
||||||
total: string;
|
total: string;
|
||||||
|
/** How many of the posted rows went in sin fondos. */
|
||||||
|
outstanding: number;
|
||||||
checkNumber: string;
|
checkNumber: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,3 +1681,68 @@ export interface PolicyOcrConfirmResult {
|
|||||||
policies: string[];
|
policies: string[];
|
||||||
postedTransactions: number;
|
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);
|
||||||
|
});
|
||||||
+3
-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.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.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.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 —
|
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
|
an OCR path that turns a carrier PDF into a `Policy` row covers some of what
|
||||||
|
|||||||
@@ -166,12 +166,17 @@ def resolve_name(raw, nid, index) -> tuple[str, str | None]:
|
|||||||
|
|
||||||
# ------------------------------- record builders ---------------------------- #
|
# ------------------------------- record builders ---------------------------- #
|
||||||
def customer_from_utilities(row, name_index) -> dict:
|
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(
|
return dict(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
name=name,
|
name=name,
|
||||||
nameSource=name_source,
|
nameSource=name_source,
|
||||||
nameMissing=int(name == NO_NAME),
|
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"]),
|
addressLine1=s(row["direccion"]),
|
||||||
addressLine2=s(row["colonia"]),
|
addressLine2=s(row["colonia"]),
|
||||||
city=s(row["ciudad"]),
|
city=s(row["ciudad"]),
|
||||||
@@ -205,6 +210,7 @@ def customer_from_insurance(row, name_index) -> dict:
|
|||||||
name=name,
|
name=name,
|
||||||
nameSource=name_source,
|
nameSource=name_source,
|
||||||
nameMissing=int(name == NO_NAME),
|
nameMissing=int(name == NO_NAME),
|
||||||
|
folderNumber=None, # insurance ids are a separate space, not a shelf folder
|
||||||
addressLine1=s(row["direccion_1"]),
|
addressLine1=s(row["direccion_1"]),
|
||||||
addressLine2=s(row["direccion_2"]),
|
addressLine2=s(row["direccion_2"]),
|
||||||
city=s(row["ciudad"]),
|
city=s(row["ciudad"]),
|
||||||
@@ -228,7 +234,7 @@ def customer_from_insurance(row, name_index) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
_CUST_COLS = [
|
_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",
|
"country", "phone", "mobile", "fax", "email", "notes", "identificationType",
|
||||||
"identificationNumber", "identificationExpiration", "customerSince",
|
"identificationNumber", "identificationExpiration", "customerSince",
|
||||||
"status", "feeAmount", "minimumBalance", "updatedAt",
|
"status", "feeAmount", "minimumBalance", "updatedAt",
|
||||||
@@ -322,7 +328,7 @@ def main() -> None:
|
|||||||
remap[rec["id"]] = stable or rec["id"]
|
remap[rec["id"]] = stable or rec["id"]
|
||||||
for rec in customers:
|
for rec in customers:
|
||||||
rec["id"] = remap[rec["id"]]
|
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:
|
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)",
|
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]))
|
(ref[0], remap[ref[1]], ref[2], ref[3], ref[4]))
|
||||||
@@ -374,6 +380,12 @@ def main() -> None:
|
|||||||
recovered = cur.fetchall()
|
recovered = cur.fetchall()
|
||||||
cur.execute("SELECT COUNT(*) FROM customers WHERE name = %s", (NO_NAME,))
|
cur.execute("SELECT COUNT(*) FROM customers WHERE name = %s", (NO_NAME,))
|
||||||
still_unnamed = cur.fetchone()[0]
|
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("=== Customer load complete ===")
|
||||||
print(f" utilities DATGRAL rows : {len(util)}")
|
print(f" utilities DATGRAL rows : {len(util)}")
|
||||||
@@ -390,6 +402,8 @@ def main() -> None:
|
|||||||
print(f" from {src:16} : {n}")
|
print(f" from {src:16} : {n}")
|
||||||
print(f" of which via the linked insurance record : {from_ins_side}")
|
print(f" of which via the linked insurance record : {from_ins_side}")
|
||||||
print(f" still {NO_NAME} : {still_unnamed}")
|
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:
|
if not sync_mode:
|
||||||
# Full-load invariants only: sync upserts into an already-loaded (and
|
# Full-load invariants only: sync upserts into an already-loaded (and
|
||||||
# pruned) table, so these exact counts don't hold.
|
# pruned) table, so these exact counts don't hold.
|
||||||
|
|||||||
@@ -164,18 +164,21 @@ def main():
|
|||||||
services.append((str(uuid.uuid4()), pid, kind, account, meter, route,
|
services.append((str(uuid.uuid4()), pid, kind, account, meter, route,
|
||||||
due, 1 if active else 0, notes))
|
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):
|
if s(row["agua"]) or flag("water1", False):
|
||||||
svc("WATER", account=s(row["agua"]), meter=s(row["medidor"]),
|
svc("WATER", account=s(row["agua"]), due=s(row["diagua"]),
|
||||||
route=s(row["ruta"]), due=s(row["diagua"]), active=flag("water1"))
|
active=flag("water1"))
|
||||||
for mcol, rcol in (("medidor2", "ruta2"), ("medidor3", "ruta3")):
|
# ELECTRIC — rpu account + the CFE meter and route beside it. MEDIDOR/
|
||||||
if s(row[mcol]) or s(row[rcol]):
|
# RUTA are the electric meter, not water: they are filled on exactly
|
||||||
svc("WATER", meter=s(row[mcol]), route=s(row[rcol]),
|
# the 1113 rows that carry an RPU (100% overlap), the meters are CFE's
|
||||||
notes="secondary meter", active=flag("water1"))
|
# alphanumeric `53MT58` form, and every legacy query that reads them is
|
||||||
# ELECTRIC (rpu account + extras); luz_tipo as note
|
# a LUZ one (LUZ, FALTANTES DE LUZ, "luz ruta 325 y 329"). They used to
|
||||||
for rc in ("rpu", "rpu2", "rpu3"):
|
# land on the WATER row, leaving every electric service without a meter.
|
||||||
if s(row[rc]):
|
# Paired by suffix: medidor2/ruta2 belong to rpu2. luz_tipo as note.
|
||||||
svc("ELECTRIC", account=s(row[rc]),
|
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,
|
notes=s(row["luz_tipo"]) if rc == "rpu" else None,
|
||||||
active=flag("electric"))
|
active=flag("electric"))
|
||||||
# GAS — the column mixes two things: an account/meter number for 160 of
|
# GAS — the column mixes two things: an account/meter number for 160 of
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jorgecuadros-platform",
|
"name": "jorgecuadros-platform",
|
||||||
"version": "1.0.24",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/database",
|
"name": "@jorgecuadros/database",
|
||||||
"version": "1.0.24",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "generated/client/index.js",
|
"main": "generated/client/index.js",
|
||||||
"types": "generated/client/index.d.ts",
|
"types": "generated/client/index.d.ts",
|
||||||
|
|||||||
+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,
|
// can sort nameless records last — ordering by `name` alone puts them first,
|
||||||
// since "(" sorts before every letter.
|
// since "(" sorts before every letter.
|
||||||
nameMissing Boolean @default(false)
|
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?
|
addressLine1 String?
|
||||||
addressLine2 String?
|
addressLine2 String?
|
||||||
city String?
|
city String?
|
||||||
@@ -123,11 +130,14 @@ model Customer {
|
|||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
|
|
||||||
statementDocuments StatementDocument[]
|
statementDocuments StatementDocument[]
|
||||||
|
statementSplits StatementDocumentSplit[]
|
||||||
|
cashReceipts CashReceipt[]
|
||||||
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
||||||
|
|
||||||
emailNotificationLogs EmailNotificationLog[]
|
emailNotificationLogs EmailNotificationLog[]
|
||||||
accountStatusHistory AccountStatusHistory[]
|
accountStatusHistory AccountStatusHistory[]
|
||||||
|
|
||||||
|
@@index([folderNumber])
|
||||||
@@map("customers")
|
@@map("customers")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,6 +732,26 @@ model StatementBatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One statement — one customer, one period — after splitting the batch.
|
/// 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 {
|
model StatementDocument {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
batchId String
|
batchId String
|
||||||
@@ -767,6 +797,15 @@ model StatementDocument {
|
|||||||
postedTransactionId String? @unique
|
postedTransactionId String? @unique
|
||||||
postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id])
|
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())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@unique([batchId, pageNumber])
|
@@unique([batchId, pageNumber])
|
||||||
@@ -845,6 +884,8 @@ model Transaction {
|
|||||||
|
|
||||||
/// Set only on OCR-posted rows — the statement page this came from.
|
/// Set only on OCR-posted rows — the statement page this came from.
|
||||||
statementDocument StatementDocument?
|
statementDocument StatementDocument?
|
||||||
|
statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
|
||||||
|
cashReceipt CashReceipt?
|
||||||
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
||||||
|
|
||||||
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
||||||
@@ -966,6 +1007,7 @@ model User {
|
|||||||
|
|
||||||
statementBatches StatementBatch[] @relation("StatementBatchUploader")
|
statementBatches StatementBatch[] @relation("StatementBatchUploader")
|
||||||
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
|
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
|
||||||
|
cashReceipts CashReceipt[] @relation("CashReceiptPreparer")
|
||||||
|
|
||||||
policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader")
|
policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader")
|
||||||
policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer")
|
policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer")
|
||||||
@@ -1248,3 +1290,35 @@ model AppSetting {
|
|||||||
|
|
||||||
@@map("app_settings")
|
@@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")
|
||||||
|
}
|
||||||
+157
-12
@@ -15,14 +15,27 @@
|
|||||||
* is a raw lifetime sum. The platform only migrated the CURRENT-year
|
* is a raw lifetime sum. The platform only migrated the CURRENT-year
|
||||||
* charge ledger (datos2); the per-year charge tables live in DreamHost
|
* charge ledger (datos2); the per-year charge tables live in DreamHost
|
||||||
* and were never staged. What survives before the cutover is therefore
|
* and were never staged. What survives before the cutover is therefore
|
||||||
* the EFECTIVO cash journal — receipts with no matching charges — so
|
* a cash journal — receipts with no matching charges — so those sums read
|
||||||
* those sums read as the office owing money it does not owe.
|
* as the office owing money it does not owe.
|
||||||
*
|
*
|
||||||
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt recorded twice, once in
|
* READ THE COMPOSITION LINE BEFORE ACTING ON THIS. After the prior-period
|
||||||
* EFECTIVO with folio `N` and once in datos2 with reference `CN`. Both
|
* import the group is mostly insurance-only customers whose rows come from
|
||||||
* rows are after the 2026-01-01 floor, so both count. The statement hides
|
* the seguros database's own EFECTIVO, which is that line's ONLY ledger.
|
||||||
* them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances
|
* Flooring those deletes receipts instead of removing a double count. The
|
||||||
* worklist, the movement browser and the /clientes/:id card do not.
|
* "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
|
* 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
|
* lead and never as the verdict. Folios are reused, so `C13483` can collide
|
||||||
@@ -82,6 +95,44 @@ const BF_PREDICATE = `(
|
|||||||
AND t.legacySourceTable = 'datos2')
|
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() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const limit = Number(arg(args, "--limit", "25"));
|
const limit = Number(arg(args, "--limit", "25"));
|
||||||
@@ -127,9 +178,13 @@ async function main() {
|
|||||||
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS rawMxn,
|
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='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)
|
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 todayMxn,
|
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)
|
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 todayUsd,
|
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 >= ?
|
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate >= ?
|
||||||
THEN t.amount ELSE 0 END), 2) AS flooredMxn,
|
THEN t.amount ELSE 0 END), 2) AS flooredMxn,
|
||||||
ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ?
|
ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ?
|
||||||
@@ -197,6 +252,39 @@ async function main() {
|
|||||||
cutover,
|
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 ---------------------------------
|
// ---- section B: double-booked receipts ---------------------------------
|
||||||
const pairs = await prisma.$queryRawUnsafe(
|
const pairs = await prisma.$queryRawUnsafe(
|
||||||
`
|
`
|
||||||
@@ -269,6 +357,34 @@ async function main() {
|
|||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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 -------------------------------------------------------
|
// ---- CSV escapes -------------------------------------------------------
|
||||||
if (args.includes("--csv-a")) return dumpCsv(floorless);
|
if (args.includes("--csv-a")) return dumpCsv(floorless);
|
||||||
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
|
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
|
||||||
@@ -276,8 +392,15 @@ async function main() {
|
|||||||
// ---- report ------------------------------------------------------------
|
// ---- report ------------------------------------------------------------
|
||||||
console.log("\nBOOK (voided and outstanding rows excluded)");
|
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(` raw lifetime sum, no floor ${money(book.rawMxn)} MXN ${money(book.rawUsd)} USD`);
|
||||||
console.log(` today (per-customer BF floor) ${money(book.todayMxn)} MXN ${money(book.todayUsd)} 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(` 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 preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0);
|
||||||
const wouldZero = floorless.filter((r) => d(r.postRows) === 0);
|
const wouldZero = floorless.filter((r) => d(r.postRows) === 0);
|
||||||
@@ -291,6 +414,17 @@ async function main() {
|
|||||||
console.log(` balance moved by flooring: ${money(-deltaMxn)} MXN ${money(-deltaUsd)} USD`);
|
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}` +
|
console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` +
|
||||||
` (their balance becomes 0 — an assertion, not a migrated figure)`);
|
` (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(
|
console.log(
|
||||||
`\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` +
|
`\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` +
|
||||||
` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`,
|
` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`,
|
||||||
@@ -347,6 +481,16 @@ async function main() {
|
|||||||
console.log(` confirmed USD receipt posted to datos2 in MXN: ${converted.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(` 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(` 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(
|
console.log(
|
||||||
`\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` +
|
`\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` +
|
||||||
` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`,
|
` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`,
|
||||||
@@ -382,8 +526,9 @@ async function main() {
|
|||||||
"\nNOTE: nothing above has been changed. Section A is a proposal to move the\n" +
|
"\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" +
|
"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" +
|
"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 an\n" +
|
"database. It exists in the DreamHost per-year tables. Section B is no\n" +
|
||||||
"independent defect and does not need a corte to fix.",
|
"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 {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
Reference in new issue
Block a user