Sign the investments line so withdrawals net against contributions
ci / lint-test (push) Successful in 50s

A withdrawal from a fund is a disinvestment, not income: units convert
back to cash and net worth is 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 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 March, $3,000
in May 2025, $53,000 across the window. Since net = income - spent -
investments, March's net of -$55,918.73 should read -$5,918.73.

Filing withdrawals as income is the other tempting answer and is worse:
it books an asset disposal as earnings and feeds the same figure into net
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,
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.

The budget page gates the Invested card on `!== 0` rather than `> 0` — a
net-disinvesting month is real data, not an empty one — and renders
negative months in amber so the sign is not hidden by matching digits.
This commit is contained in:
2026-07-31 00:05:05 +10:00
parent f6c500b27a
commit c70d2b1fac
5 changed files with 124 additions and 4 deletions
+4 -2
View File
@@ -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
+7 -2
View File
@@ -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 (
<td key={m} className="px-3 py-2 text-right font-mono tabular-nums text-indigo-300">
// A net-disinvesting month is a different fact from an
// investing one; same-coloured digits hide the sign.
<td key={m} className={`px-3 py-2 text-right font-mono tabular-nums ${inv < 0 ? "text-amber-400" : "text-indigo-300"}`}>
{inv ? fmt(inv) : "—"}
</td>
);