From ab00f8c592198c40959504e4ecb6a059626021cd Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 26 Jul 2026 10:05:09 +1000 Subject: [PATCH] fix(analytics): my share is what is left over, not 100% The split-adjusted spend expression assumed a transaction with no split row for me was entirely mine. That is wrong when a transaction is allocated fully to someone else: I paid, they owe all of it, and there is no row for me to match. Both branches of the CASE missed and the ELSE charged me the full amount. 24 transactions were affected, all travel bookings between 2026-01-09 and 2026-06-26, overstating my spend by $8,579.07 across every analytics view. Adds myShare/mySplitOf to analytics-sql.ts, which fall back to 100 - (sum of everyone else's shares) instead of 100, and applies them to all five analytics routes. Centralised for the same reason as EXCLUDE_NON_SPEND: the expression was duplicated five times and had already drifted. --- src/app/api/analytics/fees/route.ts | 8 ++--- .../analytics/merchants/[merchant]/route.ts | 10 +++---- src/app/api/analytics/merchants/route.ts | 4 +-- src/app/api/analytics/monthly/route.ts | 9 ++---- src/app/api/analytics/subscriptions/route.ts | 8 ++--- src/lib/analytics-sql.ts | 29 +++++++++++++++++++ 6 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/app/api/analytics/fees/route.ts b/src/app/api/analytics/fees/route.ts index 0054c5c..57657ad 100644 --- a/src/app/api/analytics/fees/route.ts +++ b/src/app/api/analytics/fees/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; -import { OWNER_SCOPE, STATEMENTS_JOIN } from "@/lib/analytics-sql"; +import { OWNER_SCOPE, STATEMENTS_JOIN, mySplitOf } from "@/lib/analytics-sql"; export async function GET(req: NextRequest) { const user = await getCurrentUser(req); @@ -41,11 +41,7 @@ export async function GET(req: NextRequest) { t.description, t.merchant_name, t.transaction_type, - CASE - WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 - WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 - ELSE COALESCE(t.amount_aud, t.amount) - END::numeric(12,2) AS my_amount, + ${mySplitOf(`COALESCE(t.amount_aud, t.amount)`)}::numeric(12,2) AS my_amount, COALESCE(s.bank_name, 'Manual') AS bank_name FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id diff --git a/src/app/api/analytics/merchants/[merchant]/route.ts b/src/app/api/analytics/merchants/[merchant]/route.ts index 2361bd3..9abac5d 100644 --- a/src/app/api/analytics/merchants/[merchant]/route.ts +++ b/src/app/api/analytics/merchants/[merchant]/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; -import { OWNER_SCOPE, STATEMENTS_JOIN, EFFECTIVE_CATEGORY } from "@/lib/analytics-sql"; +import { OWNER_SCOPE, STATEMENTS_JOIN, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; + +const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`); export async function GET( req: NextRequest, @@ -32,10 +34,8 @@ export async function GET( t.amount, t.amount_aud, CASE - WHEN t.transaction_type IN ('refund', 'credit') THEN - -(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END) - ELSE - (CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END) + WHEN t.transaction_type IN ('refund', 'credit') THEN -${MY_AMOUNT} + ELSE ${MY_AMOUNT} END::numeric(10,2) as my_amount, t.transaction_type, ${EFFECTIVE_CATEGORY} as category, diff --git a/src/app/api/analytics/merchants/route.ts b/src/app/api/analytics/merchants/route.ts index 23cdc41..dffc6cf 100644 --- a/src/app/api/analytics/merchants/route.ts +++ b/src/app/api/analytics/merchants/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; -import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY } from "@/lib/analytics-sql"; +import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; // Split-adjusted amount helper (positive for spend, negative for refunds) -const MY_AMOUNT = `CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END`; +const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`); const SPEND_EXPR = ` CASE WHEN t.transaction_type IN ('refund', 'credit') THEN -(${MY_AMOUNT}) diff --git a/src/app/api/analytics/monthly/route.ts b/src/app/api/analytics/monthly/route.ts index 9cd052a..48a1f3b 100644 --- a/src/app/api/analytics/monthly/route.ts +++ b/src/app/api/analytics/monthly/route.ts @@ -8,6 +8,7 @@ import { EXCLUDE_NON_SPEND, SPEND_ROWS, SPEND_BASE, + mySplitOf, } from "@/lib/analytics-sql"; export async function GET(req: NextRequest) { @@ -34,13 +35,7 @@ export async function GET(req: NextRequest) { `SELECT TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month, ${EFFECTIVE_CATEGORY} as category, - SUM( - CASE - WHEN ts.share_percent IS NOT NULL THEN (${SPEND_BASE}) * ts.share_percent / 100 - WHEN o.my_share_percent IS NOT NULL THEN (${SPEND_BASE}) * o.my_share_percent / 100 - ELSE (${SPEND_BASE}) - END - )::numeric(12,2) as total_spent, + SUM(${mySplitOf(SPEND_BASE)})::numeric(12,2) as total_spent, COUNT(*)::int as transaction_count FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id diff --git a/src/app/api/analytics/subscriptions/route.ts b/src/app/api/analytics/subscriptions/route.ts index d5b0c20..335430f 100644 --- a/src/app/api/analytics/subscriptions/route.ts +++ b/src/app/api/analytics/subscriptions/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; -import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY } from "@/lib/analytics-sql"; +import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; export async function GET(req: NextRequest) { const user = await getCurrentUser(req); @@ -23,11 +23,7 @@ export async function GET(req: NextRequest) { COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) AS merchant, ${EFFECTIVE_CATEGORY} AS category, t.transaction_date, - CASE - WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 - WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 - ELSE COALESCE(t.amount_aud, t.amount) - END AS my_amount + ${mySplitOf(`COALESCE(t.amount_aud, t.amount)`)} AS my_amount FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 diff --git a/src/lib/analytics-sql.ts b/src/lib/analytics-sql.ts index 6c8e3cc..fbe86e3 100644 --- a/src/lib/analytics-sql.ts +++ b/src/lib/analytics-sql.ts @@ -42,6 +42,35 @@ export const SPEND_BASE = `CASE WHEN t.interest_amount IS NOT NULL THEN t.intere /** Effective category, honouring overrides. Never NULL. */ export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'other')`; +/** + * My percentage share of a transaction, 0-100. + * + * Resolution order: + * 1. An explicit `transaction_splits` row for me. + * 2. The `my_share_percent` override. + * 3. Whatever is left after everyone else's shares. + * + * Step 3 is the one that matters. Assuming 100% when no split row exists for me + * is wrong whenever a transaction is allocated entirely to someone else — I paid, + * they owe all of it, and there is no row for me to find. Those rows would + * otherwise land in my spend at full value. + * + * Requires `transaction_splits ts` joined on `ts.participant_id = ` + * and `transaction_overrides o` joined on the transaction. + */ +export const myShare = (participant = "$1") => `COALESCE( + ts.share_percent, + o.my_share_percent, + 100 - COALESCE(( + SELECT SUM(x.share_percent) FROM transaction_splits x + WHERE x.transaction_id = t.id AND x.participant_id <> ${participant} + ), 0) + )`; + +/** `base` scaled to my share. Use for every per-user spend total. */ +export const mySplitOf = (base: string, participant = "$1") => + `((${base}) * ${myShare(participant)} / 100)`; + /** * Predicate excluding money-movement categories. * The COALESCE matters: a bare `category NOT IN (...)` evaluates to NULL for