diff --git a/prisma/migrations/0002_splits/migration.sql b/prisma/migrations/0002_splits/migration.sql new file mode 100644 index 0000000..517a419 --- /dev/null +++ b/prisma/migrations/0002_splits/migration.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS participants ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ DEFAULT NOW() +); +INSERT INTO participants (name) VALUES ('Me') ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS transaction_splits ( + id SERIAL PRIMARY KEY, + transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + share_percent NUMERIC(5,2) NOT NULL CHECK (share_percent > 0 AND share_percent <= 100), + settled BOOLEAN DEFAULT FALSE, + settled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (transaction_id, participant_id) +); +CREATE INDEX IF NOT EXISTS idx_splits_txn ON transaction_splits(transaction_id); +CREATE INDEX IF NOT EXISTS idx_splits_participant ON transaction_splits(participant_id); diff --git a/prisma/migrations/0003_owner_segregation/migration.sql b/prisma/migrations/0003_owner_segregation/migration.sql new file mode 100644 index 0000000..c2a242a --- /dev/null +++ b/prisma/migrations/0003_owner_segregation/migration.sql @@ -0,0 +1,17 @@ +-- Add email to participants for OAuth identity mapping +ALTER TABLE participants ADD COLUMN IF NOT EXISTS email TEXT UNIQUE; + +-- Add owner_id and account_holder_name to statements +ALTER TABLE statements ADD COLUMN IF NOT EXISTS owner_id INTEGER NOT NULL DEFAULT 1 REFERENCES participants(id); +ALTER TABLE statements ADD COLUMN IF NOT EXISTS account_holder_name TEXT; +CREATE INDEX IF NOT EXISTS idx_statements_owner_id ON statements(owner_id); + +-- Auto-assignment mapping table: (bank_name, account_number) -> owner +CREATE TABLE IF NOT EXISTS account_owner_mappings ( + id SERIAL PRIMARY KEY, + bank_name TEXT NOT NULL, + account_number TEXT NOT NULL, + owner_id INTEGER NOT NULL REFERENCES participants(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(bank_name, account_number) +); diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 0000000..48ecbfd --- /dev/null +++ b/src/app/api/me/route.ts @@ -0,0 +1,8 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; + +export async function GET(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return NextResponse.json(user); +} diff --git a/src/app/api/participants/[id]/balance/route.ts b/src/app/api/participants/[id]/balance/route.ts new file mode 100644 index 0000000..1950b17 --- /dev/null +++ b/src/app/api/participants/[id]/balance/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRaw } from "@/lib/db"; + +interface BalanceRow { + participant_id: number; + name: string; + total_owed: number; + transaction_count: number; +} + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const rows = await queryRaw( + `SELECT ts.participant_id, p.name, + SUM(t.amount * ts.share_percent / 100)::numeric(12,2) as total_owed, + COUNT(*)::int as transaction_count + FROM transaction_splits ts + JOIN transactions t ON t.id = ts.transaction_id + JOIN participants p ON p.id = ts.participant_id + WHERE ts.participant_id = $1 AND ts.settled = false + GROUP BY ts.participant_id, p.name`, + [Number(id)] + ); + + return NextResponse.json( + rows[0] ?? { participant_id: Number(id), total_owed: 0, transaction_count: 0 } + ); +} diff --git a/src/app/api/participants/balances/route.ts b/src/app/api/participants/balances/route.ts new file mode 100644 index 0000000..e8cc9df --- /dev/null +++ b/src/app/api/participants/balances/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getParticipantBalances } from "@/lib/queries"; +import { getCurrentUser } from "@/lib/auth"; + +export async function GET(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const balances = await getParticipantBalances(user.id); + return NextResponse.json(balances); +} diff --git a/src/app/api/participants/route.ts b/src/app/api/participants/route.ts new file mode 100644 index 0000000..48f43c2 --- /dev/null +++ b/src/app/api/participants/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { queryRaw } from "@/lib/db"; + +export async function GET() { + const participants = await prisma.participants.findMany({ + orderBy: { name: "asc" }, + }); + return NextResponse.json(participants); +} + +export async function POST(req: NextRequest) { + const { name, email } = await req.json(); + if (!name?.trim()) { + return NextResponse.json({ error: "name required" }, { status: 400 }); + } + const participant = await prisma.participants.create({ + data: { name: name.trim(), email: email?.trim() || null }, + }); + return NextResponse.json(participant, { status: 201 }); +} diff --git a/src/app/api/shared-transactions/route.ts b/src/app/api/shared-transactions/route.ts new file mode 100644 index 0000000..0ab22b9 --- /dev/null +++ b/src/app/api/shared-transactions/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSharedTransactions } from "@/lib/queries"; +import { getCurrentUser } from "@/lib/auth"; + +export async function GET(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const transactions = await getSharedTransactions(user.id); + return NextResponse.json(transactions); +} diff --git a/src/app/api/splits/settle/route.ts b/src/app/api/splits/settle/route.ts new file mode 100644 index 0000000..1c3e706 --- /dev/null +++ b/src/app/api/splits/settle/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { participant_id, split_ids } = body as { + participant_id?: number; + split_ids?: number[]; + }; + + const now = new Date(); + + if (participant_id) { + const result = await prisma.transaction_splits.updateMany({ + where: { participant_id, settled: false }, + data: { settled: true, settled_at: now }, + }); + return NextResponse.json({ settled: result.count }); + } + + if (split_ids?.length) { + const result = await prisma.transaction_splits.updateMany({ + where: { id: { in: split_ids }, settled: false }, + data: { settled: true, settled_at: now }, + }); + return NextResponse.json({ settled: result.count }); + } + + return NextResponse.json({ error: "participant_id or split_ids required" }, { status: 400 }); +} diff --git a/src/app/api/transactions/[id]/splits/route.ts b/src/app/api/transactions/[id]/splits/route.ts new file mode 100644 index 0000000..f6ccda4 --- /dev/null +++ b/src/app/api/transactions/[id]/splits/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { queryRaw } from "@/lib/db"; + +interface SplitInput { + participant_id: number; + share_percent: number; +} + +interface SplitRow { + id: number; + transaction_id: number; + participant_id: number; + name: string; + share_percent: number; + settled: boolean; + settled_at: string | null; + created_at: string; +} + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const splits = await queryRaw( + `SELECT ts.*, p.name + FROM transaction_splits ts + JOIN participants p ON p.id = ts.participant_id + WHERE ts.transaction_id = $1 + ORDER BY p.name`, + [Number(id)] + ); + return NextResponse.json(splits); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const transactionId = Number(id); + const { splits } = (await req.json()) as { splits: SplitInput[] }; + + if (!splits || !Array.isArray(splits) || splits.length === 0) { + return NextResponse.json({ error: "splits array required" }, { status: 400 }); + } + + const total = splits.reduce((sum, s) => sum + Number(s.share_percent), 0); + if (Math.abs(total - 100) > 0.01) { + return NextResponse.json( + { error: `Shares must sum to 100%, got ${total}%` }, + { status: 400 } + ); + } + + // Replace all splits for this transaction atomically + await prisma.$transaction([ + prisma.transaction_splits.deleteMany({ where: { transaction_id: transactionId } }), + ...splits.map((s) => + prisma.transaction_splits.create({ + data: { + transaction_id: transactionId, + participant_id: s.participant_id, + share_percent: s.share_percent, + }, + }) + ), + ]); + + const result = await queryRaw( + `SELECT ts.*, p.name FROM transaction_splits ts + JOIN participants p ON p.id = ts.participant_id + WHERE ts.transaction_id = $1 ORDER BY p.name`, + [transactionId] + ); + + return NextResponse.json(result); +} diff --git a/src/components/split-modal.tsx b/src/components/split-modal.tsx new file mode 100644 index 0000000..62ae639 --- /dev/null +++ b/src/components/split-modal.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction } from "@/lib/hooks"; + +interface Split { + participant_id: number; + share_percent: number; +} + +interface Props { + transactionId?: number; + transactionIds?: number[]; + amount?: number; + description: string; + onClose: () => void; +} + +export function SplitModal({ transactionId, transactionIds, amount, description, onClose }: Props) { + const isBulk = !!transactionIds && transactionIds.length > 0; + const singleId = transactionId ?? 0; + + const { data: participants } = useParticipants(); + const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId); + const setSplits = useSetSplits(); + const bulkAction = useBulkAction(); + + const [splits, setSplitsState] = useState([]); + const [error, setError] = useState(""); + + // Initialise: bulk always defaults to 100% Me; single loads existing splits + useEffect(() => { + if (!participants || participants.length === 0) return; + const me = participants.find((p) => p.name === "Me"); + if (isBulk) { + if (me) setSplitsState([{ participant_id: me.id, share_percent: 100 }]); + } else if (existingSplits && existingSplits.length > 0) { + setSplitsState( + existingSplits.map((s: { participant_id: number; share_percent: number }) => ({ + participant_id: s.participant_id, + share_percent: Number(s.share_percent), + })) + ); + } else if (me) { + setSplitsState([{ participant_id: me.id, share_percent: 100 }]); + } + }, [existingSplits, participants, isBulk]); + + const total = splits.reduce((sum, s) => sum + s.share_percent, 0); + + const toggleParticipant = (id: number) => { + setSplitsState((prev) => { + const exists = prev.find((s) => s.participant_id === id); + if (exists) { + return prev.filter((s) => s.participant_id !== id); + } + // Add with equal split + const count = prev.length + 1; + const equal = Math.floor(100 / count); + const remainder = 100 - equal * count; + return [ + ...prev.map((s, i) => ({ ...s, share_percent: equal + (i === 0 ? remainder : 0) })), + { participant_id: id, share_percent: equal }, + ]; + }); + }; + + const updateShare = (id: number, value: number) => { + setSplitsState((prev) => + prev.map((s) => (s.participant_id === id ? { ...s, share_percent: value } : s)) + ); + }; + + const splitEvenly = () => { + if (splits.length === 0) return; + const each = Math.floor(100 / splits.length); + const remainder = 100 - each * splits.length; + setSplitsState((prev) => + prev.map((s, i) => ({ ...s, share_percent: each + (i === 0 ? remainder : 0) })) + ); + }; + + const isPending = isBulk ? bulkAction.isPending : setSplits.isPending; + + const handleSave = async () => { + setError(""); + if (Math.abs(total - 100) > 0.01) { + setError(`Shares must sum to 100% (currently ${total.toFixed(1)}%)`); + return; + } + try { + if (isBulk) { + await bulkAction.mutateAsync({ action: "split", ids: transactionIds!, splits }); + } else { + await setSplits.mutateAsync({ transactionId: singleId, splits }); + } + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save splits"); + } + }; + + return ( +
+
e.stopPropagation()} + > +

