feat(transactions): manual transaction support and multi-owner query infrastructure

- Add POST /api/transactions to create manual transactions (statement_id=NULL, owner_id set directly)
- Queries switch from JOIN to LEFT JOIN statements so manual transactions are visible
- COALESCE(t.owner_id, s.owner_id) throughout for owner resolution
- Add "Manual" bank filter option in getTransactions
- Search extended to include merchant_normalized override
- Split data fetched via lateral subquery on every transaction row
- getParticipantBalances rewritten as UNION for bidirectional net balances
  (credits/refunds negate, split from either side of the relationship)
- getSharedTransactions: remove my_share_percent from SELECT (fixes GROUP BY error),
  WHERE rewritten as two distinct cases (owner with others split vs participant on others' txn)
- getTransactions: OR EXISTS condition so split participants see shared transactions
- add-transaction-modal component for creating manual transactions with splits
- 0008_my_share_percent migration adds my_share_percent to transaction_overrides
This commit is contained in:
2026-03-14 20:04:00 +11:00
parent 0985c38be8
commit fc22a61a43
6 changed files with 427 additions and 36 deletions
+52
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getTransactions } from "@/lib/queries";
import { queryRaw } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
@@ -23,3 +24,54 @@ export async function GET(req: NextRequest) {
return NextResponse.json(result);
}
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() as {
date: string;
description: string;
amount: number;
transaction_type?: string;
merchant_normalized?: string;
category?: string;
splits?: { participant_id: number; share_percent: number }[];
};
if (!body.date || !body.description || body.amount == null) {
return NextResponse.json({ error: "date, description, amount are required" }, { status: 400 });
}
// Insert manual transaction with no statement (statement_id = NULL, owner_id set directly)
const txRows = await queryRaw<{ id: number }>(
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_normalized, category, row_index)
VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, (
SELECT COALESCE(MAX(row_index), -1) + 1 FROM transactions WHERE owner_id = $1 AND statement_id IS NULL
))
RETURNING id`,
[
user.id,
body.date,
body.description,
body.amount,
body.transaction_type || "debit",
body.merchant_normalized || null,
body.category || null,
]
);
const transactionId = txRows[0].id;
// Insert splits if provided
if (body.splits?.length) {
for (const s of body.splits) {
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[transactionId, s.participant_id, s.share_percent]
);
}
}
return NextResponse.json({ id: transactionId }, { status: 201 });
}