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.
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
useParticipants,
|
||||
useSetOrderReview,
|
||||
useSetSplits,
|
||||
useClearSplits,
|
||||
type ItemOpinion,
|
||||
type ItemVerdict,
|
||||
type OrderReceipt,
|
||||
@@ -249,20 +250,27 @@ function SharedToggle({
|
||||
otherName: string;
|
||||
}) {
|
||||
const setSplits = useSetSplits();
|
||||
const clearSplits = useClearSplits();
|
||||
const shared = splits.some((s) => s.participant_id === SECOND_CONSUMER_ID);
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={setSplits.isPending}
|
||||
disabled={setSplits.isPending || clearSplits.isPending}
|
||||
onClick={() =>
|
||||
setSplits.mutate({
|
||||
transactionId,
|
||||
splits: shared
|
||||
? []
|
||||
: [{ participant_id: SECOND_CONSUMER_ID, share_percent: 50 }],
|
||||
})
|
||||
// Both halves on the way in, and a real delete on the way out. This
|
||||
// used to post one 50% row to share and an empty array to un-share,
|
||||
// and the endpoint rejected both — the toggle did nothing either way.
|
||||
shared
|
||||
? clearSplits.mutate(transactionId)
|
||||
: setSplits.mutate({
|
||||
transactionId,
|
||||
splits: [
|
||||
{ participant_id: OWNER_PARTICIPANT_ID, share_percent: 50 },
|
||||
{ participant_id: SECOND_CONSUMER_ID, share_percent: 50 },
|
||||
],
|
||||
})
|
||||
}
|
||||
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
|
||||
shared
|
||||
|
||||
Reference in New Issue
Block a user