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:
+29
-11
@@ -1,4 +1,5 @@
|
||||
import { queryRaw } from "./db";
|
||||
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED } from "./analytics-sql";
|
||||
|
||||
export interface RoutePointRow {
|
||||
label: string;
|
||||
@@ -49,9 +50,13 @@ export interface TransactionRow {
|
||||
my_amount: number;
|
||||
// statement context (null for manual transactions)
|
||||
bank_name: string;
|
||||
// Native currency of the statement this row came from ('AUD' for manual rows).
|
||||
// `amount` is in this currency; `amount_aud` is the converted figure.
|
||||
// The currency `amount` is denominated in; `amount_aud` is the converted
|
||||
// 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;
|
||||
/** True when `amount` is non-AUD and no converted figure exists. */
|
||||
amount_unconverted: boolean;
|
||||
owner_id: number;
|
||||
owner_name: string;
|
||||
// tags
|
||||
@@ -124,7 +129,7 @@ interface TransactionFilters {
|
||||
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||
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))`,
|
||||
`NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`,
|
||||
EXCLUDE_RECONCILED_SOURCE,
|
||||
];
|
||||
const params: unknown[] = [ownerId];
|
||||
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.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||
${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
|
||||
-- analytics-sql.ts): explicit split row, then override, then whatever is
|
||||
-- left after everyone else. Computed here so the UI cannot drift from
|
||||
@@ -427,6 +433,8 @@ export interface ParticipantBalance {
|
||||
name: string;
|
||||
total_owed: 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[]) {
|
||||
@@ -456,7 +464,11 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
||||
SELECT p.id, p.name,
|
||||
COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2)
|
||||
${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
|
||||
|
||||
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.
|
||||
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,
|
||||
1 AS split_count
|
||||
1 AS split_count,
|
||||
(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted_count
|
||||
FROM transaction_splits ts
|
||||
JOIN transactions t ON t.id = ts.transaction_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
|
||||
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
${tagFilter}
|
||||
|
||||
UNION ALL
|
||||
@@ -478,12 +491,13 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
||||
-- I owe them: my splits on transactions they own
|
||||
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,
|
||||
0 AS split_count
|
||||
0 AS split_count,
|
||||
(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted_count
|
||||
FROM transaction_splits ts
|
||||
JOIN transactions t ON t.id = ts.transaction_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
|
||||
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
${tagFilter}
|
||||
) splits ON splits.pid = p.id
|
||||
${paymentsJoin}
|
||||
@@ -742,6 +756,10 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
|
||||
COALESCE(o.category_override, t.category) as effective_category,
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||
${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,
|
||||
p_owner.name as owner_name,
|
||||
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 NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
${tagClause}
|
||||
${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
|
||||
`, params);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user