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