b958d052876a3e3e6165537f460739427d420922
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6c14b493ef |
analytics: your share of what someone else paid is your spend
ci / lint-test (push) Successful in 1m25s
Every spend analytic gated on `OWNER_SCOPE = $1` and scaled by `mySplitOf` *within* that gate, so ownership was a precondition for an expense being yours. Half a grocery shop Sonu paid for counted as zero — in monthly, daily, merchants, subscriptions, fees and the budget page. 167 rows / $3,210.91 across Jan-Jul 2026, worst in April (+$1,354.83, the Europe trips), while getParticipantBalances booked the matching debt correctly. The app could say you owed her for a shop while insisting you had not spent anything on it. New MY_SPEND_SCOPE(): owner = me OR I hold a split. OWNER_SCOPE stays on the things that measure an *account* rather than a person — the income and investment lines, and the statement-level fee rollup. myShare had to change with it, and widening the gate alone would have been worse than the bug: its `100 - everyone else` fallback is the payer's remainder, so on someone else's unsplit row it returns 100 and moves their whole bill onto you. It now branches on ownership — my row resolves as before; their row takes an explicit split row only, absent meaning 0. That 0 is what makes the wider gate safe. my_share_percent is deliberately not read on someone else's row: one unscoped column, writable by anyone who can see the row, so "my" can only mean the owner's. All 402 rows carrying one today are owner-side. MY_SHARE_PCT mirrors myShare for the transactions list, which has no viewer-scoped ts join; a test asserts the two agree across seven fixture shapes. No historical restatement — every non-owner split is 2026-dated, and the 1,266 pre-2026 SplitMyExpenses splits are all on rows you own. Also fixes a latent failure in the NATIVE_CURRENCY test, which inserted a statement relying on participant id 1 existing (owner_id is NOT NULL DEFAULT 1 with an FK) and only passed when a sibling file had left one behind. It now owns its fixture. 15 new integration tests; 189 integration + 130 unit green. |
||
|
|
c70d2b1fac |
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. |
||
|
|
788219b9fd |
fix(splits): the cutover date, not a boolean, is what gates a balance
ci / lint-test (push) Successful in 46s
Nothing dated before 2026-01-09 can be owed, because carryover transaction 2348
already carries the entire pre-cutover balance as a single figure. ACTIVE_OBLIGATION
now says so directly.
This inverts which mechanism is load-bearing, and that is the point. Until now
the only thing keeping $37,233.28 of paid debt out of the balances was
transaction_splits.settled -- a boolean that any delete-and-recreate write path
resets to false, as the split modal did until commit
|
||
|
|
dbfbd5196d |
fix(transactions): supersede rows imported twice instead of deleting them
ci / lint-test (push) Successful in 48s
Statements 107, 142 and 143 bill overlapping periods on one ANZ account, so 31 transactions -- $42,040.68 -- are in the ledger twice. They are marked superseded, not deleted. Every child of transactions is ON DELETE CASCADE (splits, tags, overrides, expense_metadata, order_reviews), so deleting "the duplicate" destroys whatever curation sits on it, and which member of a pair holds that curation is an accident of import order: here 1 pair carries splits and 6 carry overrides, all on the surviving side, but nothing guarantees that. Superseding keeps the row, keeps its children, and makes a mistake one UPDATE to undo rather than a restore from backup. reconciled_with_id could not be reused. Its predicate is scoped to statement_id IS NULL on purpose -- a statement line pointing at something else is the survivor, not the duplicate -- and here both rows are statement lines. The exclusion goes into EXCLUDE_RECONCILED_SOURCE rather than into a new fragment, so every query already asking "count each purchase once" gets it without being edited. The trip cost queries did not use that fragment at all and now do; verified a no-op on current data (0 trip-tagged rows are either reconciled sources or duplicates), but they were one import away from double-counting. Most of the $42k is transfers and investments, which spend already excludes. The damage was elsewhere: duplicated rows in the list, and rules re-splitting a duplicate -- txn 3807 is one of these 31 and was a candidate for splitting earlier today. Balances are unchanged: no duplicate carried a split. |
||
|
|
689fadc8b9 |
feat(shared): give a payment a tab to settle
A payment has only ever recorded from, to, amount and date. That is why the per-trip owed figure did not exist — getTripAnalytics said so where the number should have been: "split_payments carries no trip attribution, so a payment cannot be assigned to a trip. Settlement is a property of the whole relationship." Every trip therefore read 100% unsettled, including trips paid in full. It is also why the Shared page silently drops payments under a tag filter. With one global pool there was nothing honest to subtract, so it showed gross splits under the same label. A tag is a view; a scope is a ledger. The scope is a trip, not a new settlement_contexts table. trips already has owner_id, dates and archived, and transaction_overrides.trip_id already decides membership. A second grouping beside it would be two unsynchronised scopes over the same rows, with no invariant saying which governs. NULL means the ongoing household tab, which never closes. settled answers a different question and the two must not be collapsed: trip_id is which tab, settled is whether the obligation is still live. Critically, a live obligation is NOT settled by flipping the flag — it is settled by recording the payment, and the balance nets to zero on its own. Doing both would subtract the settlement twice. So settled is written only by the historical import, for repayments made on a platform we no longer run, and there is deliberately no "mark settled" action. Both owed figures now exclude settled splits and the trip figure nets its own payments. Spend analytics (myShare/mySplitOf) deliberately still count settled rows: my half of a 2025 grocery shop is my spend whether or not the other half was ever repaid, and filtering them would re-inflate exactly the figures importing settled history exists to correct. Also drops /api/participants/[id]/balance. It had no consumers, no owner scoping, no debit/credit signs and no EXCLUDE_RECONCILED_SOURCE — a fourth balance implementation that disagreed with the others and would have imported three bugs if anything had aligned to it. getTripAnalytics had no test at all. It has five now, including the one that matters: a household payment must not make a trip look paid. Verified by mutation — neutering the settled filter fails three, and dropping the trip filter on payments fails that one. |
||
|
|
a4ab543a6c |
fix(analytics): make the displayed numbers mean what they say
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). |
||
|
|
bbb90238e4 | feat(orders): implement order ingestion pipeline, review ratings schema, and [Family] exclusion | ||
|
|
d6b4ec84f6 |
feat(integrity): balance assertions, category constraint, refund netting
ci / lint-test (push) Successful in 39s
Four related fixes to spend correctness.
Balance assertions. Nothing checked that a statement's transactions add up to
its closing balance. getStatements now computes opening + movement - closing
and the statements page flags any statement that does not reconcile. Sign
depends on what the balance means: on a credit card or loan it is what you owe,
so spending increases it; on a transaction or offset account it is what you
hold. 11 statements currently fail, $4,177 unexplained - including two adjacent
ANZ statements off by exactly +/-$230.38, a transaction filed against the wrong
one.
Category normalisation (migration 0015). Categories arrive from Gemini (which
writes straight to Postgres from N8N), CSV import and manual edits, so the rule
belongs in the database - same reasoning as normalize_statement_type in 0013.
Adds normalize_category(), triggers on transactions and transaction_overrides,
and CHECK constraints. Backfilled 122 rows: 19 'payment' ($42,569) to transfers,
14 'refund' recovered to the merchant's usual category, and title-case duplicates
folded into their canonical spelling - 'Shopping' and 'shopping' had been
counted as separate categories by every GROUP BY.
Refund netting. Monthly analytics counted only debits, so a refund was counted
nowhere: excluded from spend by type, and not income by category. A $2,888.92
Expedia purchase refunded in full eight days later still read as $2,888.92 of
spend. SPEND_SIGNED and NET_SPEND_ROWS bring refunds in as negatives; 'income'
joins the excluded categories so incoming money cannot leak in as negative
spend. $36,773 across 93 rows now nets correctly.
Drill-down share. The insights drill-down used my_share_percent ?? 100, which
ignored transaction_splits entirely and repeated the bug fixed in
|
||
|
|
ab00f8c592 |
fix(analytics): my share is what is left over, not 100%
ci / lint-test (push) Successful in 41s
The split-adjusted spend expression assumed a transaction with no split row for me was entirely mine. That is wrong when a transaction is allocated fully to someone else: I paid, they owe all of it, and there is no row for me to match. Both branches of the CASE missed and the ELSE charged me the full amount. 24 transactions were affected, all travel bookings between 2026-01-09 and 2026-06-26, overstating my spend by $8,579.07 across every analytics view. Adds myShare/mySplitOf to analytics-sql.ts, which fall back to 100 - (sum of everyone else's shares) instead of 100, and applies them to all five analytics routes. Centralised for the same reason as EXCLUDE_NON_SPEND: the expression was duplicated five times and had already drifted. |
||
|
|
76db9dddb4 |
feat(loans): principal/interest split so repayments stop distorting spend
ci / lint-test (push) Successful in 41s
A loan repayment is not an expense. A $3,000 mortgage repayment is roughly $1,200 of principal (equity — a balance-sheet move) and $1,800 of interest (the only part that is genuinely spend). Migration 0014: - transactions.principal_amount / interest_amount, populated only when the lender itemises the split on the repayment row - statements.interest_rate, scheduled_repayment, repayment_frequency, redraw_available, loan_term_months - normalize_repayment_frequency() + trigger, so "Fortnightly", "Bi-Weekly" and "Every 2 weeks" all land on 'fortnightly' Two statement shapes are handled. Where the loan statement lists repayments and "Interest Charged" as separate rows (the common Australian case), transaction_type already does the work. Where a lender itemises the split on the repayment row, that row is typed 'payment' and would be skipped entirely — losing the interest. New SPEND_ROWS / SPEND_BASE fragments in analytics-sql.ts count such rows at interest_amount instead of amount. Adds the loan_interest category (+ colour, and the missing fees colour). Verified against the live DB with a synthetic ANZ home loan statement: a $3,000 itemised repayment plus a $10 service fee moved April spend by exactly $1,810, with the $1,200 principal excluded and still retained on the row. Test data removed and the figure confirmed back at its original value. |
||
|
|
25ef504574 |
feat(statements+analytics): normalise statement_type; fix analytics scoping
ci / lint-test (push) Successful in 38s
Groundwork for importing bank and loan statements alongside credit cards.
statement_type was whatever free text Gemini put in account_type ('Credit Card',
'credit card', 'credit_card', 'Business Card', 'ACCESS ADVANTAGE',
'multi-currency account'). The UI coped only by doing .includes("card"), which
breaks as soon as bank and loan statements arrive.
- Migration 0013 adds normalize_statement_type() + a BEFORE INSERT/UPDATE
trigger and a CHECK constraint over credit_card|transaction|savings|loan|
offset|investment|other. The trigger means the N8N workflow keeps working
unchanged while it still sends free text. Raw value stays in account_type.
Backfilled 99 existing rows.
- src/lib/statement-types.ts mirrors the vocabulary for the UI; statements page
now filters by the real types and headlines balance vs amount due per type.
Analytics were scoped with INNER JOIN statements + s.owner_id, which silently
dropped all 180 manual/CSV transactions (statement_id IS NULL) from every
report. Switched all six routes to LEFT JOIN + COALESCE(t.owner_id, s.owner_id)
via shared fragments in src/lib/analytics-sql.ts, so the transfers/investment
exclusion that stops card-payment double counting stays consistent. Also
extended that exclusion to trip analytics, which had none.
Drive-by: /api/analytics/subscriptions was returning 500 on an unserialisable
BigInt from COUNT(*) + 1.
Verified against the live DB: monthly spend picks up the previously invisible
manual transactions (Apr 9,849.91 -> 14,286.70) and all four analytics
endpoints return 200.
|