ci / lint-test (push) Successful in 48s
Shared view: the query already returned owner_name and effective_category, the table just never rendered them. Paid by sits next to Splits because together they are whose money went out and whose share it was. Search is client-side — this endpoint returns all 1,267 split rows in one request with no pagination, so there is nothing for a round-trip to narrow, and the sort was already client-side. It matches description, merchant, notes, category and payer, but not participant names: the dropdown does that, and "sonu" matching every row she is split on would read as broken. Trip owed collapses to one settle-up figure per person, with the breakdown beside it so the net is auditable rather than asserted. I argued against netting a few hours ago and was wrong. The claim was that the grouped-payment allocation cleared each trip against the one-directional gross, so netting would redefine that debt after the fact. The rows say otherwise: Europe's $802.75 is 56 transactions Sonu actually paid across Rome, Venice, the Dolomites, Bellagio, Lucerne and Paris on which I hold 25%, and paid_by_me is $0.00 on every row of every trip because nothing has ever been recorded going from me to her. Her side looked settled only because the allocation derived her payment split from her gross, so it lands on zero by construction. The one-directional view was hiding a live obligation, not protecting an allocation. Nets now: Auckland Sonu +$1,077.25, Europe Sonu -$802.75, Sonu + Sunny -$936.34, Europe Molina -$816.16. Also correcting an error in my own reporting: I said Auckland's mirror was $0.00. It is $428.39 — 17 Auckland rows Sonu paid that I hold a split on. Two ad-hoc verification queries mis-joined on a nullable scope column and under-reported the mirror side. The app code was never affected and the owed column is still byte-identical. The footnote now states the trap the netting exposes: a debt settled by a payment left on the household tab still reads as outstanding on the trip. Payment 5 (Molina to Sonu, $1,605.49) is exactly that case and is left alone as a data decision. 277 passing, build clean.
1181 lines
51 KiB
TypeScript
1181 lines
51 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,
|
|
getTrips, isTripParticipant, assignTransactionsToTrip, deleteTrip,
|
|
} = 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 — exclude_categories", () => {
|
|
it("hides the excluded category", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { description: "Grocery run", category: "groceries" });
|
|
await insertTransaction(pool, ownerId, { description: "Card payment", category: "transfers" });
|
|
|
|
const { data, total } = await getTransactions(ownerId, {
|
|
exclude_categories: ["transfers"], limit: 50, offset: 0,
|
|
});
|
|
expect(data).toHaveLength(1);
|
|
expect(total).toBe(1);
|
|
expect(data[0].description).toBe("Grocery run");
|
|
});
|
|
|
|
it("keeps uncategorised rows visible", async () => {
|
|
// NULL <> ALL(...) is NULL, not true. Without the COALESCE an uncategorised
|
|
// row would vanish from a filter that never named its category.
|
|
const { ownerId } = await seedParticipants(pool);
|
|
// insertTransaction defaults category to 'other', so insert directly.
|
|
await pool.query(
|
|
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, transaction_type, category, row_index)
|
|
VALUES ($1, NULL, '2026-06-15', 'Unknown thing', 100, 'debit', NULL, 0)`,
|
|
[ownerId]
|
|
);
|
|
|
|
const { data } = await getTransactions(ownerId, {
|
|
exclude_categories: ["transfers"], limit: 50, offset: 0,
|
|
});
|
|
expect(data).toHaveLength(1);
|
|
expect(data[0].description).toBe("Unknown thing");
|
|
});
|
|
|
|
it("an explicit category pick beats the exclusion", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { description: "Card payment", category: "transfers" });
|
|
|
|
const { data } = await getTransactions(ownerId, {
|
|
categories: ["transfers"], exclude_categories: ["transfers"], limit: 50, offset: 0,
|
|
});
|
|
expect(data).toHaveLength(1);
|
|
});
|
|
|
|
it("respects the category override, not the raw category", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const txId = await insertTransaction(pool, ownerId, { description: "Was a transfer", category: "transfers" });
|
|
await pool.query(
|
|
`INSERT INTO transaction_overrides (transaction_id, category_override) VALUES ($1, 'investment')`,
|
|
[txId]
|
|
);
|
|
|
|
const { data } = await getTransactions(ownerId, {
|
|
exclude_categories: ["transfers"], limit: 50, offset: 0,
|
|
});
|
|
expect(data).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ── Trip participation ────────────────────────────────────────────────────────
|
|
//
|
|
// Trips were scoped to `trips.owner_id`, so a co-traveller saw nothing: Sonu
|
|
// could not open a single trip despite paying for 104 of the tagged rows
|
|
// herself. Participation is DERIVED from the expenses rather than stored as a
|
|
// membership list, because a trip is all the expenses on one trip — and two
|
|
// records of one fact drift apart.
|
|
|
|
describe("trip participation — visibility", () => {
|
|
/** A trip owned by `ownerId` with one row `ownerId` paid for. */
|
|
async function tripWithOwnerRow(ownerId: number, name = "Owned Trip") {
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name) VALUES ($1, $2) RETURNING id`,
|
|
[ownerId, name]
|
|
);
|
|
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]
|
|
);
|
|
return { tripId, txId };
|
|
}
|
|
|
|
it("shows a trip to its owner", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const { tripId } = await tripWithOwnerRow(ownerId);
|
|
|
|
const trips = await getTrips(ownerId);
|
|
expect(trips.map((t) => t.id)).toContain(tripId);
|
|
});
|
|
|
|
it("shows a trip to someone holding a split on one of its rows", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const { tripId, txId } = await tripWithOwnerRow(ownerId);
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, 50)`,
|
|
[txId, otherId]
|
|
);
|
|
|
|
const trips = await getTrips(otherId);
|
|
expect(trips.map((t) => t.id)).toContain(tripId);
|
|
expect(await isTripParticipant(tripId, otherId)).toBe(true);
|
|
});
|
|
|
|
it("shows a trip to someone who paid for one of its rows but holds no split", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const { tripId } = await tripWithOwnerRow(ownerId);
|
|
const theirTx = await insertTransaction(pool, otherId, { amount: 80, category: "travel" });
|
|
await pool.query(
|
|
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
|
[theirTx, tripId]
|
|
);
|
|
|
|
expect((await getTrips(otherId)).map((t) => t.id)).toContain(tripId);
|
|
});
|
|
|
|
it("shows a trip to someone whose payment is scoped to it", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const { tripId } = await tripWithOwnerRow(ownerId);
|
|
await pool.query(
|
|
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
|
VALUES ($1, $2, 50, '2026-06-20', $3)`,
|
|
[otherId, ownerId, tripId]
|
|
);
|
|
|
|
expect((await getTrips(otherId)).map((t) => t.id)).toContain(tripId);
|
|
});
|
|
|
|
// The Singapore + Bangkok 2026 case. A trip nobody else took must not appear
|
|
// just because trips became shareable — this is the whole reason
|
|
// participation is derived from the expenses rather than granted.
|
|
it("HIDES a trip from someone with no split, no row and no payment on it", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const { tripId } = await tripWithOwnerRow(ownerId, "Solo Trip");
|
|
|
|
expect((await getTrips(otherId)).map((t) => t.id)).not.toContain(tripId);
|
|
expect(await isTripParticipant(tripId, otherId)).toBe(false);
|
|
expect(await getTripById(tripId, otherId)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("trip owed — both directions, never netted", () => {
|
|
/** `payerId` paid a $200 travel row on the trip; `splitId` holds 50% of it. */
|
|
async function seed(payerId: number, splitId: number, ownerId: number) {
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Pair Trip') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
const txId = await insertTransaction(pool, payerId, { 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, splitId]
|
|
);
|
|
return tripId;
|
|
}
|
|
|
|
it("the payer sees it as owed to them, with nothing on the mirror", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seed(ownerId, 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);
|
|
expect(Number(bob.i_owe)).toBeCloseTo(0);
|
|
expect(Number(bob.i_owe_gross)).toBeCloseTo(0);
|
|
});
|
|
|
|
// The figure that could not exist before. An obligation lives on a row someone
|
|
// ELSE paid for, so a viewer-as-payer query can never contain it — which is
|
|
// why Sonu's Europe page read "you are owed $2,408.24" while omitting the
|
|
// $8,004.04 she owed.
|
|
it("the split holder sees the same figure as owed BY them", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seed(ownerId, otherId, ownerId);
|
|
|
|
const { participant_splits } = await getTripAnalytics(tripId, otherId);
|
|
const alice = participant_splits.find((r) => r.participant_id === ownerId)!;
|
|
expect(Number(alice.i_owe)).toBeCloseTo(100);
|
|
expect(Number(alice.owed)).toBeCloseTo(0);
|
|
});
|
|
|
|
// Europe 2026: paid in full, so the net is zero but the gross is not — the UI
|
|
// needs both to say "settled" rather than a bare "0.00".
|
|
it("keeps gross and paid alongside the net so a paid-up trip reads as settled", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seed(ownerId, otherId, ownerId);
|
|
await pool.query(
|
|
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
|
VALUES ($1, $2, 100, '2026-06-20', $3)`,
|
|
[otherId, ownerId, tripId]
|
|
);
|
|
|
|
const asPayer = await getTripAnalytics(tripId, ownerId);
|
|
const bob = asPayer.participant_splits.find((r) => r.participant_id === otherId)!;
|
|
expect(Number(bob.owed)).toBeCloseTo(0);
|
|
expect(Number(bob.owed_gross)).toBeCloseTo(100);
|
|
expect(Number(bob.paid_to_me)).toBeCloseTo(100);
|
|
|
|
const asDebtor = await getTripAnalytics(tripId, otherId);
|
|
const alice = asDebtor.participant_splits.find((r) => r.participant_id === ownerId)!;
|
|
expect(Number(alice.i_owe)).toBeCloseTo(0);
|
|
expect(Number(alice.i_owe_gross)).toBeCloseTo(100);
|
|
expect(Number(alice.paid_by_me)).toBeCloseTo(100);
|
|
});
|
|
|
|
// The API returns both halves whole; the trip page nets them for display. The
|
|
// halves must stay separately available so that net is decomposable — a net
|
|
// nobody can audit is how a wrong figure survives, and it is what let Europe
|
|
// read "settled" while concealing 56 rows Sonu had paid.
|
|
it("returns each direction whole rather than pre-netted", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seed(ownerId, otherId, ownerId);
|
|
// A second row, paid the other way, so both directions are live at once.
|
|
const theirTx = await insertTransaction(pool, otherId, { amount: 60, category: "travel" });
|
|
await pool.query(
|
|
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
|
[theirTx, tripId]
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, 50)`,
|
|
[theirTx, ownerId]
|
|
);
|
|
|
|
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
|
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
|
|
expect(Number(bob.owed)).toBeCloseTo(100);
|
|
expect(Number(bob.i_owe)).toBeCloseTo(30);
|
|
// What the page displays as the single settle-up figure.
|
|
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(70);
|
|
});
|
|
|
|
// Europe 2026's shape exactly: her side paid in full, his side never paid at
|
|
// all. The one-directional view called that "settled"; the net must not.
|
|
it("nets a fully-paid side against an unpaid opposite side", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seed(ownerId, otherId, ownerId);
|
|
// Bob pays his $100 share in full, scoped to the trip.
|
|
await pool.query(
|
|
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
|
VALUES ($1, $2, 100, '2026-06-20', $3)`,
|
|
[otherId, ownerId, tripId]
|
|
);
|
|
// But Alice holds a share of something Bob paid for, and never settled it.
|
|
const bobsTx = await insertTransaction(pool, otherId, { amount: 40, category: "travel" });
|
|
await pool.query(
|
|
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
|
[bobsTx, tripId]
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, 50)`,
|
|
[bobsTx, ownerId]
|
|
);
|
|
|
|
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
|
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
|
|
expect(Number(bob.owed)).toBeCloseTo(0); // his side: settled
|
|
expect(Number(bob.i_owe)).toBeCloseTo(20); // her side: never paid
|
|
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-20); // net: you owe them
|
|
});
|
|
|
|
it("reports whether the viewer owns the trip", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const tripId = await seed(ownerId, otherId, ownerId);
|
|
|
|
expect((await getTripAnalytics(tripId, ownerId)).viewer_is_owner).toBe(true);
|
|
expect((await getTripAnalytics(tripId, otherId)).viewer_is_owner).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getTransactions — trip_all_rows", () => {
|
|
async function seedSharedTrip() {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Shared Trip') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
// One row Bob holds a split on — this is what makes him a participant.
|
|
const shared = await insertTransaction(pool, ownerId, { description: "Shared hotel", category: "travel" });
|
|
// One row Bob has no stake in whatsoever.
|
|
const solo = await insertTransaction(pool, ownerId, { description: "Alice solo museum", category: "travel" });
|
|
await pool.query(
|
|
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2), ($3, $2)`,
|
|
[shared, tripId, solo]
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, 50)`,
|
|
[shared, otherId]
|
|
);
|
|
return { ownerId, otherId, tripId };
|
|
}
|
|
|
|
it("gives a participant every row on the trip", async () => {
|
|
const { otherId, tripId } = await seedSharedTrip();
|
|
|
|
const { data } = await getTransactions(otherId, {
|
|
trip_id: String(tripId), trip_all_rows: true, limit: 50, offset: 0,
|
|
});
|
|
expect(data.map((r) => r.description).sort()).toEqual(["Alice solo museum", "Shared hotel"]);
|
|
});
|
|
|
|
// The main transactions page filters by trip through this same endpoint. If
|
|
// the widening were implied by trip_id, filtering your own ledger by a trip
|
|
// would silently fill it with someone else's rows and skew its totals.
|
|
it("keeps owner scoping when the flag is absent", async () => {
|
|
const { otherId, tripId } = await seedSharedTrip();
|
|
|
|
const { data } = await getTransactions(otherId, {
|
|
trip_id: String(tripId), limit: 50, offset: 0,
|
|
});
|
|
expect(data.map((r) => r.description)).toEqual(["Shared hotel"]);
|
|
});
|
|
|
|
it("returns nothing to a non-participant who passes the flag", async () => {
|
|
const { ownerId } = await seedSharedTrip();
|
|
const stranger = await pool.query(
|
|
`INSERT INTO participants (name) VALUES ('Carol') RETURNING id`
|
|
);
|
|
const carolId = stranger.rows[0].id as number;
|
|
const trip = await pool.query(`SELECT id FROM trips LIMIT 1`);
|
|
|
|
const { data } = await getTransactions(carolId, {
|
|
trip_id: String(trip.rows[0].id), trip_all_rows: true, limit: 50, offset: 0,
|
|
});
|
|
expect(data).toHaveLength(0);
|
|
expect(ownerId).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("does not widen anything when trip_id is 'unassigned'", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
await insertTransaction(pool, ownerId, { description: "Alice untripped" });
|
|
await insertTransaction(pool, otherId, { description: "Bob untripped" });
|
|
|
|
const { data } = await getTransactions(otherId, {
|
|
trip_id: "unassigned", trip_all_rows: true, limit: 50, offset: 0,
|
|
});
|
|
expect(data.map((r) => r.description)).toEqual(["Bob untripped"]);
|
|
});
|
|
});
|
|
|
|
describe("assignTransactionsToTrip — authorisation", () => {
|
|
it("refuses a trip the caller does not participate in", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Private Trip') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
const bobsTx = await insertTransaction(pool, otherId, { description: "Bob lunch" });
|
|
|
|
await expect(assignTransactionsToTrip(tripId, [bobsTx], otherId)).rejects.toThrow(/participant/i);
|
|
});
|
|
|
|
// The hole this closed: the function took no caller at all, so any
|
|
// authenticated participant could move any transaction id into any trip.
|
|
it("silently skips transactions the caller cannot see", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Alice Trip') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
const mine = await insertTransaction(pool, ownerId, { description: "Alice flight" });
|
|
const theirs = await insertTransaction(pool, otherId, { description: "Bob private" });
|
|
|
|
const moved = await assignTransactionsToTrip(tripId, [mine, theirs], ownerId);
|
|
expect(moved).toBe(1);
|
|
|
|
const rows = await pool.query(
|
|
`SELECT transaction_id FROM transaction_overrides WHERE trip_id = $1`, [tripId]
|
|
);
|
|
expect(rows.rows.map((r) => r.transaction_id)).toEqual([mine]);
|
|
});
|
|
|
|
it("lets a participant assign their own transaction to the trip", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Joint Trip') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
// Make Bob a participant first.
|
|
const seedTx = await insertTransaction(pool, ownerId, { category: "travel" });
|
|
await pool.query(
|
|
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
|
[seedTx, tripId]
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, 50)`,
|
|
[seedTx, otherId]
|
|
);
|
|
|
|
const bobsTx = await insertTransaction(pool, otherId, { description: "Bob taxi" });
|
|
expect(await assignTransactionsToTrip(tripId, [bobsTx], otherId)).toBe(1);
|
|
});
|
|
});
|
|
|
|
// Delete is the one thing that stayed owner-only. Both trip foreign keys are
|
|
// ON DELETE SET NULL, so deleting a trip untags every transaction on it and
|
|
// drops the trip scope from its payments — including a hand-derived allocation
|
|
// that nothing recomputes.
|
|
describe("deleteTrip — owner only", () => {
|
|
it("does not delete when a non-owner participant asks", async () => {
|
|
const { ownerId, otherId } = await seedParticipants(pool);
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Precious Trip') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
const txId = await insertTransaction(pool, ownerId, { 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]
|
|
);
|
|
// Bob can see it...
|
|
expect(await getTripById(tripId, otherId)).not.toBeNull();
|
|
|
|
await deleteTrip(tripId, otherId);
|
|
|
|
// ...and still cannot remove it, nor untag its transaction.
|
|
expect(await getTripById(tripId, ownerId)).not.toBeNull();
|
|
const still = await pool.query(
|
|
`SELECT trip_id FROM transaction_overrides WHERE transaction_id = $1`, [txId]
|
|
);
|
|
expect(still.rows[0].trip_id).toBe(tripId);
|
|
});
|
|
|
|
it("deletes when the owner asks", async () => {
|
|
const { ownerId } = await seedParticipants(pool);
|
|
const trip = await pool.query(
|
|
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Doomed Trip') RETURNING id`,
|
|
[ownerId]
|
|
);
|
|
const tripId = trip.rows[0].id as number;
|
|
|
|
await deleteTrip(tripId, ownerId);
|
|
expect(await getTripById(tripId, ownerId)).toBeNull();
|
|
});
|
|
});
|