ci / lint-test (push) Successful in 46s
Nothing dated before 2026-01-09 can be owed, because carryover transaction 2348
already carries the entire pre-cutover balance as a single figure. ACTIVE_OBLIGATION
now says so directly.
This inverts which mechanism is load-bearing, and that is the point. Until now
the only thing keeping $37,233.28 of paid debt out of the balances was
transaction_splits.settled -- a boolean that any delete-and-recreate write path
resets to false, as the split modal did until commit 6add958. Losing the flag on
a pre-cutover row now costs nothing: the date still excludes it. The flag matters
only on or after the cutover, marking the few settled outside this app.
It also makes splitting history safe to do freely. A split on a 2024 grocery
shop can now describe how the expense was shared -- which is what stops it
inflating spend -- without asserting a debt that was settled years ago. That was
the whole reason not to split pre-2026 expenses, and it no longer applies.
The bound is inclusive because transaction 2348 is itself dated 2026-01-09; an
exclusive one would drop the carryover and with it the entire pre-cutover
balance.
Corrects one live figure: a Woolworths on 2026-01-06 was split 50/50 three days
before the cutover, double-counting $7.55 against the carryover. Sonu
$5,428.08 -> $5,420.53.
The test fixture default moved to 2026-06-15 -- it was 2024-06-15, which is now
pre-cutover and made every balance fixture read zero. That the suite caught this
is the guard working.
724 lines
32 KiB
TypeScript
724 lines
32 KiB
TypeScript
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
|
import { createPool, mockDbWithPool, resetDB, seedParticipants, insertTransaction } from "./helpers";
|
|
|
|
// Create a pool and mock @/lib/db BEFORE any dynamic imports that use it.
|
|
// vi.doMock is NOT hoisted so it can close over the pool instance.
|
|
const pool = createPool();
|
|
mockDbWithPool(pool);
|
|
|
|
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
|
|
// use the test pool rather than Prisma's singleton.
|
|
const { getTransactions, getParticipantBalances, getTripAnalytics, getTripById, getStatements } = await import("@/lib/queries");
|
|
|
|
beforeEach(async () => {
|
|
await resetDB(pool);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await pool.end();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ── getTransactions ───────────────────────────────────────────────────────────
|
|
|
|
describe("getTransactions — owner scoping", () => {
|
|
it("returns only the owner's transactions", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { description: "Alice groceries" });
|
|
await insertTransaction(pool, otherId, { description: "Bob petrol" });
|
|
|
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
|
expect(data).toHaveLength(1);
|
|
expect(data[0].description).toBe("Alice groceries");
|
|
});
|
|
|
|
it("includes transactions where owner is a split participant", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, otherId, { description: "Shared dinner" });
|
|
|
|
// Add Alice as a split participant
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[txId, ownerId]
|
|
);
|
|
|
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
|
expect(data.some((t) => t.description === "Shared dinner")).toBe(true);
|
|
});
|
|
|
|
it("returns correct total count", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { description: "tx1" });
|
|
await insertTransaction(pool, ownerId, { description: "tx2" });
|
|
await insertTransaction(pool, ownerId, { description: "tx3" });
|
|
|
|
const { total } = await getTransactions(ownerId, { limit: 2, offset: 0 });
|
|
expect(total).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("getTransactions — date filters", () => {
|
|
it("filters by from date", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" });
|
|
await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" });
|
|
|
|
const { data } = await getTransactions(ownerId, { from: "2024-02-01", limit: 50, offset: 0 });
|
|
expect(data).toHaveLength(1);
|
|
expect(data[0].description).toBe("new tx");
|
|
});
|
|
|
|
it("filters by to date", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" });
|
|
await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" });
|
|
|
|
const { data } = await getTransactions(ownerId, { to: "2024-01-31", limit: 50, offset: 0 });
|
|
expect(data).toHaveLength(1);
|
|
expect(data[0].description).toBe("old tx");
|
|
});
|
|
});
|
|
|
|
describe("getTransactions — category filter", () => {
|
|
it("filters by category", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { description: "Grocery run", category: "groceries" });
|
|
await insertTransaction(pool, ownerId, { description: "Dinner out", category: "dining" });
|
|
|
|
const { data } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 });
|
|
expect(data).toHaveLength(1);
|
|
expect(data[0].description).toBe("Grocery run");
|
|
});
|
|
|
|
it("category override takes precedence over raw category", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, ownerId, { category: "dining" });
|
|
await pool.query(
|
|
`INSERT INTO transaction_overrides (transaction_id, category_override) VALUES ($1, 'groceries')`,
|
|
[txId]
|
|
);
|
|
|
|
const { data: dining } = await getTransactions(ownerId, { categories: ["dining"], limit: 50, offset: 0 });
|
|
const { data: groceries } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 });
|
|
expect(dining).toHaveLength(0); // override hides original
|
|
expect(groceries).toHaveLength(1); // override exposes new category
|
|
});
|
|
});
|
|
|
|
describe("getTransactions — search filter", () => {
|
|
it("searches description case-insensitively", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { description: "COLES WYNDHAM" });
|
|
await insertTransaction(pool, ownerId, { description: "ALDI POINT COOK" });
|
|
|
|
const { data } = await getTransactions(ownerId, { search: "coles", limit: 50, offset: 0 });
|
|
expect(data).toHaveLength(1);
|
|
expect(data[0].description).toBe("COLES WYNDHAM");
|
|
});
|
|
});
|
|
|
|
describe("getTransactions — amount filters", () => {
|
|
it("filters by amount_min", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { amount: 20 });
|
|
await insertTransaction(pool, ownerId, { amount: 200 });
|
|
|
|
const { data } = await getTransactions(ownerId, { amount_min: 100, limit: 50, offset: 0 });
|
|
expect(data).toHaveLength(1);
|
|
expect(Number(data[0].amount)).toBe(200);
|
|
});
|
|
|
|
it("filters by amount_max", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { amount: 20 });
|
|
await insertTransaction(pool, ownerId, { amount: 200 });
|
|
|
|
const { data } = await getTransactions(ownerId, { amount_max: 50, limit: 50, offset: 0 });
|
|
expect(data).toHaveLength(1);
|
|
expect(Number(data[0].amount)).toBe(20);
|
|
});
|
|
});
|
|
|
|
describe("getTransactions — pagination", () => {
|
|
it("respects limit and offset", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
for (let i = 0; i < 5; i++) {
|
|
await insertTransaction(pool, ownerId, { description: `tx-${i}`, transaction_date: `2024-0${i + 1}-01` });
|
|
}
|
|
|
|
const page1 = await getTransactions(ownerId, { limit: 2, offset: 0 });
|
|
const page2 = await getTransactions(ownerId, { limit: 2, offset: 2 });
|
|
expect(page1.data).toHaveLength(2);
|
|
expect(page2.data).toHaveLength(2);
|
|
expect(page1.data[0].description).not.toBe(page2.data[0].description);
|
|
expect(page1.total).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe("getTransactions — splits and tags attached", () => {
|
|
it("attaches empty arrays when no splits or tags", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId);
|
|
|
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
|
expect(data[0].splits).toEqual([]);
|
|
expect(data[0].tags).toEqual([]);
|
|
});
|
|
|
|
it("attaches split participants", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"]);
|
|
const txId = await insertTransaction(pool, ownerId);
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[txId, otherId]
|
|
);
|
|
|
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
|
expect(data[0].splits).toHaveLength(1);
|
|
expect(data[0].splits[0].name).toBe("Bob");
|
|
expect(Number(data[0].splits[0].share_percent)).toBe(50);
|
|
});
|
|
});
|
|
|
|
// ── getParticipantBalances ────────────────────────────────────────────────────
|
|
|
|
describe("getParticipantBalances", () => {
|
|
it("shows zero balance when no splits", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
void otherId;
|
|
const balances = await getParticipantBalances(ownerId);
|
|
expect(balances.every((b) => Number(b.total_owed) === 0)).toBe(true);
|
|
});
|
|
|
|
it("calculates positive balance when participant owes owner", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
// Alice pays $100, Bob owes 50%
|
|
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[txId, otherId]
|
|
);
|
|
|
|
const balances = await getParticipantBalances(ownerId);
|
|
const bobBalance = balances.find((b) => b.id === otherId);
|
|
expect(bobBalance).toBeDefined();
|
|
expect(Number(bobBalance!.total_owed)).toBeCloseTo(50);
|
|
});
|
|
|
|
it("reduces balance after recording a payment", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[txId, otherId]
|
|
);
|
|
// Bob pays Alice $30
|
|
await pool.query(
|
|
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date)
|
|
VALUES ($1, $2, 30, '2024-06-20')`,
|
|
[otherId, ownerId]
|
|
);
|
|
|
|
const balances = await getParticipantBalances(ownerId);
|
|
const bobBalance = balances.find((b) => b.id === otherId);
|
|
expect(Number(bobBalance!.total_owed)).toBeCloseTo(20);
|
|
});
|
|
|
|
it("shows negative balance when owner owes participant", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
// Bob pays $100 for a shared expense, Alice owes 50%
|
|
const txId = await insertTransaction(pool, otherId, { amount: 100 });
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[txId, ownerId]
|
|
);
|
|
|
|
const balances = await getParticipantBalances(ownerId);
|
|
const bobBalance = balances.find((b) => b.id === otherId);
|
|
expect(Number(bobBalance!.total_owed)).toBeCloseTo(-50);
|
|
});
|
|
|
|
it("unsettled_count reflects open splits", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tx1 = await insertTransaction(pool, ownerId, { amount: 100 });
|
|
const tx2 = await insertTransaction(pool, ownerId, { amount: 80 });
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, 50), ($3, $4, 50)`,
|
|
[tx1, otherId, tx2, otherId]
|
|
);
|
|
|
|
const balances = await getParticipantBalances(ownerId);
|
|
const bobBalance = balances.find((b) => b.id === otherId);
|
|
expect(bobBalance!.unsettled_count).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("getTransactions — order provenance for the description sub-line", () => {
|
|
it("carries the route and platform of an order-derived row", async () => {
|
|
// Five rows all reading "Order - Uber Trip" are indistinguishable in the
|
|
// list; where the trip went is the only thing that separates them, and it
|
|
// was already stored.
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, ownerId, { description: "Order - Uber Trip" });
|
|
await pool.query(
|
|
`INSERT INTO expense_metadata (source, order_reference, platform, route, transaction_id)
|
|
VALUES ('email', $1, 'uber',
|
|
'[{"label":"Pick-up","time":"7:32 pm","address":"Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia"},
|
|
{"label":"Drop-off","time":"8:10 pm","address":"19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia"}]'::jsonb,
|
|
$2)`,
|
|
[`route-${Date.now()}`, txId]
|
|
);
|
|
|
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
|
const row = data.find((r) => r.id === txId)!;
|
|
expect(row.order_platform).toBe("uber");
|
|
expect(row.order_route).toHaveLength(2);
|
|
expect(row.order_route![0].address).toContain("Melbourne Airport");
|
|
});
|
|
|
|
it("resolves from the statement line for a card-settled order", async () => {
|
|
// A card-settled order creates no transaction of its own (I5) — the
|
|
// receipt points at the statement line through matched_transaction_id.
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, ownerId, { description: "UBER *TRIP AUCKLAND" });
|
|
await pool.query(
|
|
`INSERT INTO expense_metadata (source, order_reference, platform, route, matched_transaction_id)
|
|
VALUES ('email', $1, 'uber',
|
|
'[{"label":"Pick-up","time":null,"address":"64 Federal Street, Auckland 1010, NZ"},
|
|
{"label":"Drop-off","time":null,"address":"International Terminal, Auckland 2022, New Zealand"}]'::jsonb,
|
|
$2)`,
|
|
[`route-card-${Date.now()}`, txId]
|
|
);
|
|
|
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
|
const row = data.find((r) => r.id === txId)!;
|
|
expect(row.order_route).toHaveLength(2);
|
|
});
|
|
|
|
it("leaves an ordinary transaction with no route", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, ownerId, { description: "COLES 1234" });
|
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
|
const row = data.find((r) => r.id === txId)!;
|
|
expect(row.order_route).toBeNull();
|
|
expect(row.order_platform).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── settlement scope: settled + split_payments.trip_id (migration 0022) ───────
|
|
|
|
describe("getParticipantBalances — settled", () => {
|
|
it("excludes a settled split from what is owed", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
|
VALUES ($1, $2, 50, true)`,
|
|
[txId, otherId]
|
|
);
|
|
|
|
const balances = await getParticipantBalances(ownerId);
|
|
const bob = balances.find((b) => b.id === otherId);
|
|
expect(Number(bob!.total_owed)).toBeCloseTo(0);
|
|
});
|
|
|
|
it("still counts an unsettled split alongside a settled one", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const settledTx = await insertTransaction(pool, ownerId, { amount: 100 });
|
|
const liveTx = await insertTransaction(pool, ownerId, { amount: 40 });
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
|
VALUES ($1, $2, 50, true), ($3, $2, 50, false)`,
|
|
[settledTx, otherId, liveTx]
|
|
);
|
|
|
|
const balances = await getParticipantBalances(ownerId);
|
|
const bob = balances.find((b) => b.id === otherId);
|
|
// Only the live split counts: 50% of 40.
|
|
expect(Number(bob!.total_owed)).toBeCloseTo(20);
|
|
});
|
|
});
|
|
|
|
describe("getTripAnalytics — per-trip settlement", () => {
|
|
async function seedTrip(ownerId: number, otherId: number) {
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name, start_date, end_date)
|
|
VALUES ($1, 'Test Trip', '2026-03-01', '2026-03-10') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
const txId = await insertTransaction(pool, ownerId, { amount: 200, category: "travel" });
|
|
await pool.query(
|
|
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
|
[txId, tripId]
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, 50)`,
|
|
[txId, otherId]
|
|
);
|
|
return tripId;
|
|
}
|
|
|
|
it("reports the gross share before any payment", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seedTrip(ownerId, otherId);
|
|
|
|
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
|
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
|
expect(Number(bob!.owed)).toBeCloseTo(100);
|
|
});
|
|
|
|
it("nets off a payment scoped to that trip", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seedTrip(ownerId, otherId);
|
|
await pool.query(
|
|
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
|
VALUES ($1, $2, 60, '2026-03-15', $3)`,
|
|
[otherId, ownerId, tripId]
|
|
);
|
|
|
|
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
|
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
|
expect(Number(bob!.owed)).toBeCloseTo(40);
|
|
});
|
|
|
|
// The point of the whole scope column: settling the household tab must not
|
|
// make a trip look paid. Before trip_id existed there was one global pool and
|
|
// this distinction could not be expressed.
|
|
it("ignores a household payment when reporting the trip", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seedTrip(ownerId, otherId);
|
|
await pool.query(
|
|
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
|
VALUES ($1, $2, 60, '2026-03-15', NULL)`,
|
|
[otherId, ownerId]
|
|
);
|
|
|
|
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
|
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
|
expect(Number(bob!.owed)).toBeCloseTo(100);
|
|
});
|
|
|
|
it("drops a settled split from the trip figure", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seedTrip(ownerId, otherId);
|
|
await pool.query(`UPDATE transaction_splits SET settled = true WHERE participant_id = $1`, [otherId]);
|
|
|
|
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
|
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
|
expect(bob === undefined || Number(bob.owed) === 0).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("getTripAnalytics — owner scoping", () => {
|
|
it("ignores a trip expense someone else paid for", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Owner Scope Trip') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
|
|
// Bob paid this one. Alice's share of it is a debt Alice owes Bob — it is
|
|
// not something Bob owes Alice, so it must not appear on Alice's trip view.
|
|
const bobPaid = await insertTransaction(pool, otherId, { amount: 500, category: "travel" });
|
|
await pool.query(`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`, [bobPaid, tripId]);
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, 50), ($1, $3, 50)`,
|
|
[bobPaid, ownerId, otherId]
|
|
);
|
|
|
|
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
|
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
|
expect(bob === undefined || Number(bob.owed) === 0).toBe(true);
|
|
});
|
|
|
|
it("ignores a payment settled between the other two participants", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const third = await pool.query(
|
|
`INSERT INTO participants (name, email) VALUES ('Carol', 'carol@example.com') RETURNING id`
|
|
);
|
|
const carolId = third.rows[0].id as number;
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Third Party Trip') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
|
|
const txId = await insertTransaction(pool, ownerId, { amount: 300, category: "travel" });
|
|
await pool.query(`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`, [txId, tripId]);
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[txId, carolId]
|
|
);
|
|
// Carol pays Bob, not the owner. Carol still owes the owner $150.
|
|
await pool.query(
|
|
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
|
VALUES ($1, $2, 150, '2026-03-15', $3)`,
|
|
[carolId, otherId, tripId]
|
|
);
|
|
|
|
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
|
const carol = participant_splits.find((r) => r.participant_id === carolId);
|
|
expect(Number(carol!.owed)).toBeCloseTo(150);
|
|
});
|
|
});
|
|
|
|
// A refunded trip expense must not still read as trip cost. These queries
|
|
// filtered on debit/fee/interest, so a refund was dropped entirely and the
|
|
// original purchase stood at full value.
|
|
describe("getTripAnalytics — refunds reduce trip cost", () => {
|
|
async function seedTripWithRefund(ownerId: number) {
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name, start_date, end_date)
|
|
VALUES ($1, 'Refund Trip', '2026-03-01', '2026-03-10') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
|
|
const spend = await insertTransaction(pool, ownerId, {
|
|
amount: 200, category: "travel", description: "Hotel booking", transaction_date: "2026-03-02",
|
|
});
|
|
const refund = await insertTransaction(pool, ownerId, {
|
|
amount: 50, category: "travel", description: "Hotel partial refund",
|
|
transaction_type: "refund", transaction_date: "2026-03-05",
|
|
});
|
|
await pool.query(
|
|
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $3), ($2, $3)`,
|
|
[spend, refund, tripId]
|
|
);
|
|
return tripId;
|
|
}
|
|
|
|
it("nets the refund out of the category total", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const tripId = await seedTripWithRefund(ownerId);
|
|
|
|
const { category_breakdown } = await getTripAnalytics(tripId, ownerId);
|
|
const travel = category_breakdown.find((c) => c.category === "travel");
|
|
expect(Number(travel!.amount)).toBeCloseTo(150);
|
|
});
|
|
|
|
it("nets the refund out of the trip's headline total_spend", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const tripId = await seedTripWithRefund(ownerId);
|
|
|
|
const trip = await getTripById(tripId, ownerId);
|
|
expect(Number(trip!.total_spend)).toBeCloseTo(150);
|
|
});
|
|
|
|
it("shows the refund as a negative on its own day", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const tripId = await seedTripWithRefund(ownerId);
|
|
|
|
const { daily_spend } = await getTripAnalytics(tripId, ownerId);
|
|
const refundDay = daily_spend.find((d) => d.date === "2026-03-05");
|
|
expect(Number(refundDay!.amount)).toBeCloseTo(-50);
|
|
});
|
|
|
|
// The owed side must be untouched: a refund carries no split, and the owed
|
|
// query deliberately excludes credits. Netting cost must not move a balance.
|
|
it("leaves what the other participant owes unchanged", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seedTripWithRefund(ownerId);
|
|
const rows = await pool.query(
|
|
`SELECT transaction_id FROM transaction_overrides WHERE trip_id = $1 ORDER BY transaction_id`,
|
|
[tripId]
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[rows.rows[0].transaction_id, otherId]
|
|
);
|
|
|
|
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
|
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
|
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);
|
|
});
|
|
});
|
|
|
|
// A statement imported twice puts every transaction in the overlap in the
|
|
// ledger twice. The duplicate is superseded rather than deleted, because every
|
|
// child of `transactions` cascades on delete.
|
|
describe("superseded duplicates are excluded but kept", () => {
|
|
it("hides a superseded row from the transaction list", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const keep = await insertTransaction(pool, ownerId, { description: "RAIZ INVESTMENT", amount: 1500 });
|
|
const dup = await insertTransaction(pool, ownerId, { description: "RAIZ INVESTMENT", amount: 1500 });
|
|
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
|
|
|
|
const { data, total } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
|
expect(total).toBe(1);
|
|
expect(data.map((t) => t.id)).toEqual([keep]);
|
|
});
|
|
|
|
it("keeps the superseded row and its children in the database", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const keep = await insertTransaction(pool, ownerId, { amount: 100 });
|
|
const dup = await insertTransaction(pool, ownerId, { amount: 100 });
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[dup, otherId]
|
|
);
|
|
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
|
|
|
|
const rows = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id = $1`, [dup]);
|
|
expect(rows.rows[0].superseded_by_id).toBe(keep);
|
|
const kids = await pool.query(`SELECT count(*)::int AS n FROM transaction_splits WHERE transaction_id = $1`, [dup]);
|
|
expect(kids.rows[0].n).toBe(1);
|
|
});
|
|
|
|
// The point of excluding it: a split on a duplicate must not be owed twice.
|
|
it("does not count a superseded row towards what someone owes", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const keep = await insertTransaction(pool, ownerId, { amount: 100 });
|
|
const dup = await insertTransaction(pool, ownerId, { amount: 100 });
|
|
for (const id of [keep, dup]) {
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[id, otherId]
|
|
);
|
|
}
|
|
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
|
|
|
|
const balances = await getParticipantBalances(ownerId);
|
|
const bob = balances.find((b) => b.id === otherId);
|
|
expect(Number(bob!.total_owed)).toBeCloseTo(50);
|
|
});
|
|
|
|
it("refuses to let a row supersede itself", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const id = await insertTransaction(pool, ownerId);
|
|
await expect(
|
|
pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $1`, [id])
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
// Nothing before the cutover can be owed: carryover transaction 2348 already
|
|
// carries the entire pre-cutover balance as one figure. Splits on older
|
|
// transactions exist to describe how an expense was shared -- which keeps it
|
|
// out of spend -- without asserting a debt.
|
|
describe("the split cutover gates every balance", () => {
|
|
it("ignores a split on a transaction before the cutover", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, ownerId, {
|
|
amount: 100, transaction_date: "2026-01-08",
|
|
});
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[txId, otherId]
|
|
);
|
|
|
|
const balances = await getParticipantBalances(ownerId);
|
|
const bob = balances.find((b) => b.id === otherId);
|
|
expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0);
|
|
});
|
|
|
|
// Inclusive: transaction 2348, which carries the whole pre-cutover balance,
|
|
// is itself dated 2026-01-09. An exclusive bound would drop it.
|
|
it("counts a split dated exactly on the cutover", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, ownerId, {
|
|
amount: 100, transaction_date: "2026-01-09",
|
|
});
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
|
[txId, otherId]
|
|
);
|
|
|
|
const balances = await getParticipantBalances(ownerId);
|
|
const bob = balances.find((b) => b.id === otherId);
|
|
expect(Number(bob!.total_owed)).toBeCloseTo(50);
|
|
});
|
|
|
|
// The point of the date guard: it does not depend on `settled` surviving.
|
|
it("still ignores a pre-cutover split whose settled flag was lost", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, ownerId, {
|
|
amount: 200, transaction_date: "2025-06-01",
|
|
});
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
|
VALUES ($1, $2, 50, false)`,
|
|
[txId, otherId]
|
|
);
|
|
|
|
const balances = await getParticipantBalances(ownerId);
|
|
const bob = balances.find((b) => b.id === otherId);
|
|
expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0);
|
|
});
|
|
});
|