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).
159 lines
5.5 KiB
TypeScript
159 lines
5.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { queryRaw } from "@/lib/db";
|
|
import {
|
|
OWNER_SCOPE,
|
|
STATEMENTS_JOIN,
|
|
EFFECTIVE_CATEGORY,
|
|
EXCLUDE_NON_SPEND,
|
|
EXCLUDE_RECONCILED_SOURCE,
|
|
NET_SPEND_ROWS,
|
|
SPEND_SIGNED,
|
|
mySplitOf,
|
|
toDateStr,
|
|
} from "@/lib/analytics-sql";
|
|
|
|
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") || "6"), 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 startStr = toDateStr(startDate);
|
|
const endStr = toDateStr(endDate);
|
|
|
|
// Expenses: debits excluding transfers and investments, split-adjusted
|
|
const spendRows = await queryRaw<{
|
|
month: string;
|
|
category: string;
|
|
total_spent: number;
|
|
transaction_count: number;
|
|
}>(
|
|
`SELECT
|
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
|
${EFFECTIVE_CATEGORY} as category,
|
|
-- 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
|
|
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
|
|
ORDER BY 1 DESC, total_spent DESC`,
|
|
[user.id, startStr, endStr]
|
|
);
|
|
|
|
// Income: credits/payments categorised as income
|
|
const incomeRows = await queryRaw<{
|
|
month: string;
|
|
total_income: number;
|
|
transaction_count: number;
|
|
}>(
|
|
`SELECT
|
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
|
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_income,
|
|
COUNT(*)::int as transaction_count
|
|
FROM transactions t
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
${STATEMENTS_JOIN}
|
|
WHERE ${OWNER_SCOPE} = $1
|
|
AND t.transaction_type IN ('credit', 'payment')
|
|
AND ${EFFECTIVE_CATEGORY} = 'income'
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
AND t.transaction_date >= $2
|
|
AND t.transaction_date < $3
|
|
GROUP BY 1
|
|
ORDER BY 1 DESC`,
|
|
[user.id, startStr, endStr]
|
|
);
|
|
|
|
// Investments: any transaction categorised as investment
|
|
const investmentRows = await queryRaw<{
|
|
month: string;
|
|
total_invested: number;
|
|
transaction_count: number;
|
|
}>(
|
|
`SELECT
|
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
|
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_invested,
|
|
COUNT(*)::int as transaction_count
|
|
FROM transactions t
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
${STATEMENTS_JOIN}
|
|
WHERE ${OWNER_SCOPE} = $1
|
|
AND ${EFFECTIVE_CATEGORY} = 'investment'
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
AND t.transaction_date >= $2
|
|
AND t.transaction_date < $3
|
|
GROUP BY 1
|
|
ORDER BY 1 DESC`,
|
|
[user.id, startStr, endStr]
|
|
);
|
|
|
|
// Build month list (most recent first)
|
|
const months: string[] = [];
|
|
for (let i = monthCount - 1; i >= 0; i--) {
|
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
|
}
|
|
months.reverse();
|
|
|
|
const spendMap = new Map<string, number>();
|
|
const countMap = new Map<string, number>();
|
|
const incomeMap = new Map<string, number>();
|
|
const investMap = new Map<string, number>();
|
|
|
|
for (const r of spendRows) {
|
|
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
|
|
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
|
|
}
|
|
for (const r of incomeRows) incomeMap.set(r.month, Number(r.total_income));
|
|
for (const r of investmentRows) investMap.set(r.month, Number(r.total_invested));
|
|
|
|
const allCategories = new Set<string>();
|
|
for (const r of spendRows) allCategories.add(r.category);
|
|
|
|
const rows = Array.from(allCategories)
|
|
.sort()
|
|
.map((cat) => {
|
|
const spent: Record<string, number> = {};
|
|
const txCount: Record<string, number> = {};
|
|
for (const m of months) {
|
|
const s = spendMap.get(`${cat}:${m}`);
|
|
const c = countMap.get(`${cat}:${m}`);
|
|
if (s !== undefined) spent[m] = s;
|
|
if (c !== undefined) txCount[m] = c;
|
|
}
|
|
return { category: cat, spent, txCount };
|
|
});
|
|
|
|
const totals: Record<string, { spent: number; income: number; investments: number; net: number }> = {};
|
|
for (const m of months) {
|
|
let spent = 0;
|
|
for (const row of rows) spent += row.spent[m] || 0;
|
|
const income = incomeMap.get(m) || 0;
|
|
const investments = investMap.get(m) || 0;
|
|
totals[m] = {
|
|
spent: Math.round(spent * 100) / 100,
|
|
income: Math.round(income * 100) / 100,
|
|
investments: Math.round(investments * 100) / 100,
|
|
net: Math.round((income - spent - investments) * 100) / 100,
|
|
};
|
|
}
|
|
|
|
return NextResponse.json({ months, rows, income: Object.fromEntries(incomeMap), investments: Object.fromEntries(investMap), totals });
|
|
}
|