fix(analytics): my share is what is left over, not 100%
ci / lint-test (push) Successful in 41s

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.
This commit is contained in:
2026-07-26 10:05:09 +10:00
parent 02495e4173
commit ab00f8c592
6 changed files with 42 additions and 26 deletions
+2 -6
View File
@@ -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
@@ -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,
+2 -2
View File
@@ -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})
+2 -7
View File
@@ -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
+2 -6
View File
@@ -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