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, and the case the UI must NOT call a debt.
  //
  // A payment is allocated to a trip as a lump sum covering the payer's GROSS
  // share, so netting the other side off leaves the trip negative by whatever
  // the payment over-covered. That surplus is carried in the overall balance,
  // not owed to them — which is why the page distinguishes a negative net WITH a
  // payment into the scope (over-covered) from one WITHOUT (genuinely owed).
  // `paid_to_me` is what makes the two separable, so it must stay non-zero here.
  it("goes negative by the over-covered amount when a payment clears the gross", 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 gross, fully paid
    expect(Number(bob.i_owe)).toBeCloseTo(20);    // her share of his spending
    expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-20);
    // The discriminator: he paid into this scope, so the -20 is over-coverage
    // rather than a bill. Without paid_to_me the page cannot tell the two apart.
    expect(Number(bob.paid_to_me)).toBeGreaterThan(0);
  });

  it("goes negative with no payment when the viewer's share simply exceeds theirs", async () => {
    const { ownerId, otherId } = await seedParticipants(pool);
    const trip = await pool.query(
      `INSERT INTO trips (owner_id, name) VALUES ($1, 'Unpaid Trip') RETURNING id`,
      [ownerId]
    );
    const tripId = trip.rows[0].id as number;
    // Only one row, paid by Bob, with Alice holding half. Nobody has paid anyone.
    const bobsTx = await insertTransaction(pool, otherId, { amount: 90, 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) - Number(bob.i_owe)).toBeCloseTo(-45);
    // No payment into the scope, so this negative IS Alice's to settle.
    expect(Number(bob.paid_to_me)).toBeCloseTo(0);
  });

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