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 });
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { useCreateTransaction, useParticipants } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
interface Prefill {
date?: string;
description?: string;
amount?: number;
transaction_type?: string;
merchant_normalized?: string;
category?: string;
splits?: { participant_id: number; share_percent: number }[];
}
export function AddTransactionModal({
prefill,
title,
onClose,
}: {
prefill?: Prefill;
title?: string;
onClose: () => void;
}) {
const createTransaction = useCreateTransaction();
const { data: participants = [] } = useParticipants();
const [date, setDate] = useState(prefill?.date ?? new Date().toISOString().slice(0, 10));
const [description, setDescription] = useState(prefill?.description ?? "");
const [amount, setAmount] = useState(prefill?.amount != null ? String(prefill.amount) : "");
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
const [category, setCategory] = useState(prefill?.category ?? "");
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
prefill?.splits ?? []
);
function addSplit() {
if (!participants.length) return;
setSplits([...splits, { participant_id: participants[0].id, share_percent: 50 }]);
}
function updateSplit(i: number, patch: Partial<{ participant_id: number; share_percent: number }>) {
setSplits(splits.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
}
function removeSplit(i: number) {
setSplits(splits.filter((_, idx) => idx !== i));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await createTransaction.mutateAsync({
date,
description,
amount: parseFloat(amount),
transaction_type: type,
merchant_normalized: merchant || undefined,
category: category || undefined,
splits: splits.length ? splits : undefined,
});
onClose();
}
const splitTotal = splits.reduce((s, e) => s + (e.share_percent || 0), 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md shadow-2xl space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-semibold text-sm text-zinc-300">{title ?? "Add Transaction"}</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Date</label>
<input
type="date"
required
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
<input
type="number"
step="0.01"
required
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Description</label>
<input
required
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. Coles Wyndham Vale"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{TRANSACTION_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
<option value=""> none </option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Merchant (optional)</label>
<input
value={merchant}
onChange={(e) => setMerchant(e.target.value)}
placeholder="Normalized merchant name"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
{/* Splits */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-zinc-500">
Splits (optional)
{splits.length > 0 && (
<span className={`ml-2 ${splitTotal === 100 ? "text-emerald-400" : "text-amber-400"}`}>
{splitTotal}%
</span>
)}
</label>
{participants.length > 0 && (
<button type="button" onClick={addSplit} className="text-xs text-indigo-400 hover:text-indigo-300">
+ Add
</button>
)}
</div>
{splits.map((s, i) => (
<div key={i} className="flex gap-2 mb-1.5 items-center">
<select
value={s.participant_id}
onChange={(e) => updateSplit(i, { participant_id: Number(e.target.value) })}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{participants.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<input
type="number"
min={0}
max={100}
value={s.share_percent}
onChange={(e) => updateSplit(i, { share_percent: Number(e.target.value) })}
className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
<span className="text-xs text-zinc-500">%</span>
<button type="button" onClick={() => removeSplit(i)} className="text-zinc-500 hover:text-red-400 text-lg leading-none">×</button>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<button
type="submit"
disabled={createTransaction.isPending}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
{createTransaction.isPending ? "Saving..." : "Save Transaction"}
</button>
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
+77 -3
View File
@@ -83,6 +83,35 @@ export function useBanks() {
});
}
export function useCreateTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: {
date: string;
description: string;
amount: number;
transaction_type?: string;
merchant_normalized?: string;
category?: string;
splits?: { participant_id: number; share_percent: number }[];
}) => {
const res = await fetch("/api/transactions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error((await res.json()).error || "Failed to create transaction");
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["splits"] });
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
qc.invalidateQueries({ queryKey: ["participant-balances"] });
},
});
}
export function useUpdateTransaction() {
const qc = useQueryClient();
return useMutation({
@@ -95,6 +124,10 @@ export function useUpdateTransaction() {
merchant_normalized?: string;
notes?: string;
transaction_type?: string;
my_share_percent?: number | null;
description?: string;
amount?: number;
transaction_date?: string;
}) => {
const res = await fetch(`/api/transactions/${id}`, {
method: "PATCH",
@@ -106,6 +139,7 @@ export function useUpdateTransaction() {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["transaction"] });
qc.invalidateQueries({ queryKey: ["analytics"] });
},
});
}
@@ -421,14 +455,54 @@ export function useDeleteRule() {
export function useApplyRules() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
const res = await fetch("/api/rules/apply", { method: "POST" });
mutationFn: async (splitFrom?: string) => {
const res = await fetch("/api/rules/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ splitFrom: splitFrom || null }),
});
if (!res.ok) throw new Error("Failed to apply rules");
return res.json() as Promise<{ matched: number; transactions_affected: number }>;
return res.json() as Promise<{ id: number; matched: number; transactions_affected: number }>;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["rules"] });
qc.invalidateQueries({ queryKey: ["rule-runs"] });
},
});
}
export interface RuleRun {
id: number;
applied_at: string;
split_from: string | null;
matched: number;
transactions_affected: number;
reverted_at: string | null;
}
export function useRuleRuns() {
return useQuery({
queryKey: ["rule-runs"],
queryFn: async () => {
const res = await fetch("/api/rules/apply");
if (!res.ok) throw new Error("Failed to fetch rule runs");
return res.json() as Promise<RuleRun[]>;
},
});
}
export function useRevertRuleRun() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (runId: number) => {
const res = await fetch(`/api/rules/apply/${runId}/revert`, { method: "POST" });
if (!res.ok) throw new Error("Failed to revert run");
return res.json() as Promise<{ reverted: number }>;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["rule-runs"] });
},
});
}
+81 -33
View File
@@ -8,7 +8,7 @@ export interface TagRow {
export interface TransactionRow {
id: number;
statement_id: number;
statement_id: number | null;
transaction_date: string;
description: string;
amount: number;
@@ -26,14 +26,17 @@ export interface TransactionRow {
category_override: string | null;
merchant_override: string | null;
notes: string | null;
my_share_percent: number | null;
effective_category: string;
effective_merchant: string;
// statement context
// statement context (null for manual transactions)
bank_name: string;
owner_id: number;
owner_name: string;
// tags
tags: TagRow[];
// splits
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
}
export interface StatementRow {
@@ -79,7 +82,7 @@ interface TransactionFilters {
}
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
const conditions: string[] = [`s.owner_id = $1`];
const conditions: string[] = [`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`];
const params: unknown[] = [ownerId];
let paramIdx = 2;
@@ -96,11 +99,15 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
params.push(filters.category);
}
if (filters.bank_name) {
conditions.push(`s.bank_name = $${paramIdx++}`);
params.push(filters.bank_name);
if (filters.bank_name === "Manual") {
conditions.push(`t.statement_id IS NULL`);
} else {
conditions.push(`s.bank_name = $${paramIdx++}`);
params.push(filters.bank_name);
}
}
if (filters.search) {
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx})`);
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx} OR COALESCE(o.merchant_normalized, t.merchant_normalized) ILIKE $${paramIdx})`);
params.push(`%${filters.search}%`);
paramIdx++;
}
@@ -124,7 +131,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
SELECT COUNT(*)::int as total
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN statements s ON s.id = t.statement_id
${where}
`;
const countResult = await queryRaw<{ total: number }>(countSql, params);
@@ -132,32 +139,41 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
const dataSql = `
SELECT t.*,
o.category_override, o.merchant_normalized as merchant_override, o.notes,
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name, s.owner_id,
COALESCE(s.bank_name, 'Manual') as bank_name,
COALESCE(t.owner_id, s.owner_id) as owner_id,
p.name as owner_name,
txn_tags.tags
txn_tags.tags,
txn_splits.splits
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = s.owner_id
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN LATERAL (
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
FROM transaction_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.transaction_id = t.id
) txn_tags ON true
LEFT JOIN LATERAL (
SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits
FROM transaction_splits ts
JOIN participants sp ON sp.id = ts.participant_id
WHERE ts.transaction_id = t.id
) txn_splits ON true
${where}
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
`;
params.push(limit, offset);
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[] }>(dataSql, params);
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(dataSql, params);
const data = raw.map((r) => ({
...r,
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
})) as TransactionRow[];
return { data, total, limit, offset };
@@ -166,15 +182,16 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
export async function getTransactionById(id: number) {
const sql = `
SELECT t.*,
o.category_override, o.merchant_normalized as merchant_override, o.notes,
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name, s.owner_id,
COALESCE(s.bank_name, 'Manual') as bank_name,
COALESCE(t.owner_id, s.owner_id) as owner_id,
p.name as owner_name
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = s.owner_id
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
WHERE t.id = $1
`;
const rows = await queryRaw<TransactionRow>(sql, [id]);
@@ -220,8 +237,13 @@ export async function getMerchantSuggestions(search: string) {
}
export async function getBankNames() {
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
return queryRaw<{ bank_name: string }>(sql);
const [bankRows, manualCount] = await Promise.all([
queryRaw<{ bank_name: string }>(`SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`),
queryRaw<{ count: number }>(`SELECT COUNT(*)::int as count FROM transactions WHERE statement_id IS NULL`),
]);
const banks = bankRows.map((r) => r.bank_name);
if (manualCount[0]?.count > 0) banks.push("Manual");
return banks;
}
export interface ParticipantBalance {
@@ -234,13 +256,35 @@ export interface ParticipantBalance {
export async function getParticipantBalances(ownerId: number) {
return queryRaw<ParticipantBalance>(`
SELECT p.id, p.name,
COALESCE(SUM(CASE WHEN ts.settled = false THEN t.amount * ts.share_percent / 100 ELSE 0 END), 0)::numeric(12,2) as total_owed,
COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count
COALESCE(SUM(combined.signed_amount), 0)::numeric(12,2) as total_owed,
COALESCE(SUM(combined.unsettled_count), 0)::int as unsettled_count
FROM participants p
LEFT JOIN transaction_splits ts ON ts.participant_id = p.id
LEFT JOIN transactions t ON t.id = ts.transaction_id
LEFT JOIN statements s ON s.id = t.statement_id
WHERE (s.owner_id = $1 OR s.id IS NULL)
LEFT JOIN (
-- They owe me: their splits on transactions I own
SELECT ts.participant_id AS pid,
CASE WHEN ts.settled = false
THEN (CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100
ELSE 0 END AS signed_amount,
CASE WHEN ts.settled = false AND t.transaction_type IN ('debit', 'fee', 'interest') THEN 1 ELSE 0 END AS unsettled_count
FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id
LEFT JOIN statements s ON s.id = t.statement_id
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
UNION ALL
-- I owe them: my splits on transactions they own
SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
CASE WHEN ts.settled = false
THEN -(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100
ELSE 0 END AS signed_amount,
0 AS unsettled_count
FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id
LEFT JOIN statements s ON s.id = t.statement_id
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
) combined ON combined.pid = p.id
WHERE p.id != $1
GROUP BY p.id, p.name
ORDER BY p.name
`, [ownerId]);
@@ -267,7 +311,8 @@ export async function getSharedTransactions(ownerId: number) {
o.category_override, o.merchant_normalized as merchant_override, o.notes,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name, s.owner_id,
COALESCE(s.bank_name, 'Manual') as bank_name,
COALESCE(t.owner_id, s.owner_id) as owner_id,
p_owner.name as owner_name,
json_agg(json_build_object(
'split_id', ts.id,
@@ -280,13 +325,16 @@ export async function getSharedTransactions(ownerId: number) {
JOIN transaction_splits ts ON ts.transaction_id = t.id
JOIN participants p ON p.id = ts.participant_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p_owner ON p_owner.id = s.owner_id
WHERE s.owner_id = $1
AND EXISTS (
SELECT 1 FROM transaction_splits ts2
JOIN participants p2 ON p2.id = ts2.participant_id
WHERE ts2.transaction_id = t.id AND p2.name != 'Me'
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
WHERE (
-- I own this transaction and at least one other person has a split
COALESCE(t.owner_id, s.owner_id) = $1
AND EXISTS (SELECT 1 FROM transaction_splits ts2 WHERE ts2.transaction_id = t.id AND ts2.participant_id != $1)
) OR (
-- Someone else owns this transaction and I have a split on it
COALESCE(t.owner_id, s.owner_id) != $1
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
)
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
ORDER BY t.transaction_date DESC