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); }); });