Sign the investments line so withdrawals net against contributions
ci / lint-test (push) Successful in 50s
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:
@@ -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
|
`investment` (excluded from spend, surfaced on the investments line in monthly
|
||||||
analytics).
|
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
|
### Prisma
|
||||||
|
|
||||||
The schema at `prisma/schema.prisma` covers all tables. The generated client (gitignored) must be regenerated after schema changes:
|
The schema at `prisma/schema.prisma` covers all tables. The generated client (gitignored) must be regenerated after schema changes:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
EXCLUDE_RECONCILED_SOURCE,
|
EXCLUDE_RECONCILED_SOURCE,
|
||||||
NATIVE_CURRENCY,
|
NATIVE_CURRENCY,
|
||||||
AMOUNT_UNCONVERTED,
|
AMOUNT_UNCONVERTED,
|
||||||
|
INVESTMENT_SIGNED,
|
||||||
} from "../../lib/analytics-sql";
|
} from "../../lib/analytics-sql";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,3 +125,61 @@ describe("NATIVE_CURRENCY", () => {
|
|||||||
expect((await currencyOf(id)).ccy).toBe("AUD");
|
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<number> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
EXCLUDE_RECONCILED_SOURCE,
|
EXCLUDE_RECONCILED_SOURCE,
|
||||||
NET_SPEND_ROWS,
|
NET_SPEND_ROWS,
|
||||||
SPEND_SIGNED,
|
SPEND_SIGNED,
|
||||||
|
INVESTMENT_SIGNED,
|
||||||
mySplitOf,
|
mySplitOf,
|
||||||
toDateStr,
|
toDateStr,
|
||||||
} from "@/lib/analytics-sql";
|
} from "@/lib/analytics-sql";
|
||||||
@@ -80,7 +81,8 @@ export async function GET(req: NextRequest) {
|
|||||||
[user.id, startStr, endStr]
|
[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<{
|
const investmentRows = await queryRaw<{
|
||||||
month: string;
|
month: string;
|
||||||
total_invested: number;
|
total_invested: number;
|
||||||
@@ -88,7 +90,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}>(
|
}>(
|
||||||
`SELECT
|
`SELECT
|
||||||
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
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
|
COUNT(*)::int as transaction_count
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
|||||||
@@ -331,7 +331,10 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
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 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.
|
// Hero delta vs the average of the other *complete* months that have data.
|
||||||
//
|
//
|
||||||
@@ -677,7 +680,9 @@ export default function AnalyticsPage() {
|
|||||||
{tableMonths.map((m) => {
|
{tableMonths.map((m) => {
|
||||||
const inv = analytics.investments[m];
|
const inv = analytics.investments[m];
|
||||||
return (
|
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) : "—"}
|
{inv ? fmt(inv) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -192,6 +192,33 @@ export const SPEND_SIGNED = `CASE
|
|||||||
ELSE (${SPEND_BASE})
|
ELSE (${SPEND_BASE})
|
||||||
END`;
|
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.
|
* A split that still counts towards what someone owes.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user