fix(trips): money that came back is not what the trip cost
ci / lint-test (push) Successful in 1m28s
ci / lint-test (push) Successful in 1m28s
Every trip figure filtered on transaction_type IN ('debit','fee','interest'),
which drops refunds and credits outright. A partly-refunded booking therefore
read at its full price and the refund subtracted nothing, anywhere: the
headline total_spend, the category breakdown, the daily chart, top merchants
and the tag breakdown were all gross.
This is the same defect the general analytics fixed once already, which is why
NET_SPEND_ROWS and SPEND_SIGNED exist -- a refunded Expedia purchase read as
$2,888.92 of spend until they did. Trip analytics never adopted them. Doing so
now costs one predicate and one expression per query.
getTrips/getTripById needed the trips alias moved to `tr`: the fragments assume
`t` is `transactions`, and hand-inlining a copy rather than renaming is exactly
how the reconciled-row exclusion drifted out of the analytics routes before.
On Europe 2026 this is $821.12 -- a LuxuryEscapes booking with two part-credits
against it, and a FreeNow hold adjustment. Fully cancelled bookings are a
different case and are handled by untagging both legs from the trip by hand,
because a trip never incurred a cost it cancelled.
No balance moves: the owed query already excludes credits and a refund carries
no split. There is a test asserting exactly that, and it passes with or without
this change -- it is a guard, not a proof. The three that do prove it fail
without it.
This commit is contained in:
@@ -8,7 +8,7 @@ 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");
|
||||
const { getTransactions, getParticipantBalances, getTripAnalytics, getTripById } = await import("@/lib/queries");
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
@@ -466,3 +466,75 @@ describe("getTripAnalytics — owner scoping", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user