feat(statements): flag billing periods that overlap another statement
ci / lint-test (push) Successful in 46s
ci / lint-test (push) Successful in 46s
An account cannot be billed twice for the same day, so an overlap means those
transactions are in the ledger twice. ANZ statements 107 and 143 overlap by 118
days and put roughly $42,000 of duplicate rows in; nothing anywhere said so.
Two details decide whether this catches the real case:
- Account numbers compare with non-digits stripped. The duplicate got in
because the existing key compared raw text and ANZ wrote the same account
as 408556264 on one statement and 4085-56264 on the other.
- The range is half-open. These statements are issued back-to-back with one
period ending the day the next starts, so inclusive bounds flagged 5 pairs
of which 3 were consecutive and fine. Half-open leaves exactly the 2 real
ones.
NULL bounds are excluded rather than handed to daterange, where NULL means
unbounded and an undated statement would overlap all of history.
Detection only. It does not refuse the import or touch the duplicate rows --
cleaning those is separate, and must supersede rather than delete because every
child of transactions is ON DELETE CASCADE and the curation sits on the
duplicate side.
Both subtleties have a test, and both fail if you undo them.
This commit is contained in:
@@ -8,7 +8,7 @@ mockDbWithPool(pool);
|
||||
|
||||
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
|
||||
// use the test pool rather than Prisma's singleton.
|
||||
const { getTransactions, getParticipantBalances, getTripAnalytics, getTripById } = await import("@/lib/queries");
|
||||
const { getTransactions, getParticipantBalances, getTripAnalytics, getTripById, getStatements } = await import("@/lib/queries");
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
@@ -538,3 +538,73 @@ describe("getTripAnalytics — refunds reduce trip cost", () => {
|
||||
expect(Number(bob!.owed)).toBeCloseTo(100);
|
||||
});
|
||||
});
|
||||
|
||||
// An account cannot be billed twice for the same day. The boundary handling is
|
||||
// the whole difficulty: these statements are issued back-to-back with one
|
||||
// period ending the day the next begins, so naive inclusive ranges flag every
|
||||
// consecutive pair.
|
||||
describe("getStatements — overlapping billing periods", () => {
|
||||
async function addStatement(
|
||||
ownerId: number, account: string, start: string | null, end: string | null
|
||||
): Promise<number> {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO statements (filename, bank_name, account_number, owner_id,
|
||||
billing_start_date, billing_end_date)
|
||||
VALUES ($1, 'ANZ', $2, $3, $4, $5) RETURNING id`,
|
||||
[`stmt-${account}-${start}.pdf`, account, ownerId, start, end]
|
||||
);
|
||||
return r.rows[0].id as number;
|
||||
}
|
||||
|
||||
it("does not flag statements that merely touch at a boundary", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await addStatement(ownerId, "4085-56264", "2025-05-16", "2025-11-14");
|
||||
await addStatement(ownerId, "4085-56264", "2025-11-14", "2026-05-15");
|
||||
|
||||
const rows = await getStatements(ownerId);
|
||||
expect(rows.every((r) => r.overlaps.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a genuine overlap on both statements, with the day count", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const a = await addStatement(ownerId, "4085-56264", "2025-11-12", "2026-03-12");
|
||||
const b = await addStatement(ownerId, "4085-56264", "2025-11-14", "2026-05-15");
|
||||
|
||||
const rows = await getStatements(ownerId);
|
||||
const rowA = rows.find((r) => r.id === a)!;
|
||||
const rowB = rows.find((r) => r.id === b)!;
|
||||
expect(rowA.overlaps).toEqual([{ id: b, days: 118 }]);
|
||||
expect(rowB.overlaps).toEqual([{ id: a, days: 118 }]);
|
||||
});
|
||||
|
||||
// The real duplicate got in because the existing key compared raw text and
|
||||
// ANZ wrote the same account both ways.
|
||||
it("matches the same account written with and without punctuation", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const a = await addStatement(ownerId, "408556264", "2025-11-12", "2026-03-12");
|
||||
const b = await addStatement(ownerId, "4085-56264", "2025-11-14", "2026-05-15");
|
||||
|
||||
const rows = await getStatements(ownerId);
|
||||
expect(rows.find((r) => r.id === a)!.overlaps).toEqual([{ id: b, days: 118 }]);
|
||||
});
|
||||
|
||||
it("ignores a different account billing the same days", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await addStatement(ownerId, "4085-56264", "2025-11-12", "2026-03-12");
|
||||
await addStatement(ownerId, "9999-11111", "2025-11-12", "2026-03-12");
|
||||
|
||||
const rows = await getStatements(ownerId);
|
||||
expect(rows.every((r) => r.overlaps.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
// NULL is unbounded to daterange, which would make an undated statement
|
||||
// overlap the entire history.
|
||||
it("does not treat an undated statement as overlapping everything", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await addStatement(ownerId, "4085-56264", "2025-11-12", "2026-03-12");
|
||||
await addStatement(ownerId, "4085-56264", null, null);
|
||||
|
||||
const rows = await getStatements(ownerId);
|
||||
expect(rows.every((r) => r.overlaps.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user