Files
finance-app/src/app/api/transactions/[id]/splits/route.ts
T
siddharthd 22e4a1ead0
ci / lint-test (push) Successful in 1m39s
fix(splits): make every split account for 100%
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

145 lines
4.9 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { queryRaw } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { canAccessTransactions } from "@/lib/queries";
interface SplitInput {
participant_id: number;
share_percent: number;
}
interface SplitRow {
id: number;
transaction_id: number;
participant_id: number;
name: string;
share_percent: number;
settled: boolean;
settled_at: string | null;
created_at: string;
}
export async function GET(
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;
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const splits = await queryRaw<SplitRow>(
`SELECT ts.*, p.name
FROM transaction_splits ts
JOIN participants p ON p.id = ts.participant_id
WHERE ts.transaction_id = $1
ORDER BY p.name`,
[Number(id)]
);
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 }> }
) {
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 { splits } = (await req.json()) as { splits: SplitInput[] };
if (!splits || !Array.isArray(splits) || splits.length === 0) {
return NextResponse.json({ error: "splits array required" }, { status: 400 });
}
const total = splits.reduce((sum, s) => sum + Number(s.share_percent), 0);
if (Math.abs(total - 100) > 0.01) {
return NextResponse.json(
{ error: `Shares must sum to 100%, got ${total}%` },
{ status: 400 }
);
}
// Carry `settled` across the rewrite.
//
// This replaces every split rather than editing in place, so without this the
// recreated rows take the column default of false — silently converting a
// discharged historical obligation into a live debt. That is not theoretical:
// 657 pre-2026 transactions 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, and
// nothing on screen would say so.
//
// Changing someone's percentage does not re-open the obligation — it was
// settled outside this app and stays settled. A participant added who was not
// there before is a genuinely new obligation and correctly starts unsettled.
const previous = await queryRaw<{
participant_id: number;
settled: boolean;
settled_at: string | null;
}>(
`SELECT participant_id, settled, settled_at
FROM transaction_splits WHERE transaction_id = $1`,
[transactionId]
);
const settledBefore = new Map(
previous.map((p) => [p.participant_id, { settled: p.settled, settled_at: p.settled_at }])
);
// Replace all splits for this transaction atomically
await prisma.$transaction([
prisma.transaction_splits.deleteMany({ where: { transaction_id: transactionId } }),
...splits.map((s) => {
const before = settledBefore.get(s.participant_id);
return prisma.transaction_splits.create({
data: {
transaction_id: transactionId,
participant_id: s.participant_id,
share_percent: s.share_percent,
settled: before?.settled ?? false,
settled_at: before?.settled_at ? new Date(before.settled_at) : null,
},
});
}),
]);
const result = await queryRaw<SplitRow>(
`SELECT ts.*, p.name FROM transaction_splits ts
JOIN participants p ON p.id = ts.participant_id
WHERE ts.transaction_id = $1 ORDER BY p.name`,
[transactionId]
);
return NextResponse.json(result);
}