Commit Graph
4 Commits
Author SHA1 Message Date
siddharthd 22e4a1ead0 fix(splits): make every split account for 100%
ci / lint-test (push) Successful in 1m39s
A 50/50 arrangement was stored as a single row saying "Sonu 50%". The
arithmetic was never wrong — `myShare` resolves the payer's share as
`100 - SUM(everyone else)`, so balances and per-user spend were correct
throughout. It was still a bug, because a ledger is read as well as
computed: on screen that row is a 50% share against a blank, which looks
like half the money is unallocated and is indistinguishable from a split
somebody abandoned half-finished.

It also leaked. `getSharedTransactions` filters by participant with an
EXISTS on an explicit split row, so filtering the Shared view by the
payer silently dropped every transaction where their share was only ever
implied.

Four write paths could produce it, three of them unguarded:

  - the Slack nudge's share button, which inserted one row
  - `POST /api/transactions`, where the add form shows an amber total
    under 100 but saves anyway — this is how Lawn Mowing and Hedge
    Pruning were stored
  - `applyRuleActions`, where ten of the fourteen live split rules name
    only the other person

`completeSplit` is now the single place that writes the remainder, and
every one of those paths ends in it. The remainder goes to the
transaction's *owner*, never to "me": the owner's row on their own
transaction is excluded from both halves of the balance query, so it
cannot create, enlarge or discharge a debt, whereas a row for me on
someone else's transaction is a real obligation. That distinction is
what makes this safe to apply to existing data.

Also fixes the order panel's "Shared 50/50" toggle, which was inert in
both directions: it posted a lone 50% row to share (rejected — must
total 100%) and an empty array to un-share (rejected — array required),
because no way to clear a split existed. DELETE on the splits route is
that way.

Backfill: 7 rows, verified against a row-level dump diff — 2549 -> 2556
rows, none removed, none modified — and participant balances byte
identical before and after (Molina 19556.07, Sonu 20913.35). Every split
in the database now totals 100%.

Not done: a database-level constraint. Enforcing the sum needs a
deferred constraint trigger, and the rule path commits its DELETE and
INSERT as separate statements, so the trigger would reject the
intermediate state. Making it work means wrapping every write path in a
transaction, which is a larger change than the defect warrants.
2026-07-29 10:23:06 +10:00
siddharthd 6add958132 fix(splits): editing a split must not resurrect a settled debt
ci / lint-test (push) Successful in 47s
The route replaces every split for a transaction rather than editing in place,
so the recreated rows took the column default settled=false. Opening the split
modal on a historical transaction and saving it therefore converted a
discharged obligation into a live one, with nothing on screen saying so.

That is not theoretical. 657 pre-2026 transactions now carry settled splits
imported from SplitMyExpenses -- $37,233.28 of balance that the carryover
(transaction 2348) already accounts for. Editing one would double-count its
share against a debt that was paid years ago.

Now carries settled and settled_at across the rewrite, per participant, the
same way the rules revert route already does. Changing someone's percentage
does not re-open the obligation: it was settled outside this app and stays
settled. A participant who was not on the transaction before is a genuinely new
obligation and correctly starts unsettled.

rule-actions.ts was already safe here -- it upserts ON CONFLICT DO UPDATE SET
share_percent, so it never touches the flag.
2026-07-28 13:40:48 +10:00
siddharthd 0b40924af0 fix(security+trips): auth/ownership on all API routes; trip analytics in AUD
Ten routes accepted requests with no getCurrentUser check (transactions/[id],
bulk, splits, tags-on-tx, splits/settle, statements/[id], tags, tags/[id],
merchants, participants/[id]/balance), and by-id routes did no ownership
check at all — any participant could read or modify another's data.

Adds canAccessTransactions() (owner via statement/direct, or split
participant), applies it to every transaction-scoped route, owner-scopes
statements/[id], and rescopes splits/settle in raw SQL so settlement only
touches splits the caller is party to.

Also: all trip analytics now sum COALESCE(amount_aud, amount) instead of raw
amount, matching every other analytics query — trip totals previously added
foreign-currency amounts to AUD ones unit-less.

And rules apply_split no longer delete+reinserts splits (which reset settled
flags on every run) — it upserts share_percent and removes only participants
no longer in the rule.
2026-07-19 20:07:09 +10:00
siddharthd 5dbeb0cb87 chore: commit previously untracked runtime files (splits, auth, participants, shared) 2026-03-08 18:00:46 +11:00