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"}
+
+
+
+
+ );
+}
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