fix(analytics): make the displayed numbers mean what they say
ci / lint-test (push) Failing after 44s

Six metric-integrity defects from the UI/IA review, plus two found while
verifying the review's own claims against the code.

The reconciled-row exclusion existed only in queries.ts. Every analytics
route counted the superseded manual rows as spend — 48 rows, $4,474.79 of
double count, invisible precisely because the transaction list looked
right. It is now one fragment both sides import.

The spend-pace chart computed its own totals in the browser: gross
amounts, debits only, no personal share, no refunds, fees, interest or
itemised loan repayments. On live data it ended July at $4,747.31 under a
headline reading $3,597.10 — and its own baseline line was drawn from the
split-adjusted monthly totals, so the two series in one chart disagreed
with each other. Both now come from /api/analytics/daily, built from the
same fragments as the headline.

Fees aggregated every statement ever imported with no date filter, under
a heading with no period, so a lifetime figure read as a current one and
grew forever. Now bounded, labelled, and selectable.

Comparisons no longer measure a month in progress against complete ones:
the in-progress month is out of every baseline, and a selected current
month is compared through the same day.

Two the review did not catch:

- Every analytics window was a day early. toISOString() on a
  local-midnight Date converts backwards through UTC. Surfaced only once
  fees started reporting the range it had used.
- /monthly rounded per category, /daily per category-day, so the pace
  chart ended a few cents off the headline above it.

Shared currency needed amending rather than applying. Reading s.currency
would have labelled every order row AUD, since an order receipt has no
statement and carries its own currency — the opposite convention from a
foreign charge on an AUD statement, where amount IS AUD. NATIVE_CURRENCY's
COALESCE order keeps the two apart. Balances also now count rows whose AUD
value is genuinely unknown instead of netting a foreign figure against AUD
ones. Latent today: no foreign transaction is currently split.

Tag-filtered balance cards no longer claim "owes you". With a filter on,
payments are deliberately not subtracted, so the figure is a split total
and settling against it would record a payment for a debt that never was.

