feat(billing): shared statements module across both business lines

Plan step 6 — the payoff of the unified customer record: a utility charge
and an insurance payment finally sit on the same page, under the same
person, with a running balance.

API (apps/api/src/billing/):
- GET /billing — cross-customer movement browser. Search over customer,
  referencia, cheque, concepto and periodo; filters for business line,
  currency, charge-vs-credit, concept, origin table and a from/to date
  range; 5 sorts. Returns totals for the whole filtered set, not just the
  page, so a filtered view can't be misread as the full ledger.
- GET /billing/balances — per-customer receivables worklist with
  owing/credit/settled buckets and 4 sorts. Raw SQL (parameterized via
  Prisma.sql): needs conditional sums per currency and per direction in
  one pass plus ordering and pagination on a computed balance, none of
  which groupBy expresses.
- GET /billing/stats, /billing/facets, /billing/customers/:id.

Web:
- /estado-cuenta — two views over the same ledger, because staff ask two
  different questions: "Saldos por cliente" (who owes what) and
  "Movimientos" (every charge and credit).
- /estado-cuenta/[id] — the statement: balance per currency, the same
  balance split by business line, charges broken out by concept, and the
  full movement list with a running balance.
- Cross-linked from the customer and property detail pages.

Two data findings shape the whole module:

1. transactions.amount is a signed ledger. Every charge type is negative
   without exception (WATER 3115/3117, ELECTRIC 2191/2191, PROPERTY TAXES
   926/926, TRUST FEE 188/188) and every deposit type positive (CHECK and
   CASH DEPOSIT, PAYPAL, all of EFECTIVO). So SUM(amount) is the balance
   and negative means the customer owes the office.

2. Currency is not summable. 912 of the 1269 customers with a ledger move
   in both MXN and USD, the charge side is MXN-only while receipts arrive
   in both, and no per-movement exchange rate was ever stored. A single
   "total balance" would be a figure that never existed in the books, so
   every total is reported per currency and the balance filter/sort takes
   a currency argument rather than collapsing.

