Compare commits
2
Commits
a4ab543a6c
...
ae23b03d5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae23b03d5d | ||
|
|
689fadc8b9 |
@@ -0,0 +1,48 @@
|
|||||||
|
-- Settlement scope: which tab a payment settles.
|
||||||
|
--
|
||||||
|
-- `split_payments` has carried from/to/amount/date since it was written and
|
||||||
|
-- nothing else. That is the whole reason a per-trip balance has never been
|
||||||
|
-- computable — `getTripAnalytics` says so in a comment where the figure should
|
||||||
|
-- be: "split_payments carries no trip attribution, so a payment cannot be
|
||||||
|
-- assigned to a trip. Settlement is a property of the whole relationship."
|
||||||
|
--
|
||||||
|
-- It is also the reason the Shared page silently drops payments the moment a
|
||||||
|
-- tag filter is applied (`getParticipantBalances`): with one global payments
|
||||||
|
-- pool there is no honest way to show a filtered balance, so it showed gross
|
||||||
|
-- splits under the same label instead. A tag is a view; a scope is a ledger.
|
||||||
|
--
|
||||||
|
-- The scope is a *trip*, not a new `settlement_contexts` table. `trips` already
|
||||||
|
-- has owner_id, dates and an archived flag, and `transaction_overrides.trip_id`
|
||||||
|
-- already decides which transactions belong to it. A second grouping beside it
|
||||||
|
-- would be two unsynchronised scopes over the same rows — a trip could hold a
|
||||||
|
-- mix of contexts and a context could span trips, with no invariant saying
|
||||||
|
-- which one governs.
|
||||||
|
--
|
||||||
|
-- NULL means the ongoing household tab. That tab never closes, which is why
|
||||||
|
-- this is nullable rather than defaulted to some "general" row: absence is the
|
||||||
|
-- honest representation of "not attached to a trip", and it keeps every
|
||||||
|
-- existing payment correct without a backfill.
|
||||||
|
|
||||||
|
ALTER TABLE split_payments
|
||||||
|
ADD COLUMN IF NOT EXISTS trip_id integer REFERENCES trips(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN split_payments.trip_id IS
|
||||||
|
'The trip this payment settles. NULL = the ongoing household tab.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_split_payments_trip
|
||||||
|
ON split_payments (trip_id)
|
||||||
|
WHERE trip_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- `settled` answers a different question and the two must not be collapsed:
|
||||||
|
-- trip_id is *which tab*, settled is *is this obligation still live*. A
|
||||||
|
-- pre-2026 historical split is settled with no tab; a Europe split becomes
|
||||||
|
-- settled when Europe's payment lands; a household split stays unsettled and
|
||||||
|
-- open indefinitely.
|
||||||
|
--
|
||||||
|
-- Nothing writes `settled` today. The comment in queries.ts claims
|
||||||
|
-- /api/splits/settle does — that route does not exist, and the column is false
|
||||||
|
-- on all 1,279 rows, which is why every trip has always reported 100%
|
||||||
|
-- unsettled including trips paid in full.
|
||||||
|
|
||||||
|
COMMENT ON COLUMN transaction_splits.settled IS
|
||||||
|
'Obligation discharged. Excluded from owed figures; still counted in spend analytics.';
|
||||||
@@ -18,6 +18,7 @@ model trips {
|
|||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
overrides transaction_overrides[]
|
overrides transaction_overrides[]
|
||||||
|
payments split_payments[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model transaction_overrides {
|
model transaction_overrides {
|
||||||
@@ -75,9 +76,13 @@ model split_payments {
|
|||||||
payment_date DateTime @db.Date
|
payment_date DateTime @db.Date
|
||||||
notes String?
|
notes String?
|
||||||
linked_transaction_id Int?
|
linked_transaction_id Int?
|
||||||
|
trip_id Int?
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
|
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
|
||||||
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
|
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
|
||||||
|
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([trip_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model tags {
|
model tags {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export async function resetDB(pool: Pool) {
|
|||||||
transactions,
|
transactions,
|
||||||
statements,
|
statements,
|
||||||
tags,
|
tags,
|
||||||
|
trips,
|
||||||
participants
|
participants
|
||||||
RESTART IDENTITY CASCADE
|
RESTART IDENTITY CASCADE
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ mockDbWithPool(pool);
|
|||||||
|
|
||||||
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
|
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
|
||||||
// use the test pool rather than Prisma's singleton.
|
// use the test pool rather than Prisma's singleton.
|
||||||
const { getTransactions, getParticipantBalances } = await import("@/lib/queries");
|
const { getTransactions, getParticipantBalances, getTripAnalytics } = await import("@/lib/queries");
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetDB(pool);
|
await resetDB(pool);
|
||||||
@@ -305,3 +305,109 @@ describe("getTransactions — order provenance for the description sub-line", ()
|
|||||||
expect(row.order_platform).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { queryRaw } from "@/lib/db";
|
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
|
||||||
|
|
||||||
interface BalanceRow {
|
|
||||||
participant_id: number;
|
|
||||||
name: string;
|
|
||||||
total_owed: number;
|
|
||||||
transaction_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const user = await getCurrentUser(req);
|
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const rows = await queryRaw<BalanceRow>(
|
|
||||||
`SELECT ts.participant_id, p.name,
|
|
||||||
SUM(COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100)::numeric(12,2) as total_owed,
|
|
||||||
COUNT(*)::int as transaction_count
|
|
||||||
FROM transaction_splits ts
|
|
||||||
JOIN transactions t ON t.id = ts.transaction_id
|
|
||||||
JOIN participants p ON p.id = ts.participant_id
|
|
||||||
WHERE ts.participant_id = $1 AND ts.settled = false
|
|
||||||
GROUP BY ts.participant_id, p.name`,
|
|
||||||
[Number(id)]
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
rows[0] ?? { participant_id: Number(id), total_owed: 0, transaction_count: 0 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -277,7 +277,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800">
|
<tr className="border-b border-zinc-800">
|
||||||
{["Person", "Share of this trip"].map((h) => (
|
{["Person", "Outstanding on this trip"].map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
|
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
|
||||||
@@ -291,7 +291,16 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
{participant_splits.map((p) => (
|
{participant_splits.map((p) => (
|
||||||
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
|
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
|
||||||
<td className="px-5 py-3 font-medium">{p.name}</td>
|
<td className="px-5 py-3 font-medium">{p.name}</td>
|
||||||
<td className="px-5 py-3 text-right tabular-nums font-mono">${Number(p.owed).toFixed(2)}</td>
|
<td className="px-5 py-3 text-right tabular-nums font-mono">
|
||||||
|
<span className={Math.abs(Number(p.owed)) < 0.005 ? "text-zinc-500" : ""}>
|
||||||
|
${Number(p.owed).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
{p.unconverted_count > 0 && (
|
||||||
|
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
|
||||||
|
approx · {p.unconverted_count} unconverted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -171,3 +171,42 @@ export const SPEND_SIGNED = `CASE
|
|||||||
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE})
|
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE})
|
||||||
ELSE (${SPEND_BASE})
|
ELSE (${SPEND_BASE})
|
||||||
END`;
|
END`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A split that still counts towards what someone owes.
|
||||||
|
*
|
||||||
|
* This is the line between the two questions the same table answers, and
|
||||||
|
* conflating them is what made three different "owed" figures disagree:
|
||||||
|
*
|
||||||
|
* - **Owed** — what is still outstanding between two people. Must apply this.
|
||||||
|
* - **Spend** — what a purchase cost me. Must NOT apply this.
|
||||||
|
*
|
||||||
|
* A settled split is still a real expense: my half of a 2025 grocery shop is my
|
||||||
|
* spend whether or not the other half was ever repaid. Filtering settled rows
|
||||||
|
* out of `myShare`/`mySplitOf` would re-inflate exactly the figures that
|
||||||
|
* importing settled history exists to correct.
|
||||||
|
*
|
||||||
|
* `settled` marks obligations discharged OUTSIDE this app — imported
|
||||||
|
* SplitMyExpenses history, whose repayments happened on a platform we no longer
|
||||||
|
* run and which therefore has no `split_payments` row here.
|
||||||
|
*
|
||||||
|
* A live obligation is NOT settled by flipping this. It is settled by recording
|
||||||
|
* the payment, and the balance nets to zero on its own. Doing both would
|
||||||
|
* subtract the settlement twice — the splits leave the sum AND the payment is
|
||||||
|
* deducted — driving the balance negative by the amount repaid. So there is
|
||||||
|
* deliberately no "mark settled" action anywhere: settling up is recording a
|
||||||
|
* payment, and this column is only ever written by the historical import.
|
||||||
|
*
|
||||||
|
* Assumes the `transaction_splits` alias is `ts`.
|
||||||
|
*/
|
||||||
|
export const ACTIVE_OBLIGATION = `ts.settled = false`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab a split belongs to: its transaction's trip, else the household.
|
||||||
|
*
|
||||||
|
* Membership already lives on `transaction_overrides.trip_id`, so this is a
|
||||||
|
* read of existing data rather than a new grouping key. Assumes an
|
||||||
|
* `transaction_overrides` alias `o` is joined (LEFT — a transaction with no
|
||||||
|
* override row has no trip, which is the common case and means household).
|
||||||
|
*/
|
||||||
|
export const SPLIT_SCOPE = `o.trip_id`;
|
||||||
|
|||||||
+71
-23
@@ -1,5 +1,5 @@
|
|||||||
import { queryRaw } from "./db";
|
import { queryRaw } from "./db";
|
||||||
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED } from "./analytics-sql";
|
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED, ACTIVE_OBLIGATION, STATEMENTS_JOIN } from "./analytics-sql";
|
||||||
|
|
||||||
export interface RoutePointRow {
|
export interface RoutePointRow {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -448,6 +448,13 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
|||||||
// Payments settle the total relationship between two people, not a specific tag.
|
// Payments settle the total relationship between two people, not a specific tag.
|
||||||
// Only subtract payments when viewing the unfiltered total; with a tag filter
|
// Only subtract payments when viewing the unfiltered total; with a tag filter
|
||||||
// active, show the raw split amount for that tag context only.
|
// active, show the raw split amount for that tag context only.
|
||||||
|
//
|
||||||
|
// That asymmetry is a symptom, not a design: a tag is a view and has no
|
||||||
|
// payments, so a tag-filtered balance had nothing honest to subtract. A trip
|
||||||
|
// does have payments (`split_payments.trip_id`, migration 0022), which is why
|
||||||
|
// the per-trip figure in getTripAnalytics can be netted and this one cannot.
|
||||||
|
// The fix for the tag case is to stop showing a balance there, not to invent
|
||||||
|
// one — see docs/shared-expenses-design.md.
|
||||||
const paymentsJoin = tagIds?.length ? "" : `
|
const paymentsJoin = tagIds?.length ? "" : `
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -484,6 +491,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
|||||||
LEFT JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
|
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
|
||||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||||
|
AND ${ACTIVE_OBLIGATION}
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
@@ -498,6 +506,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
|||||||
LEFT JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
|
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
|
||||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||||
|
AND ${ACTIVE_OBLIGATION}
|
||||||
${tagFilter}
|
${tagFilter}
|
||||||
) splits ON splits.pid = p.id
|
) splits ON splits.pid = p.id
|
||||||
${paymentsJoin}
|
${paymentsJoin}
|
||||||
@@ -825,7 +834,14 @@ export interface TripAnalytics {
|
|||||||
daily_spend: { date: string; amount: number }[];
|
daily_spend: { date: string; amount: number }[];
|
||||||
top_merchants: { merchant: string; amount: number; count: number }[];
|
top_merchants: { merchant: string; amount: number; count: number }[];
|
||||||
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
|
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
|
||||||
participant_splits: { participant_id: number; name: string; owed: number }[];
|
participant_splits: {
|
||||||
|
participant_id: number;
|
||||||
|
name: string;
|
||||||
|
/** Their share of this trip, net of payments scoped to it. */
|
||||||
|
owed: number;
|
||||||
|
/** Splits counted at a non-AUD figure because no converted amount exists. */
|
||||||
|
unconverted_count: number;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTrips(ownerId: number): Promise<TripRow[]> {
|
export async function getTrips(ownerId: number): Promise<TripRow[]> {
|
||||||
@@ -929,27 +945,59 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
|
|||||||
ORDER BY 4 DESC
|
ORDER BY 4 DESC
|
||||||
`, [tripId]),
|
`, [tripId]),
|
||||||
|
|
||||||
// No settled/unsettled breakdown here. It was computed from
|
// Owed per participant for THIS trip, net of payments made against it.
|
||||||
// transaction_splits.settled, which only /api/splits/settle writes and
|
//
|
||||||
// nothing in the UI calls — so it is false on all 673 splits and every trip
|
// This was gross splits, with a comment explaining why it could not be
|
||||||
// reported 100% unsettled, including trips paid in full. A real per-trip
|
// anything better: `split_payments` carried no trip attribution, so a
|
||||||
// figure is not computable either: split_payments carries no trip
|
// payment could not be assigned to a trip and every trip reported 100%
|
||||||
// attribution, so a payment cannot be assigned to a trip. Settlement is a
|
// unsettled including trips paid in full. `split_payments.trip_id`
|
||||||
// property of the whole relationship until settlement contexts exist
|
// (migration 0022) closes that, so the figure is now real.
|
||||||
// (see docs/shared-expenses-design.md).
|
//
|
||||||
queryRaw<{ participant_id: number; name: string; owed: number }>(`
|
// Three exclusions, all load-bearing:
|
||||||
SELECT
|
// - ACTIVE_OBLIGATION drops settled splits, so a closed trip reads zero
|
||||||
p.id AS participant_id,
|
// rather than its original gross.
|
||||||
p.name,
|
// - EXCLUDE_RECONCILED_SOURCE drops the manual row a statement line has
|
||||||
SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount))::float AS owed
|
// superseded. The trip queries never applied it, so a reconciled trip
|
||||||
FROM transaction_overrides o
|
// expense was counted twice here.
|
||||||
JOIN transactions tx ON tx.id = o.transaction_id
|
// - AMOUNT_UNCONVERTED counts rows whose AUD value is unknown, the same
|
||||||
JOIN transaction_splits ts ON ts.transaction_id = tx.id
|
// way getParticipantBalances does. They are still summed (at their
|
||||||
JOIN participants p ON p.id = ts.participant_id
|
// native figure), so a non-zero count means this total is approximate
|
||||||
WHERE o.trip_id = $1
|
// and the UI has to say so. A trip is where foreign rows actually live,
|
||||||
AND tx.transaction_type IN ('debit','fee','interest')
|
// so netting a EUR figure against AUD ones silently is most likely to
|
||||||
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
|
// bite exactly here.
|
||||||
GROUP BY p.id
|
//
|
||||||
|
// `transactions` is aliased `t` so the shared fragments apply directly —
|
||||||
|
// they assume that alias, and hand-inlining a copy is what let the
|
||||||
|
// reconciled-row exclusion drift out of the analytics routes to begin with.
|
||||||
|
queryRaw<{ participant_id: number; name: string; owed: number; unconverted_count: number }>(`
|
||||||
|
WITH owed AS (
|
||||||
|
SELECT ts.participant_id AS pid,
|
||||||
|
SUM(ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount)) AS gross,
|
||||||
|
SUM(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted
|
||||||
|
FROM transaction_overrides o
|
||||||
|
JOIN transactions t ON t.id = o.transaction_id
|
||||||
|
${STATEMENTS_JOIN}
|
||||||
|
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
||||||
|
WHERE o.trip_id = $1
|
||||||
|
AND t.transaction_type IN ('debit','fee','interest')
|
||||||
|
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
|
||||||
|
AND ${ACTIVE_OBLIGATION}
|
||||||
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||||
|
GROUP BY ts.participant_id
|
||||||
|
),
|
||||||
|
paid AS (
|
||||||
|
SELECT sp.from_participant_id AS pid, SUM(sp.amount) AS amt
|
||||||
|
FROM split_payments sp
|
||||||
|
WHERE sp.trip_id = $1
|
||||||
|
GROUP BY sp.from_participant_id
|
||||||
|
)
|
||||||
|
SELECT p.id AS participant_id, p.name,
|
||||||
|
(COALESCE(owed.gross, 0) - COALESCE(paid.amt, 0))::float AS owed,
|
||||||
|
COALESCE(owed.unconverted, 0)::int AS unconverted_count
|
||||||
|
FROM participants p
|
||||||
|
LEFT JOIN owed ON owed.pid = p.id
|
||||||
|
LEFT JOIN paid ON paid.pid = p.id
|
||||||
|
WHERE owed.pid IS NOT NULL OR paid.pid IS NOT NULL
|
||||||
ORDER BY 3 DESC
|
ORDER BY 3 DESC
|
||||||
`, [tripId]),
|
`, [tripId]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user