diff --git a/CLAUDE.md b/CLAUDE.md index 6292f9c..83b706f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -529,6 +529,33 @@ Loan interest uses the `loan_interest` category; principal repayments use `investment` (excluded from spend, surfaced on the investments line in monthly analytics). +### The investments line is signed + +A withdrawal from a fund is a **disinvestment**, not income. Units convert back +to cash; net worth is unchanged. `INVESTMENT_SIGNED` (`analytics-sql.ts`) makes +credits and refunds negative so they net against contributions, and +`/api/analytics/monthly` is the only consumer. + +Summed unsigned, a withdrawal read as *more* money invested: March 2026 showed +$38,615.34 of investing in a month that was net **−$11,384.66**, because a +$25,000 Raiz withdrawal was added to an $8,563.80 IBKR deposit instead of +cancelling it. **Each credit costs twice** — once for being added, once for not +being subtracted — so the error is double the credit, $50,000 in that month. + +Filing withdrawals as `income` is the other tempting answer and is worse: it +books an asset disposal as earnings and feeds `net = income − spent − +investments` with a flattering sign. Same reason the Up item sales in Known Gaps +do not belong on the income line. + +**What this cannot resolve:** part of a withdrawal genuinely *is* income — the +capital gain. The bank descriptor is one gross figure with no cost base +(`TRANSFER FROM RAIZ WITHDRAWAL 7D5262D8A839248A12`), so it cannot be decomposed +from statement data. Netting tracks cash committed against cash returned and +leaves the gain for holdings data to surface; it does not assert the gain is zero. + +Consequence for the UI: a net-disinvesting month is real data, so the budget page +gates on `!== 0`, not `> 0`, and renders negatives in amber. + ### Prisma The schema at `prisma/schema.prisma` covers all tables. The generated client (gitignored) must be regenerated after schema changes: diff --git a/src/__tests__/integration/analytics-sql.test.ts b/src/__tests__/integration/analytics-sql.test.ts index 97f078f..e812467 100644 --- a/src/__tests__/integration/analytics-sql.test.ts +++ b/src/__tests__/integration/analytics-sql.test.ts @@ -4,6 +4,7 @@ import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED, + INVESTMENT_SIGNED, } from "../../lib/analytics-sql"; /** @@ -124,3 +125,61 @@ describe("NATIVE_CURRENCY", () => { expect((await currencyOf(id)).ccy).toBe("AUD"); }); }); + +describe("INVESTMENT_SIGNED", () => { + /** The signed value the investments line would attribute to this row. */ + async function signedValue(id: number): Promise { + const row = await queryRow<{ v: string }>( + `SELECT (${INVESTMENT_SIGNED})::text AS v FROM transactions t WHERE t.id = $1`, + [id] + ); + return Number(row!.v); + } + + it("counts a contribution positive", async () => { + const id = await scratchTxn( + "transaction_date, description, amount, transaction_type, category", + "'2026-03-01','Investment fixture — deposit', 5000.00, 'debit', 'investment'" + ); + expect(await signedValue(id)).toBe(5000); + }); + + it("counts a withdrawal negative so it nets against contributions", async () => { + // The $25,000 Raiz withdrawal that made March 2026 read as a $38,615.34 + // investing month when it was net -$11,384.66. + const id = await scratchTxn( + "transaction_date, description, amount, transaction_type, category", + "'2026-03-19','Investment fixture — withdrawal', 25000.00, 'credit', 'investment'" + ); + expect(await signedValue(id)).toBe(-25000); + }); + + it("a deposit and an equal withdrawal net to zero", async () => { + const inId = await scratchTxn( + "transaction_date, description, amount, transaction_type, category", + "'2026-03-01','Investment fixture — net in', 1000.00, 'debit', 'investment'" + ); + const outId = await scratchTxn( + "transaction_date, description, amount, transaction_type, category", + "'2026-03-02','Investment fixture — net out', 1000.00, 'credit', 'investment'" + ); + expect(await signedValue(inId) + await signedValue(outId)).toBe(0); + }); + + it("prefers amount_aud over the native amount", async () => { + // The IBKR rows are USD; the line is denominated in AUD. + const id = await scratchTxn( + "transaction_date, description, amount, amount_aud, transaction_type, category", + "'2026-07-25','Investment fixture — foreign', 10000.00, 14310.00, 'debit', 'investment'" + ); + expect(await signedValue(id)).toBe(14310); + }); + + it("treats a refund like a withdrawal", async () => { + const id = await scratchTxn( + "transaction_date, description, amount, transaction_type, category", + "'2026-03-01','Investment fixture — reversal', 1500.00, 'refund', 'investment'" + ); + expect(await signedValue(id)).toBe(-1500); + }); +}); diff --git a/src/app/api/analytics/monthly/route.ts b/src/app/api/analytics/monthly/route.ts index 4dd53c5..d24a193 100644 --- a/src/app/api/analytics/monthly/route.ts +++ b/src/app/api/analytics/monthly/route.ts @@ -9,6 +9,7 @@ import { EXCLUDE_RECONCILED_SOURCE, NET_SPEND_ROWS, SPEND_SIGNED, + INVESTMENT_SIGNED, mySplitOf, toDateStr, } from "@/lib/analytics-sql"; @@ -80,7 +81,8 @@ export async function GET(req: NextRequest) { [user.id, startStr, endStr] ); - // Investments: any transaction categorised as investment + // Investments: any transaction categorised as investment, signed so that + // withdrawals net against contributions (see INVESTMENT_SIGNED). const investmentRows = await queryRaw<{ month: string; total_invested: number; @@ -88,7 +90,7 @@ export async function GET(req: NextRequest) { }>( `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, + SUM(${INVESTMENT_SIGNED})::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 diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx index 7fe8a06..4acdb95 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -331,7 +331,10 @@ export default function AnalyticsPage() { const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 }; const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0); - const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0); + // `!== 0`, not `> 0`: the investments line is signed, so a net-disinvesting + // month is real data, not an empty one. A window where every month nets + // negative would otherwise render as "—". + const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) !== 0); // Hero delta vs the average of the other *complete* months that have data. // @@ -677,7 +680,9 @@ export default function AnalyticsPage() { {tableMonths.map((m) => { const inv = analytics.investments[m]; return ( - + // A net-disinvesting month is a different fact from an + // investing one; same-coloured digits hide the sign. + {inv ? fmt(inv) : "—"} ); diff --git a/src/lib/analytics-sql.ts b/src/lib/analytics-sql.ts index f30cb69..c501219 100644 --- a/src/lib/analytics-sql.ts +++ b/src/lib/analytics-sql.ts @@ -192,6 +192,33 @@ export const SPEND_SIGNED = `CASE ELSE (${SPEND_BASE}) END`; +/** + * The investment line, signed: withdrawals come back as negatives so they net + * against contributions. + * + * A withdrawal from a fund is a disinvestment — units converted back to cash, + * net worth unchanged. Summed unsigned it read as *more* money invested: March + * 2026 showed $38,615.34 of investing in a month that was net -$11,384.66, + * because a $25,000 Raiz withdrawal was added to a $8,563.80 IBKR deposit + * instead of cancelling it. Overstated by $50,000 in that month alone — each + * credit costs twice, once for being added and once for not being subtracted. + * + * Filing withdrawals as `income` instead is the other tempting answer and is + * worse: it books an asset disposal as earnings and feeds the same figure into + * `net = income - spent - investments` with a flattering sign. Same reason the + * Up item sales in Known Gaps do not belong on the income line. + * + * Caveat this cannot resolve: part of a withdrawal genuinely is income — the + * capital gain. The bank descriptor is a single gross figure with no cost base, + * so it cannot be decomposed here. Netting tracks cash committed against cash + * returned and leaves the gain for holdings data to surface; it does not claim + * the gain is zero. + */ +export const INVESTMENT_SIGNED = `CASE + WHEN t.transaction_type IN ('refund', 'credit') THEN -COALESCE(t.amount_aud, t.amount) + ELSE COALESCE(t.amount_aud, t.amount) +END`; + /** * A split that still counts towards what someone owes. *