fix(analytics): make the displayed numbers mean what they say
ci / lint-test (push) Failing after 44s
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user