Split-coverage warnings deliberately omitted (user decision).
This commit is contained in:
2026-07-27 17:10:27 +10:00
parent 5ee5ee24cf
commit a4ab543a6c
14 changed files with 643 additions and 90 deletions
+55 -1
View File
@@ -1,7 +1,61 @@
# UI and information architecture review # UI and information architecture review
**Date:** 2026-07-26 **Date:** 2026-07-26
**Status:** Review and redesign proposal — nothing implemented **Status:** Priority 0 implemented 2026-07-27 (see below). Priorities 14 remain
proposals.
## Implementation status — Priority 0 (2026-07-27)
All six Priority 0 items landed, with three amendments found while verifying the
proposals against the code:
1. **Reconciled source rows** — the exclusion was missing from *all five*
analytics routes, not only `/monthly`. It is now one fragment
(`EXCLUDE_RECONCILED_SOURCE`) that `queries.ts` also imports, so the two
halves cannot drift apart again. Real effect: 48 rows, **$4,474.79** of
double-counted spend removed from every category total, mover, Pareto and
merchant ranking.
2. **Spend pace** — now served by `/api/analytics/daily`, built from the same
fragments as the headline. Measured on live data, the old client-side series
ended July at **$4,747.31** against a headline of **$3,597.10** — a 32%
overstatement of the number directly above it.
3. **Fees and interest** — bounded by an explicit period (default 12 months,
`months=0` for all time), with the range shown and selectable. The unbounded
figure was overstating the last 12 months by roughly **$2,700 of fees**.
4. **Split-coverage warning***deliberately not implemented* (user decision,
2026-07-27).
5. **Shared foreign currency** — amended. The obvious fix, reading `s.currency`,
would have mislabelled every order row as AUD, because an order receipt has
no statement and carries its own currency. Sourcing is now
`NATIVE_CURRENCY = COALESCE(s.currency, t.foreign_currency_code, 'AUD')`,
whose COALESCE order keeps two opposite denomination conventions apart. Note
this change is **latent on today's data**: no foreign transaction is
currently split, so nothing on Shared looks different yet.
6. **Partial-month comparisons** — the hero average, the top movers and the pace
baseline now exclude the in-progress month, and compare through the same day
of the month when the selected month is the current one.
Also fixed while in here, both found by checking rather than by proposal:
- **Every analytics window was a day early.** `toISOString()` on a local-midnight
`Date` converts backwards through UTC in any timezone east of Greenwich. Now
`toDateStr()`. This was pre-existing in `/monthly` and `/merchants`.
- **Rounding grain.** `/monthly` rounded per category and `/daily` per
category-day, so the pace chart ended the month a few cents off its own
headline. Both now carry 4dp and round once, at display.
Guarded by `src/__tests__/integration/analytics-sql.test.ts`.
The doc's characterisation of `REGULAR_CATEGORIES` (Insights section) is also
slightly off: the set has 13 members including rent, utilities, insurance and
subscriptions, not the 8 listed. The case for replacing it stands — a flat
binary cannot express obligation — but that is the reason, not arbitrary
membership. Note too that the proposed Fixed/Essential/Lifestyle model needs a
commitment dimension that does not exist yet: `fees` cannot be split into
avoidable versus known-annual, `subscriptions` cannot be split into contractual
versus cancellable, and the contracted loan repayment is not in the spend stream
at all (`SPEND_BASE` keeps only the interest portion). That is a data-model
change, not an Insights rework.
## Executive summary ## Executive summary
@@ -0,0 +1,126 @@
import { describe, it, expect } from "vitest";
import { queryRaw, queryRow } from "../../lib/db";
import {
EXCLUDE_RECONCILED_SOURCE,
NATIVE_CURRENCY,
AMOUNT_UNCONVERTED,
} from "../../lib/analytics-sql";
/**
* These fragments are the ones that drifted.
*
* The reconciled-row exclusion lived only in `queries.ts` for months while every
* analytics route counted the superseded manual rows as spend — 48 rows, $4,474
* of double count, invisible because the transaction list (which did exclude
* them) looked right. The currency expression has the same shape of risk: it is
* read by two call sites with two different denomination conventions.
*
* Assertions are per-row against known fixtures rather than against aggregate
* totals, so a change in unrelated data cannot mask a regression.
*/
async function scratchTxn(cols: string, vals: string, params: unknown[] = []) {
const row = await queryRow<{ id: number }>(
`INSERT INTO transactions (${cols}) VALUES (${vals}) RETURNING id`,
params
);
return row!.id;
}
/** Does this row survive the predicate? */
async function passes(predicate: string, id: number): Promise<boolean> {
const rows = await queryRaw(
`SELECT t.id FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = $1 AND (${predicate})`,
[id]
);
return rows.length === 1;
}
describe("EXCLUDE_RECONCILED_SOURCE", () => {
it("drops a manual row that a statement line has superseded", async () => {
const survivor = await scratchTxn(
"transaction_date, description, amount, transaction_type",
"'2026-03-01','Analytics fixture — survivor', 10.00, 'debit'"
);
const superseded = await scratchTxn(
"transaction_date, description, amount, transaction_type, reconciled_with_id",
"'2026-03-01','Analytics fixture — superseded', 10.00, 'debit', $1",
[survivor]
);
expect(await passes(EXCLUDE_RECONCILED_SOURCE, superseded)).toBe(false);
});
it("keeps an ordinary manual row that was never reconciled", async () => {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type",
"'2026-03-01','Analytics fixture — unreconciled', 10.00, 'debit'"
);
expect(await passes(EXCLUDE_RECONCILED_SOURCE, id)).toBe(true);
});
it("keeps a credits order row — nothing ever sets reconciled_with_id on one", async () => {
// The order slice records the card match in expense_metadata, not on the
// transaction, and needsCardMatch() holds these out of the reconcile queue.
// If that ever changes, this exclusion would start eating real spend.
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, payment_method",
"'2026-03-01','Order - Analytics fixture', 25.00, 'debit', 'credits'"
);
expect(await passes(EXCLUDE_RECONCILED_SOURCE, id)).toBe(true);
});
});
describe("NATIVE_CURRENCY", () => {
async function currencyOf(id: number) {
const row = await queryRow<{ ccy: string; unconverted: boolean }>(
`SELECT ${NATIVE_CURRENCY} AS ccy, ${AMOUNT_UNCONVERTED} AS unconverted
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = $1`,
[id]
);
return row!;
}
it("reads a statement-less order row's own currency, not 'AUD'", async () => {
// The bug this guards: sourcing currency from s.currency alone labelled
// every foreign order row AUD, because an order has no statement.
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, payment_method, foreign_currency_amount, foreign_currency_code",
"'2026-03-01','Order - Foreign fixture', 3500.00, 'debit', 'credits', 3500.00, 'LKR'"
);
const { ccy, unconverted } = await currencyOf(id);
expect(ccy).toBe("LKR");
// No amount_aud: the ingest path refuses to assert an FX rate it lacks, so
// this row's AUD value is genuinely unknown and must be reported as such.
expect(unconverted).toBe(true);
});
it("prefers the statement's currency over the foreign-charge record", async () => {
// Opposite convention: on an AUD statement, `amount` is AUD and
// foreign_currency_code merely notes what was originally charged. Reading
// the foreign code here would mislabel an AUD row as USD.
const stmt = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename)
VALUES ('Analytics Fixture Bank','0000','2026-03-31','AUD','analytics-fixture.pdf') RETURNING id`
);
const id = await scratchTxn(
"transaction_date, description, amount, amount_aud, transaction_type, statement_id, foreign_currency_amount, foreign_currency_code",
"'2026-03-01','Overseas purchase fixture', 45.00, 45.00, 'debit', $1, 30.00, 'USD'",
[stmt!.id]
);
const { ccy, unconverted } = await currencyOf(id);
expect(ccy).toBe("AUD");
expect(unconverted).toBe(false);
});
it("defaults a plain manual row to AUD", async () => {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type",
"'2026-03-01','Plain manual fixture', 12.00, 'debit'"
);
expect((await currencyOf(id)).ccy).toBe("AUD");
});
});
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import {
OWNER_SCOPE,
STATEMENTS_JOIN,
EXCLUDE_NON_SPEND,
EXCLUDE_RECONCILED_SOURCE,
EFFECTIVE_CATEGORY,
NET_SPEND_ROWS,
SPEND_SIGNED,
mySplitOf,
toDateStr,
} from "@/lib/analytics-sql";
/**
* Daily net spend, by month, day-of-month and category.
*
* This exists so the spend-pace chart stops computing its own totals. It used to
* sum gross `amount_aud ?? amount` over `transaction_type = 'debit'` in the
* browser, which meant it ignored personal share, refunds, fees, interest, and
* itemised loan repayments — every rule the headline applies. The two numbers
* could disagree while both were labelled "spend", and the chart's own baseline
* line was drawn from the split-adjusted monthly totals, so the two series in
* one chart were on different bases.
*
* Day-of-month granularity is also what lets the page compare a partial current
* month against prior months *through the same day*, instead of against their
* full-month totals — which always made a month in progress look thrifty.
*
* Same fragments as /api/analytics/monthly. If that route's semantics change,
* this one changes with it.
*/
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { searchParams } = new URL(req.url);
const monthCount = Math.min(Math.max(Number(searchParams.get("months") || "12"), 1), 24);
const now = new Date();
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
const rows = await queryRaw<{ month: string; day: number; category: string; spent: string }>(
`SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
EXTRACT(DAY FROM t.transaction_date::date)::int as day,
${EFFECTIVE_CATEGORY} as category,
-- 4dp, not 2. This is grouped finer than /monthly (by day as well as
-- category), so rounding each bucket to cents and summing accumulates a
-- different error than rounding per category does — the pace chart ended
-- the month a few cents off the headline it sits under. Round once, at
-- display time.
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) as spent
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
${STATEMENTS_JOIN}
WHERE ${OWNER_SCOPE} = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2
AND t.transaction_date < $3
GROUP BY 1, 2, 3
ORDER BY 1, 2`,
[user.id, toDateStr(startDate), toDateStr(endDate)]
);
// Sparse by design — a day with no spend has no entry, and the client treats
// a missing day as zero. Emitting 31 zeroes per month per category would
// dominate the payload.
const daily: Record<string, Record<number, number>> = {};
const byCategory: Record<string, Record<string, Record<number, number>>> = {};
for (const r of rows) {
const spent = Number(r.spent);
const m = (daily[r.month] ??= {});
m[r.day] = (m[r.day] ?? 0) + spent;
const c = ((byCategory[r.month] ??= {})[r.category] ??= {});
c[r.day] = (c[r.day] ?? 0) + spent;
}
return NextResponse.json({ daily, byCategory });
}
+38 -4
View File
@@ -1,12 +1,35 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, mySplitOf } from "@/lib/analytics-sql"; import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, mySplitOf, toDateStr } from "@/lib/analytics-sql";
/**
* Fees and interest over an explicit window.
*
* This used to aggregate every statement ever imported with no date filter, and
* the UI printed the result with no period label — so a lifetime-to-date total
* read as a current-period one, and grew forever. `months=0` asks for all time
* deliberately, which is a different claim from asking for it by accident.
*/
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getCurrentUser(req); const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { searchParams } = new URL(req.url);
const monthsParam = Number(searchParams.get("months") ?? "12");
const months = Number.isFinite(monthsParam) ? Math.min(Math.max(monthsParam, 0), 120) : 12;
const allTime = months === 0;
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth() - months + 1, 1);
const fromStr = toDateStr(from);
const toStr = toDateStr(new Date(now.getFullYear(), now.getMonth() + 1, 1));
// A statement is dated by the period it covers, not by when it was imported.
const stmtWindow = allTime ? "" : `AND billing_end_date >= $2 AND billing_end_date < $3`;
const txnWindow = allTime ? "" : `AND t.transaction_date >= $2 AND t.transaction_date < $3`;
const windowParams = allTime ? [] : [fromStr, toStr];
// Statement-level fees and interest (aggregated by Gemini from the PDF) // Statement-level fees and interest (aggregated by Gemini from the PDF)
const stmtRows = await queryRaw<{ const stmtRows = await queryRaw<{
bank_name: string; bank_name: string;
@@ -19,10 +42,11 @@ export async function GET(req: NextRequest) {
SUM(COALESCE(interest_charged, 0))::numeric(12,2) AS interest SUM(COALESCE(interest_charged, 0))::numeric(12,2) AS interest
FROM statements FROM statements
WHERE owner_id = $1 WHERE owner_id = $1
${stmtWindow}
GROUP BY bank_name GROUP BY bank_name
HAVING SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0)) > 0 HAVING SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0)) > 0
ORDER BY (SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0))) DESC`, ORDER BY (SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0))) DESC`,
[user.id] [user.id, ...windowParams]
); );
// Transaction-level fee and interest line items (split-adjusted) // Transaction-level fee and interest line items (split-adjusted)
@@ -49,8 +73,10 @@ export async function GET(req: NextRequest) {
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
WHERE ${OWNER_SCOPE} = $1 WHERE ${OWNER_SCOPE} = $1
AND t.transaction_type IN ('fee', 'interest') AND t.transaction_type IN ('fee', 'interest')
AND ${EXCLUDE_RECONCILED_SOURCE}
${txnWindow}
ORDER BY t.transaction_date DESC`, ORDER BY t.transaction_date DESC`,
[user.id] [user.id, ...windowParams]
); );
const by_bank = stmtRows.map((r) => ({ const by_bank = stmtRows.map((r) => ({
@@ -69,5 +95,13 @@ export async function GET(req: NextRequest) {
const total_fees = by_bank.reduce((s, r) => s + r.fees, 0); const total_fees = by_bank.reduce((s, r) => s + r.fees, 0);
const total_interest = by_bank.reduce((s, r) => s + r.interest, 0); const total_interest = by_bank.reduce((s, r) => s + r.interest, 0);
return NextResponse.json({ by_bank, transactions, total_fees, total_interest }); return NextResponse.json({
by_bank,
transactions,
total_fees,
total_interest,
// The period is part of the answer — the client must be able to say what
// window these totals cover rather than implying "now".
period: { months, from: allTime ? null : fromStr, to: allTime ? null : toStr, all_time: allTime },
});
} }
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
import { bankLabel } from "@/lib/queries";
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`); const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
@@ -39,7 +40,7 @@ export async function GET(
END::numeric(10,2) as my_amount, END::numeric(10,2) as my_amount,
t.transaction_type, t.transaction_type,
${EFFECTIVE_CATEGORY} as category, ${EFFECTIVE_CATEGORY} as category,
COALESCE(s.bank_name, 'Manual') as bank_name, ${bankLabel()} as bank_name,
t.statement_id t.statement_id
FROM transactions t FROM transactions t
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
@@ -48,6 +49,7 @@ export async function GET(
WHERE ${OWNER_SCOPE} = $1 WHERE ${OWNER_SCOPE} = $1
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit') AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2 AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
AND ${EXCLUDE_RECONCILED_SOURCE}
ORDER BY t.transaction_date DESC ORDER BY t.transaction_date DESC
LIMIT 500 LIMIT 500
`, [user.id, decoded]); `, [user.id, decoded]);
+4 -2
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf, toDateStr } from "@/lib/analytics-sql";
// Split-adjusted amount helper (positive for spend, negative for refunds) // Split-adjusted amount helper (positive for spend, negative for refunds)
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`); const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
@@ -21,7 +21,7 @@ export async function GET(req: NextRequest) {
const cutoff = new Date(); const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - months); cutoff.setMonth(cutoff.getMonth() - months);
const fromDate = cutoff.toISOString().slice(0, 10); const fromDate = toDateStr(cutoff);
// Merchant aggregates — net spend (debits + fees - refunds/credits) // Merchant aggregates — net spend (debits + fees - refunds/credits)
const rows = await queryRaw<{ const rows = await queryRaw<{
@@ -69,6 +69,7 @@ export async function GET(req: NextRequest) {
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit') AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY 1 GROUP BY 1
HAVING SUM(${SPEND_EXPR}) > 0 HAVING SUM(${SPEND_EXPR}) > 0
ORDER BY net_spend DESC ORDER BY net_spend DESC
@@ -95,6 +96,7 @@ export async function GET(req: NextRequest) {
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3) AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY 1, 2 GROUP BY 1, 2
ORDER BY 1, 2 ORDER BY 1, 2
`, [user.id, fromDate, topMerchants]); `, [user.id, fromDate, topMerchants]);
+10 -3
View File
@@ -6,9 +6,11 @@ import {
STATEMENTS_JOIN, STATEMENTS_JOIN,
EFFECTIVE_CATEGORY, EFFECTIVE_CATEGORY,
EXCLUDE_NON_SPEND, EXCLUDE_NON_SPEND,
EXCLUDE_RECONCILED_SOURCE,
NET_SPEND_ROWS, NET_SPEND_ROWS,
SPEND_SIGNED, SPEND_SIGNED,
mySplitOf, mySplitOf,
toDateStr,
} from "@/lib/analytics-sql"; } from "@/lib/analytics-sql";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
@@ -22,8 +24,8 @@ export async function GET(req: NextRequest) {
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1); const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1); const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
const startStr = startDate.toISOString().slice(0, 10); const startStr = toDateStr(startDate);
const endStr = endDate.toISOString().slice(0, 10); const endStr = toDateStr(endDate);
// Expenses: debits excluding transfers and investments, split-adjusted // Expenses: debits excluding transfers and investments, split-adjusted
const spendRows = await queryRaw<{ const spendRows = await queryRaw<{
@@ -35,7 +37,9 @@ export async function GET(req: NextRequest) {
`SELECT `SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month, TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
${EFFECTIVE_CATEGORY} as category, ${EFFECTIVE_CATEGORY} as category,
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(12,2) as total_spent, -- 4dp so the month total is summed from unrounded parts; every consumer
-- rounds for display. See the note in /api/analytics/daily.
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) as total_spent,
COUNT(*)::int as transaction_count COUNT(*)::int as transaction_count
FROM transactions t FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
@@ -44,6 +48,7 @@ export async function GET(req: NextRequest) {
WHERE ${OWNER_SCOPE} = $1 WHERE ${OWNER_SCOPE} = $1
AND ${NET_SPEND_ROWS} AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND t.transaction_date < $3 AND t.transaction_date < $3
GROUP BY 1, 2 GROUP BY 1, 2
@@ -67,6 +72,7 @@ export async function GET(req: NextRequest) {
WHERE ${OWNER_SCOPE} = $1 WHERE ${OWNER_SCOPE} = $1
AND t.transaction_type IN ('credit', 'payment') AND t.transaction_type IN ('credit', 'payment')
AND ${EFFECTIVE_CATEGORY} = 'income' AND ${EFFECTIVE_CATEGORY} = 'income'
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND t.transaction_date < $3 AND t.transaction_date < $3
GROUP BY 1 GROUP BY 1
@@ -89,6 +95,7 @@ export async function GET(req: NextRequest) {
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
WHERE ${OWNER_SCOPE} = $1 WHERE ${OWNER_SCOPE} = $1
AND ${EFFECTIVE_CATEGORY} = 'investment' AND ${EFFECTIVE_CATEGORY} = 'investment'
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND t.transaction_date < $3 AND t.transaction_date < $3
GROUP BY 1 GROUP BY 1
+2 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getCurrentUser(req); const user = await getCurrentUser(req);
@@ -31,6 +31,7 @@ export async function GET(req: NextRequest) {
WHERE ${OWNER_SCOPE} = $1 WHERE ${OWNER_SCOPE} = $1
AND t.transaction_type IN ('debit', 'fee') AND t.transaction_type IN ('debit', 'fee')
AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) IS NOT NULL AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) IS NOT NULL
), ),
merchant_with_lag AS ( merchant_with_lag AS (
+89 -44
View File
@@ -16,7 +16,7 @@ import {
ReferenceLine, ReferenceLine,
} from "recharts"; } from "recharts";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks"; import { useMonthlyAnalytics, useDailySpend, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { formatCategory, CATEGORIES } from "@/lib/categories"; import { formatCategory, CATEGORIES } from "@/lib/categories";
import { CATEGORY_COLORS, CHART, TOOLTIP_STYLE } from "@/lib/category-colors"; import { CATEGORY_COLORS, CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
@@ -37,6 +37,26 @@ function formatShortMonth(m: string): string {
const [year, month] = m.split("-"); const [year, month] = m.split("-");
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" }); return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" });
} }
function daysInMonthOf(m: string): number {
const [year, month] = m.split("-").map(Number);
return new Date(year, month, 0).getDate();
}
/**
* How much of `month` has actually happened. A month in progress is only
* complete up to today; every earlier month is complete.
*/
function elapsedDays(m: string): number {
return m === currentMonthStr() ? new Date().getDate() : daysInMonthOf(m);
}
/** Spend in `month` from day 1 through `throughDay` inclusive. */
function spendThrough(days: Record<number, number> | undefined, throughDay: number): number {
if (!days) return 0;
let sum = 0;
for (const [d, v] of Object.entries(days)) {
if (Number(d) <= throughDay) sum += v;
}
return sum;
}
function fmt(n: number): string { return `$${Math.round(n).toLocaleString()}`; } function fmt(n: number): string { return `$${Math.round(n).toLocaleString()}`; }
function fmtExact(n: number): string { return `$${n.toFixed(2)}`; } function fmtExact(n: number): string { return `$${n.toFixed(2)}`; }
function fmtSigned(n: number): string { return `${n >= 0 ? "+" : ""}$${Math.abs(n) >= 100 ? Math.round(Math.abs(n)).toLocaleString() : Math.abs(n).toFixed(0)}`; } function fmtSigned(n: number): string { return `${n >= 0 ? "+" : ""}$${Math.abs(n) >= 100 ? Math.round(Math.abs(n)).toLocaleString() : Math.abs(n).toFixed(0)}`; }
@@ -195,12 +215,14 @@ export default function AnalyticsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [months]); }, [months]);
// Cumulative chart: fetch this month's transactions // Day-of-month spend, split-adjusted server-side by the same rules as the
const smFrom = `${selectedMonth}-01`; // headline. Also what makes the comparisons below like-for-like.
const [smYear, smMonth] = selectedMonth.split("-").map(Number); const { data: dailyData } = useDailySpend(12);
const smNextDate = new Date(smYear, smMonth, 1);
const smTo = `${smNextDate.getFullYear()}-${String(smNextDate.getMonth() + 1).padStart(2, "0")}-01`; // A month in progress is only comparable to prior months through the same day.
const { data: monthTxData } = useTransactions({ from: smFrom, to: smTo, limit: 1000 }); // Comparing 27 days of July against a full June always flattered July.
const compareDay = elapsedDays(selectedMonth);
const selectedIsPartial = selectedMonth === currentMonthStr();
// Category rows for selected month // Category rows for selected month
const categoryRows = useMemo(() => { const categoryRows = useMemo(() => {
@@ -226,22 +248,27 @@ export default function AnalyticsPage() {
.slice(0, 8); .slice(0, 8);
}, [analytics, months, selectedMonth]); }, [analytics, months, selectedMonth]);
// Top movers vs previous month // Top movers vs previous month, compared through the same day of the month so
// a month in progress is not measured against a complete one.
const movers = useMemo(() => { const movers = useMemo(() => {
if (!analytics) return []; if (!analytics) return [];
const pm = prevMonth(selectedMonth); const pm = prevMonth(selectedMonth);
if (!months.includes(pm)) return []; if (!months.includes(pm)) return [];
return analytics.rows
.map((r) => ({ const catsNow = dailyData?.byCategory?.[selectedMonth] ?? {};
category: r.category, const catsBefore = dailyData?.byCategory?.[pm] ?? {};
delta: (r.spent[selectedMonth] || 0) - (r.spent[pm] || 0), const categories = new Set([...Object.keys(catsNow), ...Object.keys(catsBefore)]);
now: r.spent[selectedMonth] || 0,
before: r.spent[pm] || 0, return Array.from(categories)
})) .map((category) => {
const now = spendThrough(catsNow[category], compareDay);
const before = spendThrough(catsBefore[category], compareDay);
return { category, delta: now - before, now, before };
})
.filter((r) => Math.abs(r.delta) >= 1) .filter((r) => Math.abs(r.delta) >= 1)
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
.slice(0, 6); .slice(0, 6);
}, [analytics, months, selectedMonth]); }, [analytics, dailyData, months, selectedMonth, compareDay]);
// Pareto chart data // Pareto chart data
const paretoData = useMemo(() => { const paretoData = useMemo(() => {
@@ -258,39 +285,40 @@ export default function AnalyticsPage() {
}); });
}, [categoryRows]); }, [categoryRows]);
// Cumulative spend chart data // Cumulative spend chart data.
//
// Both series now come from the same server-side spend definition as the
// headline. The typical line is also a real averaged curve rather than the
// month total spread evenly — spending is lumpy (rent on the 1st, a shop on
// the weekend), so a straight line made ordinary months look erratic.
const cumulativeData = useMemo(() => { const cumulativeData = useMemo(() => {
const daysInMonth = new Date(smYear, smMonth, 0).getDate(); const daysInMonth = daysInMonthOf(selectedMonth);
const isCurrentMonth = selectedMonth === currentMonthStr(); const lastDay = elapsedDays(selectedMonth);
const today = new Date();
const lastDay = isCurrentMonth ? today.getDate() : daysInMonth;
const daily: Record<number, number> = {}; const daily = dailyData?.daily?.[selectedMonth] ?? {};
(monthTxData?.data ?? []) // Only complete months with data form the baseline. A month still in
.filter((tx) => tx.transaction_type === "debit" && // progress has no spend recorded past today, so including it would pull the
!["transfers", "investment"].includes(tx.effective_category) && // typical curve down by however much of it has not happened yet.
!tx.tags?.some((t: any) => (typeof t === "string" ? t : t.name) === "family")) const priorMonths = (analytics?.months ?? []).filter(
.forEach((tx) => { (m) => m !== selectedMonth && m !== currentMonthStr() && (analytics?.totals[m]?.spent || 0) > 0
const day = new Date(tx.transaction_date).getDate(); );
daily[day] = (daily[day] || 0) + Number(tx.amount_aud ?? tx.amount);
});
const priorMonths = analytics?.months.filter((m) => m !== selectedMonth) ?? [];
const priorAvg = priorMonths.length > 0
? priorMonths.reduce((s, m) => s + (analytics?.totals[m]?.spent || 0), 0) / priorMonths.length
: 0;
let cum = 0; let cum = 0;
return Array.from({ length: daysInMonth }, (_, i) => { return Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1; const day = i + 1;
if (day <= lastDay) cum += daily[day] || 0; if (day <= lastDay) cum += daily[day] || 0;
const typical = priorMonths.length
? priorMonths.reduce((s, m) => s + spendThrough(dailyData?.daily?.[m], day), 0) / priorMonths.length
: 0;
return { return {
day, day,
actual: day <= lastDay ? Math.round(cum * 100) / 100 : null, actual: day <= lastDay ? Math.round(cum * 100) / 100 : null,
typical: Math.round((priorAvg * day / daysInMonth) * 100) / 100, typical: Math.round(typical * 100) / 100,
}; };
}); });
}, [monthTxData, analytics, selectedMonth, smYear, smMonth]); }, [dailyData, analytics, selectedMonth]);
if (isLoading || !analytics) { if (isLoading || !analytics) {
return ( return (
@@ -305,16 +333,30 @@ export default function AnalyticsPage() {
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0); const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0); const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
// Hero delta vs the average of the other months that have data // Hero delta vs the average of the other *complete* months that have data.
const otherMonths = months.filter((m) => m !== selectedMonth && (analytics.totals[m]?.spent || 0) > 0); //
// Two partial-month traps here. The month in progress never belongs in the
// baseline, because most of it has not happened. And when the month in
// progress is the one selected, its running total has to be measured against
// the same slice of each prior month, not against their full totals.
const otherMonths = months.filter(
(m) => m !== selectedMonth && m !== currentMonthStr() && (analytics.totals[m]?.spent || 0) > 0
);
const comparableSpend = selectedIsPartial
? spendThrough(dailyData?.daily?.[selectedMonth], compareDay)
: totals.spent;
const avgSpend = otherMonths.length const avgSpend = otherMonths.length
? otherMonths.reduce((s, m) => s + (analytics.totals[m]?.spent || 0), 0) / otherMonths.length ? otherMonths.reduce(
(s, m) => s + (selectedIsPartial ? spendThrough(dailyData?.daily?.[m], compareDay) : analytics.totals[m]?.spent || 0),
0
) / otherMonths.length
: 0; : 0;
const avgDeltaPct = avgSpend > 0 ? Math.round(((totals.spent - avgSpend) / avgSpend) * 100) : 0; const avgDeltaPct = avgSpend > 0 ? Math.round(((comparableSpend - avgSpend) / avgSpend) * 100) : 0;
const throughQualifier = selectedIsPartial ? ` through day ${compareDay}` : "";
const heroSentence = const heroSentence =
avgSpend === 0 ? "" : avgSpend === 0 ? "" :
Math.abs(avgDeltaPct) <= 3 ? `in line with your ${otherMonths.length}-month average` : Math.abs(avgDeltaPct) <= 3 ? `in line with your ${otherMonths.length}-month average${throughQualifier}` :
`${Math.abs(avgDeltaPct)}% ${avgDeltaPct > 0 ? "above" : "below"} your ${otherMonths.length}-month average of ${fmt(avgSpend)}`; `${Math.abs(avgDeltaPct)}% ${avgDeltaPct > 0 ? "above" : "below"} your ${otherMonths.length}-month average of ${fmt(avgSpend)}${throughQualifier}`;
const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80); const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80);
const tableMonths = analytics.months.slice(0, 6); // newest-first, last 6 const tableMonths = analytics.months.slice(0, 6); // newest-first, last 6
@@ -373,7 +415,10 @@ export default function AnalyticsPage() {
{movers.length > 0 && ( {movers.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4"> <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
<h3 className="text-sm font-medium mb-1">What changed</h3> <h3 className="text-sm font-medium mb-1">What changed</h3>
<p className="text-xs text-zinc-500 mb-4">Biggest category moves vs {formatShortMonth(prevMonth(selectedMonth))}</p> <p className="text-xs text-zinc-500 mb-4">
Biggest category moves vs {formatShortMonth(prevMonth(selectedMonth))}
{selectedIsPartial && `, both through day ${compareDay}`}
</p>
<div className="grid sm:grid-cols-2 gap-x-8 gap-y-2.5"> <div className="grid sm:grid-cols-2 gap-x-8 gap-y-2.5">
{movers.map((m) => ( {movers.map((m) => (
<div key={m.category} className="flex items-center gap-3"> <div key={m.category} className="flex items-center gap-3">
+48 -5
View File
@@ -10,6 +10,18 @@ import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
const SPEND_TYPES = new Set(["debit", "fee", "interest"]); const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
/**
* The API returns an exclusive upper bound (first day of the month after the
* window). Showing that date verbatim would claim a month the totals exclude,
* so `exclusive` steps back a day for display.
*/
function formatPeriodBound(iso: string | null, exclusive = false): string {
if (!iso) return "—";
const d = new Date(`${iso}T00:00:00`);
if (exclusive) d.setDate(d.getDate() - 1);
return d.toLocaleDateString("default", { month: "short", year: "numeric" });
}
function fmt(n: number) { function fmt(n: number) {
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n); return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n);
} }
@@ -41,10 +53,13 @@ const FREQ_LABEL: Record<string, string> = {
}; };
// ─── Section wrapper ──────────────────────────────────────────────── // ─── Section wrapper ────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, aside, children }: { title: string; aside?: React.ReactNode; children: React.ReactNode }) {
return ( return (
<div className="mb-8"> <div className="mb-8">
<h3 className="text-base font-semibold text-zinc-200 mb-3">{title}</h3> <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 mb-3">
<h3 className="text-base font-semibold text-zinc-200">{title}</h3>
{aside}
</div>
{children} {children}
</div> </div>
); );
@@ -298,7 +313,8 @@ export default function InsightsPage() {
const { data: analytics } = useMonthlyAnalytics(12); const { data: analytics } = useMonthlyAnalytics(12);
const { data: analytics6 } = useMonthlyAnalytics(6); const { data: analytics6 } = useMonthlyAnalytics(6);
const { data: subData } = useSubscriptions(); const { data: subData } = useSubscriptions();
const { data: feesData } = useFees(); const [feeMonths, setFeeMonths] = useState(12);
const { data: feesData } = useFees(feeMonths);
// Build regular/occasional chart data // Build regular/occasional chart data
const chartData = useMemo(() => { const chartData = useMemo(() => {
@@ -431,11 +447,38 @@ export default function InsightsPage() {
</Section> </Section>
{/* ── 4. Fees & Interest ── */} {/* ── 4. Fees & Interest ── */}
<Section title="Fees & interest"> <Section
title="Fees & interest"
aside={
<div className="flex items-center gap-3">
{feesData?.period && (
<span className="text-xs text-zinc-500 tabular-nums">
{feesData.period.all_time
? "All time"
: `${formatPeriodBound(feesData.period.from)} ${formatPeriodBound(feesData.period.to, true)}`}
</span>
)}
<select
value={feeMonths}
onChange={(e) => setFeeMonths(Number(e.target.value))}
className="bg-zinc-900 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
aria-label="Fees period"
>
<option value={3}>Last 3 months</option>
<option value={6}>Last 6 months</option>
<option value={12}>Last 12 months</option>
<option value={24}>Last 24 months</option>
<option value={0}>All time</option>
</select>
</div>
}
>
{!feesData ? ( {!feesData ? (
<p className="text-zinc-500 text-sm">Loading...</p> <p className="text-zinc-500 text-sm">Loading...</p>
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? ( ) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
<p className="text-zinc-500 text-sm">No fees or interest recorded across your statements.</p> <p className="text-zinc-500 text-sm">
No fees or interest recorded {feesData.period?.all_time ? "on any statement" : "in this period"}.
</p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{feesData.by_bank.length > 0 && ( {feesData.by_bank.length > 0 && (
+42 -5
View File
@@ -22,8 +22,12 @@ function formatDate(d: string) {
const SPEND_TYPES = new Set(["debit", "fee", "interest"]); const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(n: number, type?: string) { function formatAmount(n: number, type?: string, currency?: string) {
const formatted = `$${Number(n).toFixed(2)}`; // A bare "$" on a non-AUD row was the visible half of the problem: the row
// read as dollars while the participant balances converted to AUD, so the
// two disagreed on screen with nothing to explain why.
const value = Number(n).toFixed(2);
const formatted = !currency || currency === "AUD" ? `$${value}` : `${currency} ${value}`;
return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted; return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted;
} }
@@ -298,7 +302,17 @@ export default function SharedPage() {
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>; return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
} }
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds); const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
const { data: allTags = [] } = useTags();
const { data: me } = useCurrentUser(); const { data: me } = useCurrentUser();
// Names the tag scope when one is active. Non-empty means the cards below are
// split totals rather than payable balances.
const tagScopeLabel =
realTagIds.length === 0
? null
: realTagIds.length === 1
? (allTags.find((t) => String(t.id) === realTagIds[0])?.name ?? "this tag")
: `${realTagIds.length} tags`;
const [addingParticipant, setAddingParticipant] = useState(false); const [addingParticipant, setAddingParticipant] = useState(false);
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null); const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
const [showHistory, setShowHistory] = useState<number | null>(null); const [showHistory, setShowHistory] = useState<number | null>(null);
@@ -351,23 +365,37 @@ export default function SharedPage() {
<div> <div>
<p className="font-medium">{b.name}</p> <p className="font-medium">{b.name}</p>
<p className="text-xs text-zinc-500"> <p className="text-xs text-zinc-500">
{settled ? "all square" : theyOweMe ? `owes you` : "you owe"} {/* With a tag filter on, payments are deliberately not
subtracted — so this is a split total, not a payable
balance, and must not claim to be one. */}
{tagScopeLabel
? `split total in ${tagScopeLabel}`
: settled ? "all square" : theyOweMe ? "owes you" : "you owe"}
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className={`text-lg font-semibold ${settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}> <p className={`text-lg font-semibold ${tagScopeLabel ? "text-zinc-300" : settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
${net.toFixed(2)} ${net.toFixed(2)}
</p> </p>
{b.unconverted_count > 0 && (
<p className="text-[11px] text-amber-500/80 mt-0.5">
approx · {b.unconverted_count} unconverted
</p>
)}
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{/* Settling against a tag-scoped total would record a payment
for a figure that never was the debt. */}
{!tagScopeLabel && (
<button <button
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })} onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg" className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
> >
Record Payment Record Payment
</button> </button>
)}
<button <button
onClick={() => setShowHistory(showHistory === b.id ? null : b.id)} onClick={() => setShowHistory(showHistory === b.id ? null : b.id)}
className={`px-3 py-1.5 text-xs rounded-lg ${showHistory === b.id ? "bg-zinc-700 text-white" : "bg-zinc-800 text-zinc-500 hover:text-zinc-300"}`} className={`px-3 py-1.5 text-xs rounded-lg ${showHistory === b.id ? "bg-zinc-700 text-white" : "bg-zinc-800 text-zinc-500 hover:text-zinc-300"}`}
@@ -440,7 +468,16 @@ export default function SharedPage() {
)} )}
</td> </td>
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}> <td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
{formatAmount(tx.amount, tx.transaction_type)} {formatAmount(tx.amount, tx.transaction_type, tx.currency)}
{tx.currency !== "AUD" && (
// Splits settle on the AUD figure, so show it next to the
// native one rather than leaving the two to differ silently.
<span className="block text-xs font-normal text-zinc-500">
{tx.amount_unconverted
? "AUD value unknown"
: `${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`}
</span>
)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
+68 -1
View File
@@ -1,7 +1,7 @@
// Shared SQL fragments for analytics queries, so spend/income semantics stay // Shared SQL fragments for analytics queries, so spend/income semantics stay
// identical across routes. // identical across routes.
// //
// Two rules every analytics query must follow: // Three rules every analytics query must follow:
// //
// 1. Join `statements` with LEFT JOIN and scope on COALESCE(t.owner_id, s.owner_id). // 1. Join `statements` with LEFT JOIN and scope on COALESCE(t.owner_id, s.owner_id).
// An INNER JOIN silently drops every manual/CSV transaction (statement_id IS // An INNER JOIN silently drops every manual/CSV transaction (statement_id IS
@@ -11,6 +11,23 @@
// bank account and again as the underlying purchases on the card statement. // bank account and again as the underlying purchases on the card statement.
// Categorising the money movement as `transfers` and excluding it here is what // Categorising the money movement as `transfers` and excluding it here is what
// stops the double count. Investments are a balance-sheet move, not spend. // stops the double count. Investments are a balance-sheet move, not spend.
// 3. Apply EXCLUDE_RECONCILED_SOURCE to every row-level query. The transaction
// queries have always done this (`queries.ts`); analytics never did, which is
// the other half of the same double count.
/**
* `YYYY-MM-DD` for a Date, read in local time.
*
* `toISOString().slice(0, 10)` is the obvious thing and it is wrong here: these
* are calendar boundaries built with `new Date(y, m, 1)`, which is local
* midnight. In any timezone east of UTC that converts to the *previous* day, so
* every window silently started and ended a day early — visible once the fees
* endpoint began reporting the range it had used ("2026-04-30" for a window
* meant to open on 1 May).
*/
export function toDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** Owner scoping that works for both statement-linked and manual transactions. */ /** Owner scoping that works for both statement-linked and manual transactions. */
export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`; export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`;
@@ -18,6 +35,56 @@ export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`;
/** Join clause to pair with OWNER_SCOPE. */ /** Join clause to pair with OWNER_SCOPE. */
export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id`; export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id`;
/**
* Drops the manual/CSV row that a statement line has superseded.
*
* Reconciliation keeps both rows: the manual one the user entered and the
* statement line it turned out to be. Only the statement line should count, or
* the same purchase is spent twice. `queries.ts` has always applied this; the
* analytics routes did not, so every reconciled row was double-counted in the
* category totals, movers, Pareto, and merchant rankings.
*
* Scoped to `statement_id IS NULL` deliberately: the *source* row is the manual
* one. A statement line pointing at something else is the survivor, not the
* duplicate.
*
* Order-receipt rows (`payment_method = 'credits'`) are unaffected — they are
* inserted with `reconciled_with_id` NULL and are held out of the reconcile
* queue by `needsCardMatch()`, so nothing ever sets it. If one is reconciled by
* hand against a card line, this is what stops it double-counting.
*/
export const EXCLUDE_RECONCILED_SOURCE = `NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`;
/**
* The currency `t.amount` is actually denominated in.
*
* Two different conventions meet here and the COALESCE order is what keeps them
* apart:
*
* - A statement row is denominated in its statement's currency. If that row is
* an overseas purchase on an AUD statement, `amount` is still AUD and
* `foreign_currency_code` merely records what was originally charged — so
* `s.currency` must win.
* - An order-receipt row has no statement. There, `amount` IS the native
* figure and `foreign_currency_code` names it (order-ingestion.ts leaves
* `amount_aud` NULL rather than asserting an FX rate it does not have).
*
* Reading `s.currency` alone labels every foreign order row as AUD.
* `s` must be the statements alias in scope.
*/
export const NATIVE_CURRENCY = `COALESCE(s.currency, t.foreign_currency_code, 'AUD')`;
/**
* True when a row's AUD value is unknown: it is denominated in something other
* than AUD and carries no converted figure.
*
* Every settlement total uses `COALESCE(t.amount_aud, t.amount)`, which for such
* a row silently nets a foreign figure against AUD ones. Rather than drop the
* row (which changes a balance with no trace) or convert it (with no rate),
* count these and let the UI say the balance is incomplete.
*/
export const AMOUNT_UNCONVERTED = `(t.amount_aud IS NULL AND ${NATIVE_CURRENCY} <> 'AUD')`;
/** Transaction types that represent money going out. */ /** Transaction types that represent money going out. */
export const SPEND_TYPES = `('debit', 'fee', 'interest')`; export const SPEND_TYPES = `('debit', 'fee', 'interest')`;
+35 -5
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { TransactionRow, StatementRow, TagRow, TripRow, TripAnalytics } from "./queries"; import type { TransactionRow, StatementRow, TagRow, TripRow, TripAnalytics, ParticipantBalance } from "./queries";
export type { TripRow, TripAnalytics }; export type { TripRow, TripAnalytics };
import type { CurrentUser } from "./auth"; import type { CurrentUser } from "./auth";
@@ -216,7 +216,7 @@ export function useParticipants() {
} }
export function useParticipantBalances(tagIds?: string[]) { export function useParticipantBalances(tagIds?: string[]) {
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({ return useQuery<ParticipantBalance[]>({
queryKey: ["participant-balances", tagIds], queryKey: ["participant-balances", tagIds],
queryFn: async () => { queryFn: async () => {
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : ""; const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
@@ -775,6 +775,27 @@ export function useMonthlyAnalytics(months?: number) {
}); });
} }
/**
* Sparse by day-of-month; a missing day means zero.
* daily → { "2026-07": { 3: 42.10 } }
* byCategory → { "2026-07": { dining: { 3: 42.10 } } }
*/
export interface DailySpend {
daily: Record<string, Record<number, number>>;
byCategory: Record<string, Record<string, Record<number, number>>>;
}
export function useDailySpend(months?: number) {
const m = months || 12;
return useQuery<DailySpend>({
queryKey: ["analytics", "daily", m],
queryFn: async () => {
const res = await fetch(`/api/analytics/daily?months=${m}`);
return res.json();
},
});
}
export interface SubscriptionRow { export interface SubscriptionRow {
merchant: string; merchant: string;
category: string; category: string;
@@ -815,16 +836,25 @@ export interface FeeTxnRow {
bank_name: string; bank_name: string;
} }
export function useFees() { export interface FeePeriod {
months: number;
from: string | null;
to: string | null;
all_time: boolean;
}
/** `months = 0` means all time. */
export function useFees(months = 12) {
return useQuery<{ return useQuery<{
by_bank: FeeBankRow[]; by_bank: FeeBankRow[];
transactions: FeeTxnRow[]; transactions: FeeTxnRow[];
total_fees: number; total_fees: number;
total_interest: number; total_interest: number;
period: FeePeriod;
}>({ }>({
queryKey: ["analytics", "fees"], queryKey: ["analytics", "fees", months],
queryFn: async () => { queryFn: async () => {
const res = await fetch("/api/analytics/fees"); const res = await fetch(`/api/analytics/fees?months=${months}`);
return res.json(); return res.json();
}, },
}); });
+29 -11
View File
@@ -1,4 +1,5 @@
import { queryRaw } from "./db"; import { queryRaw } from "./db";
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED } from "./analytics-sql";
export interface RoutePointRow { export interface RoutePointRow {
label: string; label: string;
@@ -49,9 +50,13 @@ export interface TransactionRow {
my_amount: number; my_amount: number;
// statement context (null for manual transactions) // statement context (null for manual transactions)
bank_name: string; bank_name: string;
// Native currency of the statement this row came from ('AUD' for manual rows). // The currency `amount` is denominated in; `amount_aud` is the converted
// `amount` is in this currency; `amount_aud` is the converted figure. // figure where one exists. Usually the statement's currency, but an
// order-receipt row has no statement and carries its own — see
// NATIVE_CURRENCY. Not simply 'AUD' for every statement-less row.
currency: string; currency: string;
/** True when `amount` is non-AUD and no converted figure exists. */
amount_unconverted: boolean;
owner_id: number; owner_id: number;
owner_name: string; owner_name: string;
// tags // tags
@@ -124,7 +129,7 @@ interface TransactionFilters {
export async function getTransactions(ownerId: number, filters: TransactionFilters) { export async function getTransactions(ownerId: number, filters: TransactionFilters) {
const conditions: string[] = [ const conditions: string[] = [
`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`, `(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`,
`NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`, EXCLUDE_RECONCILED_SOURCE,
]; ];
const params: unknown[] = [ownerId]; const params: unknown[] = [ownerId];
let paramIdx = 2; let paramIdx = 2;
@@ -227,7 +232,8 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
COALESCE(o.category_override, t.category) as effective_category, COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
${bankLabel()} as bank_name, ${bankLabel()} as bank_name,
COALESCE(s.currency, 'AUD') as currency, ${NATIVE_CURRENCY} as currency,
${AMOUNT_UNCONVERTED} as amount_unconverted,
-- My share, resolved the same way analytics does it (see myShare in -- My share, resolved the same way analytics does it (see myShare in
-- analytics-sql.ts): explicit split row, then override, then whatever is -- analytics-sql.ts): explicit split row, then override, then whatever is
-- left after everyone else. Computed here so the UI cannot drift from -- left after everyone else. Computed here so the UI cannot drift from
@@ -427,6 +433,8 @@ export interface ParticipantBalance {
name: string; name: string;
total_owed: number; total_owed: number;
unsettled_count: number; unsettled_count: number;
/** Splits counted at a non-AUD figure because no converted amount exists. */
unconverted_count: number;
} }
export async function getParticipantBalances(ownerId: number, tagIds?: number[]) { export async function getParticipantBalances(ownerId: number, tagIds?: number[]) {
@@ -456,7 +464,11 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
SELECT p.id, p.name, SELECT p.id, p.name,
COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2) COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2)
${paymentsSelect} AS total_owed, ${paymentsSelect} AS total_owed,
COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count,
-- Splits whose AUD value is unknown. They are still summed above (as
-- their native figure), so a non-zero count means this balance is
-- approximate and the UI has to say so.
COALESCE(SUM(splits.unconverted_count), 0)::int AS unconverted_count
FROM participants p FROM participants p
LEFT JOIN ( LEFT JOIN (
@@ -465,12 +477,13 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
-- currency, so splitting on it nets a USD figure against AUD ones. -- currency, so splitting on it nets a USD figure against AUD ones.
SELECT ts.participant_id AS pid, SELECT ts.participant_id AS pid,
(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100 AS signed_amount, (CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100 AS signed_amount,
1 AS split_count 1 AS split_count,
(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted_count
FROM transaction_splits ts FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id JOIN transactions t ON t.id = ts.transaction_id
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 NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL) AND ${EXCLUDE_RECONCILED_SOURCE}
${tagFilter} ${tagFilter}
UNION ALL UNION ALL
@@ -478,12 +491,13 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
-- I owe them: my splits on transactions they own -- I owe them: my splits on transactions they own
SELECT COALESCE(t.owner_id, s.owner_id) AS pid, SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
-((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100) AS signed_amount, -((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100) AS signed_amount,
0 AS split_count 0 AS split_count,
(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted_count
FROM transaction_splits ts FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id JOIN transactions t ON t.id = ts.transaction_id
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 NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL) AND ${EXCLUDE_RECONCILED_SOURCE}
${tagFilter} ${tagFilter}
) splits ON splits.pid = p.id ) splits ON splits.pid = p.id
${paymentsJoin} ${paymentsJoin}
@@ -742,6 +756,10 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
COALESCE(o.category_override, t.category) as effective_category, COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
${bankLabel()} as bank_name, ${bankLabel()} as bank_name,
-- The table renders t.amount, which is not always AUD. Without these the
-- rows visibly disagreed with the participant balances, which do convert.
${NATIVE_CURRENCY} as currency,
${AMOUNT_UNCONVERTED} as amount_unconverted,
COALESCE(t.owner_id, s.owner_id) as owner_id, COALESCE(t.owner_id, s.owner_id) as owner_id,
p_owner.name as owner_name, p_owner.name as owner_name,
COALESCE(src.created_at, t.created_at) as created_at, COALESCE(src.created_at, t.created_at) as created_at,
@@ -768,10 +786,10 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1) AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
) )
) )
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL) AND ${EXCLUDE_RECONCILED_SOURCE}
${tagClause} ${tagClause}
${participantClause} ${participantClause}
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name, src.created_at GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.currency, s.owner_id, p_owner.name, src.created_at
ORDER BY t.transaction_date DESC ORDER BY t.transaction_date DESC
`, params); `, params);