+ {isBulk ? `Split ${transactionIds!.length} Transactions` : "Split Transaction"} +

+

{description}

+ {!isBulk && amount !== undefined && ( +

+ ${Number(amount).toFixed(2)} +

+ )} + + {/* Participant toggles */} +
+ {participants?.map((p) => { + const split = splits.find((s) => s.participant_id === p.id); + const active = !!split; + return ( +
+ + {p.name} + {active && ( +
+ updateShare(p.id, Number(e.target.value))} + className="w-24 accent-blue-600" + /> + + {split.share_percent}% + + {!isBulk && amount !== undefined && ( + + ${((amount * split.share_percent) / 100).toFixed(2)} + + )} +
+ )} +
+ ); + })} +
+ + {/* Total indicator */} +
+ 0.01 ? "text-red-400" : "text-green-400"}`}> + Total: {total.toFixed(1)}% + + +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..00765f2 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,27 @@ +import { NextRequest } from "next/server"; +import { queryRaw } from "./db"; + +export interface CurrentUser { + id: number; + name: string; + email: string; +} + +export async function getCurrentUser(req: NextRequest): Promise { + const email = req.headers.get("x-forwarded-user"); + + // Dev fallback: no Traefik header → use participant id=1 + if (!email) { + if (process.env.NODE_ENV === "development") { + const rows = await queryRaw(`SELECT id, name, email FROM participants WHERE id = 1`); + return rows[0] || null; + } + return null; + } + + const rows = await queryRaw( + `SELECT id, name, COALESCE(email, '') as email FROM participants WHERE email = $1`, + [email] + ); + return rows[0] || null; +}