Also: type_transactions.nameEs is entirely null (the legacy TYPE OF TRX
ESPAÑOL column is empty in all 79 rows), so Spanish concept names come
from a label map in labels.ts; the entries that are payee names rather
than categories fall through untranslated, which is correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:29:19 -07:00
co-authored by Claude Opus 4.8
parent 9de8e4e6c0
commit 2c6a6bf60b
13 changed files with 2789 additions and 1 deletions
+2
View File
@@ -6,6 +6,7 @@ import { AuthModule } from "./auth/auth.module";
import { CustomersModule } from "./customers/customers.module";
import { PoliciesModule } from "./policies/policies.module";
import { PropertiesModule } from "./properties/properties.module";
import { BillingModule } from "./billing/billing.module";
import { AppController } from "./app.controller";
@Module({
@@ -17,6 +18,7 @@ import { AppController } from "./app.controller";
CustomersModule,
PoliciesModule,
PropertiesModule,
BillingModule,
],
controllers: [AppController],
})
+116
View File
@@ -0,0 +1,116 @@
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
import { TransactionDomain } from "@jorgecuadros/database";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import {
BalanceFilter,
BalanceSort,
BillingService,
LedgerCurrency,
LedgerDirection,
MovementSort,
} from "./billing.service";
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
const DIRECTIONS: LedgerDirection[] = ["charge", "credit"];
const BALANCES: BalanceFilter[] = ["all", "owing", "credit", "settled"];
const MOVEMENT_SORTS: MovementSort[] = [
"date_desc",
"date_asc",
"amount_desc",
"amount_asc",
"customer",
];
const BALANCE_SORTS: BalanceSort[] = [
"owing_desc",
"credit_desc",
"recent",
"customer",
];
function one<T>(allowed: T[], value: string | undefined): T | undefined {
return allowed.includes(value as T) ? (value as T) : undefined;
}
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
if (!v) return undefined;
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
return Number.isNaN(d.getTime()) ? undefined : d;
}
@UseGuards(AuthenticatedGuard)
@Controller("billing")
export class BillingController {
constructor(private readonly billing: BillingService) {}
@Get("stats")
stats() {
return this.billing.stats();
}
@Get("facets")
facets() {
return this.billing.facets();
}
/** Per-customer balances — the receivables worklist. */
@Get("balances")
balances(
@Query("query") query?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("currency") currency?: string,
@Query("balance") balance?: string,
@Query("domain") domain?: string,
@Query("sort") sort?: string,
) {
return this.billing.balances({
query,
page: Math.max(1, Number(page) || 1),
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
currency: one(CURRENCIES, currency) ?? "MXN",
balance: one(BALANCES, balance) ?? "all",
domain: one(DOMAINS, domain),
sort: one(BALANCE_SORTS, sort) ?? "owing_desc",
});
}
/** One customer's full statement across both business lines. */
@Get("customers/:id")
statement(@Param("id") id: string) {
return this.billing.statement(id);
}
/** Cross-customer movement browser. */
@Get()
movements(
@Query("query") query?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("domain") domain?: string,
@Query("currency") currency?: string,
@Query("direction") direction?: string,
@Query("typeId") typeId?: string,
@Query("source") source?: string,
@Query("customerId") customerId?: string,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("sort") sort?: string,
) {
return this.billing.movements({
query,
page: Math.max(1, Number(page) || 1),
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
domain: one(DOMAINS, domain),
currency: one(CURRENCIES, currency),
direction: one(DIRECTIONS, direction),
typeId: typeId || undefined,
source: source || undefined,
customerId: customerId || undefined,
from: parseDate(from),
to: parseDate(to, true),
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { BillingController } from "./billing.controller";
import { BillingService } from "./billing.service";
@Module({
controllers: [BillingController],
providers: [BillingService],
})
export class BillingModule {}
+725
View File
@@ -0,0 +1,725 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
/**
* Shared billing / statements module — plan step 6.
*
* SIGN CONVENTION (established from the migrated data, not assumed):
* `transactions.amount` is a *signed* ledger amount.
* - negative = cargo (a charge: a utility bill, predial, trust fee, HOA due,
* insurance premium the office paid or billed on the customer's behalf).
* Every legacy `type_of_trx` on the charge side is negative without
* exception — WATER 3115/3117 negative, ELECTRIC 2191/2191, PROPERTY TAXES
* 926/926, TRUST FEE 188/188.
* - positive = abono (a credit: CHECK DEPOSIT, CASH DEPOSIT, PAYPAL, and
* every `EFECTIVO` cash receipt).
* So `SUM(amount)` is the balance: negative means the customer owes the office,
* positive means the customer is in credit.
*
* CURRENCY IS NOT SUMMABLE. 912 of the 1269 customers with a ledger have
* movements in both MXN and USD, and the charge side (`datos2`/`FEE ANUAL`/
* `fee15`) is MXN-only while receipts arrive in both. Applying a historical
* exchange rate to a 20-year ledger to produce one number would invent a figure
* the source data never had, so every total in this module is reported *per
* currency* and never collapsed. Balance filters and sorts therefore operate on
* one caller-chosen currency at a time.
*/
/** Which side of the ledger a movement is on. */
export type LedgerDirection = "charge" | "credit";
/** Balance buckets for the receivables worklist, on the selected currency. */
export type BalanceFilter = "all" | "owing" | "credit" | "settled";
export type MovementSort =
| "date_desc"
| "date_asc"
| "amount_desc"
| "amount_asc"
| "customer";
export type BalanceSort = "owing_desc" | "credit_desc" | "recent" | "customer";
export type LedgerCurrency = "MXN" | "USD";
export interface MovementParams {
query?: string;
page: number;
pageSize: number;
domain?: TransactionDomain;
currency?: LedgerCurrency;
direction?: LedgerDirection;
typeId?: string;
source?: string;
customerId?: string;
/** Inclusive ISO date bounds on `transactionDate`. */
from?: Date;
to?: Date;
sort: MovementSort;
}
export interface BalanceParams {
query?: string;
page: number;
pageSize: number;
currency: LedgerCurrency;
balance: BalanceFilter;
/** Restricts the whole balance to one business line. */
domain?: TransactionDomain;
sort: BalanceSort;
}
/** Raw shape of the per-customer balance aggregate. */
interface BalanceRow {
id: string;
name: string;
nameSource: string | null;
nameMissing: number;
city: string | null;
state: string | null;
movements: bigint | number | string;
balanceMxn: Prisma.Decimal | null;
balanceUsd: Prisma.Decimal | null;
chargesMxn: Prisma.Decimal | null;
creditsMxn: Prisma.Decimal | null;
chargesUsd: Prisma.Decimal | null;
creditsUsd: Prisma.Decimal | null;
utilityMovements: bigint | number | string;
insuranceMovements: bigint | number | string;
lastMovement: Date | null;
}
/**
* Raw-query counts come back in three shapes depending on the aggregate:
* `COUNT(*)` as bigint, `SUM(bool)` as a decimal *string*, and plain numbers.
* Normalize all of them before they reach the client as JSON.
*/
function num(v: bigint | number | string | null | undefined): number {
if (v === null || v === undefined) return 0;
return typeof v === "number" ? v : Number(v);
}
function dec(v: Prisma.Decimal | null | undefined): string {
return (v ?? new Prisma.Decimal(0)).toFixed(2);
}
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
private movementWhere(p: MovementParams): Prisma.TransactionWhereInput {
const and: Prisma.TransactionWhereInput[] = [];
if (p.query && p.query.trim()) {
const q = p.query.trim();
and.push({
OR: [
{ customer: { name: { contains: q } } },
{ reference: { contains: q } },
{ checkNumber: { contains: q } },
{ message: { contains: q } },
{ period: { contains: q } },
],
});
}
if (p.domain) and.push({ domain: p.domain });
if (p.currency) and.push({ currency: p.currency });
// A charge is strictly negative and a credit strictly positive; the ~193
// zero-amount rows are neither and are excluded from both sides on purpose.
if (p.direction === "charge") and.push({ amount: { lt: 0 } });
if (p.direction === "credit") and.push({ amount: { gt: 0 } });
if (p.typeId) and.push({ typeId: p.typeId });
if (p.source) and.push({ legacySourceTable: p.source });
if (p.customerId) and.push({ customerId: p.customerId });
if (p.from || p.to) {
and.push({
transactionDate: {
...(p.from ? { gte: p.from } : {}),
...(p.to ? { lte: p.to } : {}),
},
});
}
return and.length ? { AND: and } : {};
}
private movementOrderBy(
sort: MovementSort,
): Prisma.TransactionOrderByWithRelationInput[] {
switch (sort) {
case "date_asc":
return [{ transactionDate: "asc" }];
case "amount_desc":
return [{ amount: "desc" }];
case "amount_asc":
return [{ amount: "asc" }];
case "customer":
return [
{ customer: { nameMissing: "asc" } },
{ customer: { name: "asc" } },
{ transactionDate: "desc" },
];
default:
return [{ transactionDate: "desc" }];
}
}
/** Cross-customer movement browser — every charge and credit, filterable. */
async movements(params: MovementParams) {
const where = this.movementWhere(params);
const [total, rows] = await this.prisma.$transaction([
this.prisma.transaction.count({ where }),
this.prisma.transaction.findMany({
where,
skip: (params.page - 1) * params.pageSize,
take: params.pageSize,
orderBy: this.movementOrderBy(params.sort),
select: {
id: true,
transactionDate: true,
domain: true,
amount: true,
currency: true,
reference: true,
period: true,
checkNumber: true,
message: true,
legacySourceTable: true,
type: { select: { nameEn: true, nameEs: true } },
customer: {
select: { id: true, name: true, nameSource: true, city: true },
},
},
}),
]);
// Totals for the *filtered set*, not just the page — the number staff read
// off a filtered view ("how much did we bill for water in April") has to
// cover everything the filter matched.
const totals = await this.prisma.transaction.groupBy({
by: ["currency"],
where,
_sum: { amount: true },
_count: { _all: true },
});
const charges = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { AND: [where, { amount: { lt: 0 } }] },
_sum: { amount: true },
_count: { _all: true },
});
const credits = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { AND: [where, { amount: { gt: 0 } }] },
_sum: { amount: true },
_count: { _all: true },
});
const chargeMap = new Map(charges.map((c) => [c.currency, c]));
const creditMap = new Map(credits.map((c) => [c.currency, c]));
return {
items: rows.map((r) => ({
id: r.id,
transactionDate: r.transactionDate,
domain: r.domain,
amount: r.amount,
currency: r.currency,
direction: r.amount.lessThan(0) ? "charge" : "credit",
reference: r.reference,
period: r.period,
checkNumber: r.checkNumber,
message: r.message,
source: r.legacySourceTable,
type: r.type,
customerId: r.customer.id,
customerName: r.customer.name,
customerNameSource: r.customer.nameSource,
customerCity: r.customer.city,
})),
total,
page: params.page,
pageSize: params.pageSize,
pageCount: Math.ceil(total / params.pageSize),
totals: totals.map((t) => ({
currency: t.currency,
net: t._sum.amount,
count: t._count._all,
charges: chargeMap.get(t.currency)?._sum.amount ?? null,
chargeCount: chargeMap.get(t.currency)?._count._all ?? 0,
credits: creditMap.get(t.currency)?._sum.amount ?? null,
creditCount: creditMap.get(t.currency)?._count._all ?? 0,
})),
};
}
/**
* Receivables worklist: one row per customer with a ledger, carrying both
* currency balances, filtered/sorted on the caller's chosen currency.
*
* Raw SQL rather than Prisma `groupBy` because this needs conditional sums
* per currency *and* per direction in a single pass, plus ordering and
* pagination on a computed balance — none of which groupBy expresses.
*/
async balances(params: BalanceParams) {
const { query, page, pageSize, currency, balance, domain, sort } = params;
const filters: Prisma.Sql[] = [];
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
const txFilter = filters.length
? Prisma.sql`AND ${Prisma.join(filters, " AND ")}`
: Prisma.empty;
const nameFilter =
query && query.trim()
? Prisma.sql`AND (c.name LIKE ${`%${query.trim()}%`} OR c.city LIKE ${`%${query.trim()}%`})`
: Prisma.empty;
// The balance column the filter and sort act on.
const bal =
currency === "USD"
? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)`
: Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`;
// "Owing" is a *negative* balance (see the sign convention above). The
// 0.005 threshold keeps rounding dust out of both worklists.
let having = Prisma.empty;
if (balance === "owing") having = Prisma.sql`HAVING ${bal} < -0.005`;
else if (balance === "credit") having = Prisma.sql`HAVING ${bal} > 0.005`;
else if (balance === "settled")
having = Prisma.sql`HAVING ${bal} BETWEEN -0.005 AND 0.005`;
let orderBy: Prisma.Sql;
switch (sort) {
case "credit_desc":
orderBy = Prisma.sql`ORDER BY ${bal} DESC`;
break;
case "recent":
orderBy = Prisma.sql`ORDER BY MAX(t.transactionDate) DESC`;
break;
case "customer":
orderBy = Prisma.sql`ORDER BY c.nameMissing ASC, c.name ASC`;
break;
default:
// Deepest debt first — the point of the worklist.
orderBy = Prisma.sql`ORDER BY ${bal} ASC`;
}
const rows = await this.prisma.$queryRaw<BalanceRow[]>`
SELECT
c.id,
c.name,
c.nameSource,
c.nameMissing,
c.city,
c.state,
COUNT(*) AS movements,
SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END) AS balanceMxn,
SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END) AS balanceUsd,
SUM(CASE WHEN t.currency = 'MXN' AND t.amount < 0 THEN t.amount ELSE 0 END) AS chargesMxn,
SUM(CASE WHEN t.currency = 'MXN' AND t.amount > 0 THEN t.amount ELSE 0 END) AS creditsMxn,
SUM(CASE WHEN t.currency = 'USD' AND t.amount < 0 THEN t.amount ELSE 0 END) AS chargesUsd,
SUM(CASE WHEN t.currency = 'USD' AND t.amount > 0 THEN t.amount ELSE 0 END) AS creditsUsd,
SUM(t.domain = 'UTILITY') AS utilityMovements,
SUM(t.domain = 'INSURANCE') AS insuranceMovements,
MAX(t.transactionDate) AS lastMovement
FROM customers c
JOIN transactions t ON t.customerId = c.id
WHERE 1 = 1 ${nameFilter} ${txFilter}
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
${having}
${orderBy}
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
`;
const counted = await this.prisma.$queryRaw<{ total: bigint | number | string }[]>`
SELECT COUNT(*) AS total FROM (
SELECT c.id
FROM customers c
JOIN transactions t ON t.customerId = c.id
WHERE 1 = 1 ${nameFilter} ${txFilter}
GROUP BY c.id
${having}
) x
`;
const total = num(counted[0]?.total);
return {
items: rows.map((r) => ({
id: r.id,
name: r.name,
nameSource: r.nameSource,
city: r.city,
state: r.state,
movements: num(r.movements),
utilityMovements: num(r.utilityMovements),
insuranceMovements: num(r.insuranceMovements),
lastMovement: r.lastMovement,
balances: [
{
currency: "MXN",
balance: dec(r.balanceMxn),
charges: dec(r.chargesMxn),
credits: dec(r.creditsMxn),
},
{
currency: "USD",
balance: dec(r.balanceUsd),
charges: dec(r.chargesUsd),
credits: dec(r.creditsUsd),
},
],
})),
total,
page,
pageSize,
pageCount: Math.ceil(total / pageSize),
currency,
};
}
/** Top-line figures for the billing page header. */
async stats() {
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
this.prisma.transaction.count(),
this.prisma.transaction
.findMany({ distinct: ["customerId"], select: { customerId: true } })
.then((r) => r.length),
this.prisma.transaction.groupBy({
by: ["currency"],
_sum: { amount: true },
_count: { _all: true },
}),
this.prisma.transaction.groupBy({
by: ["domain", "currency"],
_sum: { amount: true },
_count: { _all: true },
}),
]);
const charges = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { amount: { lt: 0 } },
_sum: { amount: true },
_count: { _all: true },
});
const credits = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { amount: { gt: 0 } },
_sum: { amount: true },
_count: { _all: true },
});
const chargeMap = new Map(charges.map((c) => [c.currency, c]));
const creditMap = new Map(credits.map((c) => [c.currency, c]));
// How many customers sit on each side of the line, per currency — the
// headline for a receivables view. Counted in SQL; a customer can be
// "owing" in MXN and "in credit" in USD, and both are true at once.
const sides = await this.prisma.$queryRaw<
{
currency: string;
owing: bigint | number | string;
inCredit: bigint | number | string;
}[]
>`
SELECT currency,
SUM(bal < -0.005) AS owing,
SUM(bal > 0.005) AS inCredit
FROM (
SELECT customerId, currency, SUM(amount) AS bal
FROM transactions GROUP BY customerId, currency
) x
GROUP BY currency
`;
const sideMap = new Map(sides.map((s) => [s.currency, s]));
const [firstRow, lastRow] = await Promise.all([
this.prisma.transaction.findFirst({
orderBy: { transactionDate: "asc" },
select: { transactionDate: true },
}),
this.prisma.transaction.findFirst({
orderBy: { transactionDate: "desc" },
select: { transactionDate: true },
}),
]);
// Customers whose ledger spans both business lines — the whole reason this
// module is one view instead of two.
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
SELECT COUNT(*) AS n FROM (
SELECT customerId FROM transactions
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
) x
`;
return {
movements,
ledgerCustomers,
crossLineCustomers: num(crossLine[0]?.n),
firstMovement: firstRow?.transactionDate ?? null,
lastMovement: lastRow?.transactionDate ?? null,
byCurrency: byCurrency.map((c) => ({
currency: c.currency,
net: c._sum.amount,
count: c._count._all,
charges: chargeMap.get(c.currency)?._sum.amount ?? null,
chargeCount: chargeMap.get(c.currency)?._count._all ?? 0,
credits: creditMap.get(c.currency)?._sum.amount ?? null,
creditCount: creditMap.get(c.currency)?._count._all ?? 0,
owing: num(sideMap.get(c.currency)?.owing),
inCredit: num(sideMap.get(c.currency)?.inCredit),
})),
byDomain: byDomain.map((d) => ({
domain: d.domain,
currency: d.currency,
net: d._sum.amount,
count: d._count._all,
})),
};
}
/** Filter dropdown options for the movement browser. */
async facets() {
const types = await this.prisma.transaction.groupBy({
by: ["typeId"],
where: { typeId: { not: null } },
_count: { _all: true },
orderBy: { _count: { typeId: "desc" } },
});
const typeRows = await this.prisma.typeTransaction.findMany({
where: { id: { in: types.map((t) => t.typeId as string) } },
select: { id: true, nameEn: true, nameEs: true },
});
const typeMap = new Map(typeRows.map((t) => [t.id, t]));
const sources = await this.prisma.transaction.groupBy({
by: ["legacySourceTable"],
_count: { _all: true },
orderBy: { _count: { legacySourceTable: "desc" } },
});
const years = await this.prisma.$queryRaw<
{ year: number; count: bigint | number | string }[]
>`
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
FROM transactions GROUP BY year ORDER BY year DESC
`;
return {
types: types
.map((t) => {
const row = typeMap.get(t.typeId as string);
return {
id: t.typeId as string,
name: row?.nameEs || row?.nameEn || "—",
count: t._count._all,
};
})
.filter((t) => t.name !== "—"),
sources: sources.map((s) => ({
name: s.legacySourceTable ?? "—",
count: s._count._all,
})),
years: years.map((y) => ({ year: Number(y.year), count: num(y.count) })),
};
}
/**
* One customer's statement across both business lines.
*
* Returns the *whole* ledger rather than a page of it: the heaviest customer
* carries 365 movements (mean 26), and a running balance is meaningless if
* the client only holds a slice. The running balance is accumulated per
* currency in chronological order, then the list is handed back newest-first
* with each row's balance-after already attached.
*/
async statement(customerId: string) {
const customer = await this.prisma.customer.findUnique({
where: { id: customerId },
select: {
id: true,
name: true,
nameSource: true,
addressLine1: true,
city: true,
state: true,
phone: true,
mobile: true,
email: true,
customerSince: true,
preferredCurrency: true,
status: true,
_count: { select: { properties: true, policies: true } },
},
});
if (!customer) {
throw new NotFoundException(`Customer ${customerId} not found`);
}
const rows = await this.prisma.transaction.findMany({
where: { customerId },
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: {
id: true,
transactionDate: true,
domain: true,
amount: true,
currency: true,
reference: true,
period: true,
checkNumber: true,
message: true,
legacySourceTable: true,
type: { select: { nameEn: true, nameEs: true } },
},
});
const running = new Map<string, Prisma.Decimal>();
const movements = rows.map((r) => {
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
const next = prev.plus(r.amount);
running.set(r.currency, next);
return {
id: r.id,
transactionDate: r.transactionDate,
domain: r.domain,
amount: r.amount,
currency: r.currency,
direction: r.amount.lessThan(0) ? "charge" : "credit",
reference: r.reference,
period: r.period,
checkNumber: r.checkNumber,
message: r.message,
source: r.legacySourceTable,
type: r.type,
/** Balance in this row's currency after applying it. */
balanceAfter: next.toFixed(2),
};
});
movements.reverse();
// Per-currency summary, and the same split by business line so the two
// ledgers are visibly one statement without being illegally added up.
const perCurrency = new Map<
string,
{
currency: string;
charges: Prisma.Decimal;
credits: Prisma.Decimal;
chargeCount: number;
creditCount: number;
count: number;
first: Date | null;
last: Date | null;
}
>();
const perDomain = new Map<
string,
{
domain: TransactionDomain;
currency: string;
charges: Prisma.Decimal;
credits: Prisma.Decimal;
count: number;
}
>();
for (const r of rows) {
const c =
perCurrency.get(r.currency) ??
{
currency: r.currency,
charges: new Prisma.Decimal(0),
credits: new Prisma.Decimal(0),
chargeCount: 0,
creditCount: 0,
count: 0,
first: null as Date | null,
last: null as Date | null,
};
c.count += 1;
if (r.amount.lessThan(0)) {
c.charges = c.charges.plus(r.amount);
c.chargeCount += 1;
} else if (r.amount.greaterThan(0)) {
c.credits = c.credits.plus(r.amount);
c.creditCount += 1;
}
if (!c.first) c.first = r.transactionDate;
c.last = r.transactionDate;
perCurrency.set(r.currency, c);
const dk = `${r.domain}|${r.currency}`;
const d =
perDomain.get(dk) ??
{
domain: r.domain,
currency: r.currency,
charges: new Prisma.Decimal(0),
credits: new Prisma.Decimal(0),
count: 0,
};
d.count += 1;
if (r.amount.lessThan(0)) d.charges = d.charges.plus(r.amount);
else if (r.amount.greaterThan(0)) d.credits = d.credits.plus(r.amount);
perDomain.set(dk, d);
}
// Where the money goes, per charge type — the question a customer asks
// when they query their balance.
const byType = new Map<
string,
{ name: string; currency: string; total: Prisma.Decimal; count: number }
>();
for (const r of rows) {
if (!r.amount.lessThan(0)) continue;
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
const key = `${name}|${r.currency}`;
const e =
byType.get(key) ??
{ name, currency: r.currency, total: new Prisma.Decimal(0), count: 0 };
e.total = e.total.plus(r.amount);
e.count += 1;
byType.set(key, e);
}
return {
customer: {
...customer,
propertyCount: customer._count.properties,
policyCount: customer._count.policies,
},
summary: [...perCurrency.values()].map((c) => ({
currency: c.currency,
charges: c.charges.toFixed(2),
credits: c.credits.toFixed(2),
balance: c.charges.plus(c.credits).toFixed(2),
chargeCount: c.chargeCount,
creditCount: c.creditCount,
count: c.count,
firstMovement: c.first,
lastMovement: c.last,
})),
byDomain: [...perDomain.values()].map((d) => ({
domain: d.domain,
currency: d.currency,
charges: d.charges.toFixed(2),
credits: d.credits.toFixed(2),
balance: d.charges.plus(d.credits).toFixed(2),
count: d.count,
})),
byType: [...byType.values()]
.map((t) => ({
name: t.name,
currency: t.currency,
total: t.total.toFixed(2),
count: t.count,
}))
.sort((a, b) => Number(a.total) - Number(b.total)),
movements,
};
}
}