Files
finance-app/src/__tests__/integration/queries.test.ts
T
siddharthd 689fadc8b9 feat(shared): give a payment a tab to settle
A payment has only ever recorded from, to, amount and date. That is why
the per-trip owed figure did not exist — getTripAnalytics said so where
the number should have been: "split_payments carries no trip attribution,
so a payment cannot be assigned to a trip. Settlement is a property of the
whole relationship." Every trip therefore read 100% unsettled, including
trips paid in full.

It is also why the Shared page silently drops payments under a tag filter.
With one global pool there was nothing honest to subtract, so it showed
gross splits under the same label. A tag is a view; a scope is a ledger.

The scope is a trip, not a new settlement_contexts table. trips already
has owner_id, dates and archived, and transaction_overrides.trip_id
already decides membership. A second grouping beside it would be two
unsynchronised scopes over the same rows, with no invariant saying which
governs. NULL means the ongoing household tab, which never closes.

settled answers a different question and the two must not be collapsed:
trip_id is which tab, settled is whether the obligation is still live.
Critically, a live obligation is NOT settled by flipping the flag — it is
settled by recording the payment, and the balance nets to zero on its own.
Doing both would subtract the settlement twice. So settled is written only
by the historical import, for repayments made on a platform we no longer
run, and there is deliberately no "mark settled" action.

Both owed figures now exclude settled splits and the trip figure nets its
own payments. Spend analytics (myShare/mySplitOf) deliberately still count
settled rows: my half of a 2025 grocery shop is my spend whether or not the
other half was ever repaid, and filtering them would re-inflate exactly the
figures importing settled history exists to correct.

Also drops /api/participants/[id]/balance. It had no consumers, no owner
scoping, no debit/credit signs and no EXCLUDE_RECONCILED_SOURCE — a fourth
balance implementation that disagreed with the others and would have
imported three bugs if anything had aligned to it.

getTripAnalytics had no test at all. It has five now, including the one
that matters: a household payment must not make a trip look paid. Verified
by mutation — neutering the settled filter fails three, and dropping the
trip filter on payments fails that one.
2026-07-27 23:12:05 +10:00

414 lines
18 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 } = 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);
});
});