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.
This commit is contained in:
2026-07-29 10:23:06 +10:00
parent dd0462a5f9
commit 22e4a1ead0
9 changed files with 490 additions and 7 deletions
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { completeSplit } from "@/lib/splits";
interface SnapshotEntry {
transaction_id: number;
@@ -91,6 +92,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
[txId, s.participant_id, s.share_percent, s.settled]
);
}
// A snapshot taken before splits were required to total 100% holds the old
// partial shape, and restoring it verbatim would reintroduce exactly what
// this run is being undone from. The owner's share is balance-neutral, so
// completing it cannot change what the revert owes anyone.
await completeSplit(txId);
}
await queryRaw(
+6
View File
@@ -6,6 +6,7 @@ import {
slackUserForParticipant,
} from "@/lib/slack-verify";
import { nudgeBlocks, detailsModal, partnerNudgeBlocks } from "@/lib/slack-blocks";
import { completeSplit } from "@/lib/splits";
import {
RATINGS,
OWNER_PARTICIPANT_ID,
@@ -330,6 +331,11 @@ async function toggleShare(transactionId: number): Promise<string | null> {
DO UPDATE SET share_percent = 50`,
[transactionId, SECOND_CONSUMER_ID]
);
// Both halves, not just theirs. The button means "50/50", and a lone row for
// the other person renders as a 50% share against a blank. The payer's half
// comes from completeSplit rather than a second hardcoded insert, so it lands
// on whoever actually owns the row instead of assuming that is me.
await completeSplit(transactionId);
return null;
}
@@ -41,6 +41,30 @@ export async function GET(
return NextResponse.json(splits);
}
/**
* Remove every split — un-share the transaction.
*
* POST cannot express this: it requires shares totalling 100%, and an empty
* array is not that. Without this the order panel's "Shared 50/50" toggle had
* no way back, and pressing it to un-share failed with "splits array required".
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const transactionId = Number(id);
if (!(await canAccessTransactions(user.id, [transactionId]))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const removed = await prisma.transaction_splits.deleteMany({
where: { transaction_id: transactionId },
});
return NextResponse.json({ removed: removed.count });
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
+6
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getTransactions } from "@/lib/queries";
import { queryRaw } from "@/lib/db";
import { completeSplit } from "@/lib/splits";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
@@ -79,6 +80,11 @@ export async function POST(req: NextRequest) {
[transactionId, s.participant_id, s.share_percent]
);
}
// The form lets you name just the other person and shows the total in amber
// when it is under 100 — which is how "Lawn Mowing, Sonu 50%" was saved with
// the other half nowhere. Fill in the payer's share rather than refusing:
// naming only the other person is a reasonable thing to mean.
await completeSplit(transactionId);
}
return NextResponse.json({ id: transactionId }, { status: 201 });