ci / lint-test (push) Successful in 52s
Trips were scoped to trips.owner_id, so Sonu saw no trips at all — despite having paid for 104 of the tagged rows herself. Her own spending was invisible on the only page organised around it. A participant is now anyone with a split on, who paid for, or whose payment is scoped to, a transaction tagged to the trip. Derived, not stored. A trip_participants table was designed and rejected: the expenses already carry the fact, and two records of one fact drift apart. Deriving it also excludes Singapore + Bangkok 2026 from Sonu for free, which a table would have to be kept in sync to do. Siddharth 4 trips, Sonu 3, Molina 1. Everything about a trip is shared except delete. Both trip foreign keys are ON DELETE SET NULL, so deleting Europe 2026 untags 210 transactions and NULLs the trip scope on 6 payments — where the hand-derived Europe-first allocation lives, which nothing recomputes. That stays with the owner. Trip owed now returns both directions and nets neither. An obligation lives on a row someone else paid for, so a viewer-as-payer figure can never hold it, and Sonu's Europe read "you are owed $2,408.24" while omitting the $8,004.04 she owed. Collapsing the two into a signed net is the tempting next step and would have corrupted the scope allocation: the grouped-payment allocation cleared each trip against the one-directional gross, so redefining the debt afterwards turns $8,004.04 already allocated into an $802.75 over-allocation with household understated by the same amount. Verified byte-identical — Auckland $1,505.64, Europe Molina -$816.16, Europe Sonu $0.00, Sonu + Sunny $0.00. getTransactions gained trip_all_rows so a participant sees the whole trip. It is opt-in and not implied by trip_id, because the same endpoint backs the main transactions list and its trip filter must keep owner scoping. Participation is re-checked in SQL, so passing the flag for someone else's trip returns nothing. Payments can finally say what they settle. trip_id has existed since migration 0022 but POST never read it and GET never returned it, so every payment made in the app landed on household and the 9 trip-scoped rows were hand-written SQL. "Both" needs no new shape — one row per scope sharing a linked_transaction_id. Three write paths had no authorisation at all and were reachable by any participant: assignTransactionsToTrip checked nothing, DELETE on a payment deleted by bare id, and POST accepted any from/to pair. All three now check. Also fixes the test suite, which was pointing at postgres-pantry: container IPs move on recreation and 172.22.0.47 stopped being postgres-personal. It only failed safe because the credentials did not match — resetDB now refuses to truncate anything not named personal_test. 22 new tests, 276 passing, build clean.
140 lines
5.1 KiB
TypeScript
140 lines
5.1 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { prisma, queryRaw } from "@/lib/db";
|
|
import { assignTransactionsToTrip, canAccessTransactions } from "@/lib/queries";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { applyRuleActions, captureSnapshot } from "@/lib/rule-actions";
|
|
import type { Actions } from "@/lib/rules";
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const user = await getCurrentUser(req);
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
const body = await req.json();
|
|
const { action, ids, category, merchant_normalized, splits, tag_id } = body as {
|
|
action: string;
|
|
ids: number[];
|
|
category?: string;
|
|
merchant_normalized?: string;
|
|
splits?: { participant_id: number; share_percent: number }[];
|
|
tag_id?: number;
|
|
};
|
|
|
|
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
|
return NextResponse.json({ error: "ids required" }, { status: 400 });
|
|
}
|
|
|
|
if (!(await canAccessTransactions(user.id, ids.map(Number)))) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
|
|
if (action === "categorize" && category) {
|
|
const ops = ids.map((id) =>
|
|
prisma.transaction_overrides.upsert({
|
|
where: { transaction_id: id },
|
|
update: { category_override: category, updated_at: new Date() },
|
|
create: { transaction_id: id, category_override: category },
|
|
})
|
|
);
|
|
await prisma.$transaction(ops);
|
|
return NextResponse.json({ updated: ids.length });
|
|
}
|
|
|
|
if (action === "normalize" && merchant_normalized) {
|
|
const ops = ids.map((id) =>
|
|
prisma.transaction_overrides.upsert({
|
|
where: { transaction_id: id },
|
|
update: { merchant_normalized, updated_at: new Date() },
|
|
create: { transaction_id: id, merchant_normalized },
|
|
})
|
|
);
|
|
await prisma.$transaction(ops);
|
|
return NextResponse.json({ updated: ids.length });
|
|
}
|
|
|
|
if (action === "split" && Array.isArray(splits) && splits.length > 0) {
|
|
const total = splits.reduce((s, x) => s + x.share_percent, 0);
|
|
if (Math.abs(total - 100) > 0.01) {
|
|
return NextResponse.json({ error: "Shares must sum to 100%" }, { status: 400 });
|
|
}
|
|
await prisma.$transaction(
|
|
ids.flatMap((id) => [
|
|
prisma.transaction_splits.deleteMany({ where: { transaction_id: id } }),
|
|
prisma.transaction_splits.createMany({
|
|
data: splits.map((s) => ({
|
|
transaction_id: id,
|
|
participant_id: s.participant_id,
|
|
share_percent: s.share_percent,
|
|
})),
|
|
}),
|
|
])
|
|
);
|
|
return NextResponse.json({ updated: ids.length });
|
|
}
|
|
|
|
if ((action === "tag" || action === "untag") && tag_id) {
|
|
if (action === "tag") {
|
|
for (const id of ids) {
|
|
await queryRaw(
|
|
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
[id, tag_id]
|
|
);
|
|
}
|
|
} else {
|
|
await queryRaw(
|
|
`DELETE FROM transaction_tags WHERE transaction_id = ANY($1::int[]) AND tag_id = $2`,
|
|
[ids, tag_id]
|
|
);
|
|
}
|
|
return NextResponse.json({ updated: ids.length });
|
|
}
|
|
|
|
// Quick action: fire a saved rule's actions at the current selection. The
|
|
// selection replaces the rule's conditions, which are not evaluated at all.
|
|
// Recorded as a rule_apply_run so it can be reverted like any other run.
|
|
if (action === "apply_rule") {
|
|
const { rule_id } = body as { rule_id?: number };
|
|
if (!rule_id) return NextResponse.json({ error: "rule_id required" }, { status: 400 });
|
|
|
|
const rules = await queryRaw<{ id: number; name: string; actions: unknown }>(
|
|
`SELECT id, name, actions FROM rules WHERE id = $1 AND owner_id = $2`,
|
|
[Number(rule_id), user.id]
|
|
);
|
|
if (!rules.length) return NextResponse.json({ error: "Rule not found" }, { status: 404 });
|
|
|
|
const actions = (typeof rules[0].actions === "string"
|
|
? JSON.parse(rules[0].actions)
|
|
: rules[0].actions) as Actions;
|
|
|
|
const snapshot = await captureSnapshot(ids.map(Number));
|
|
for (const id of ids) {
|
|
await applyRuleActions(Number(id), actions);
|
|
}
|
|
|
|
const run = await queryRaw<{ id: number }>(
|
|
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
|
|
rule_id, rule_name, source)
|
|
VALUES ($1, NULL, $2, $3, $4, $5, $6, 'selection') RETURNING id`,
|
|
[user.id, ids.length, ids.length, JSON.stringify(snapshot),
|
|
rules[0].id, rules[0].name]
|
|
);
|
|
|
|
return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name });
|
|
}
|
|
|
|
if (action === "assign_trip") {
|
|
const { trip_id } = body as { ids: number[]; trip_id: number | null };
|
|
try {
|
|
// `updated` is what actually moved, not what was asked for — ids the
|
|
// caller cannot see are skipped rather than silently applied.
|
|
const updated = await assignTransactionsToTrip(trip_id, ids, user.id);
|
|
return NextResponse.json({ updated, requested: ids.length });
|
|
} catch (e) {
|
|
return NextResponse.json(
|
|
{ error: e instanceof Error ? e.message : "Failed to assign" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
|
}
|