diff --git a/prisma/migrations/0008_my_share_percent/migration.sql b/prisma/migrations/0008_my_share_percent/migration.sql new file mode 100644 index 0000000..250ddc8 --- /dev/null +++ b/prisma/migrations/0008_my_share_percent/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "transaction_overrides" ADD COLUMN "my_share_percent" DECIMAL(5,2); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ae35559..386b1ab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,7 @@ model transaction_overrides { merchant_normalized String? category_override String? notes String? + my_share_percent Decimal? @db.Decimal(5, 2) updated_at DateTime @default(now()) @updatedAt } diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts index 4d87ff2..9c75486 100644 --- a/src/app/api/transactions/route.ts +++ b/src/app/api/transactions/route.ts @@ -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 }); +} diff --git a/src/components/add-transaction-modal.tsx b/src/components/add-transaction-modal.tsx new file mode 100644 index 0000000..1a7cb00 --- /dev/null +++ b/src/components/add-transaction-modal.tsx @@ -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 ( +
+
e.stopPropagation()} + > +

{title ?? "Add Transaction"}

+ +
+
+
+ + setDate(e.target.value)} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + /> +
+
+ + 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" + /> +
+
+ +
+ + 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" + /> +
+ +
+
+ + +
+
+ + +
+
+ +
+ + 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" + /> +
+ + {/* Splits */} +
+
+ + {participants.length > 0 && ( + + )} +
+ {splits.map((s, i) => ( +
+ + 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" + /> + % + +
+ ))} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 8ab08d7..6cb1f3c 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -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; + }, + }); +} + +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"] }); }, }); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index e94b405..3cab4b5 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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(dataSql, params); + const raw = await queryRaw(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(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(` 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