From 85e78014075f78819eeece5eaadaeeaa027b2a15 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sat, 14 Mar 2026 21:09:00 +1100 Subject: [PATCH] feat(shared): replace settle buttons with payment ledger - New split_payments table records actual payments between participants - Balance = total split obligations - total payments (splits never marked settled) - Record Payment modal per participant: direction toggle, amount pre-filled with balance, date, notes - Payment history inline on each balance card with +/- display and delete - Per-transaction Settle button removed; Action column removed from shared table - Splits always show the true cost breakdown regardless of payment state --- .../0009_split_payments/migration.sql | 13 + prisma/schema.prisma | 15 + src/app/api/split-payments/route.ts | 88 ++++++ src/app/shared/page.tsx | 273 +++++++++++++----- src/lib/hooks.ts | 54 +++- src/lib/queries.ts | 34 ++- 6 files changed, 383 insertions(+), 94 deletions(-) create mode 100644 prisma/migrations/0009_split_payments/migration.sql create mode 100644 src/app/api/split-payments/route.ts diff --git a/prisma/migrations/0009_split_payments/migration.sql b/prisma/migrations/0009_split_payments/migration.sql new file mode 100644 index 0000000..64f4aab --- /dev/null +++ b/prisma/migrations/0009_split_payments/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE split_payments ( + id SERIAL PRIMARY KEY, + from_participant_id INTEGER NOT NULL REFERENCES participants(id), + to_participant_id INTEGER NOT NULL REFERENCES participants(id), + amount DECIMAL(10,2) NOT NULL CHECK (amount > 0), + payment_date DATE NOT NULL, + notes TEXT, + linked_transaction_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_split_payments_from ON split_payments(from_participant_id); +CREATE INDEX idx_split_payments_to ON split_payments(to_participant_id); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 386b1ab..a60b834 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,8 @@ model participants { created_at DateTime @default(now()) splits transaction_splits[] account_owner_mappings account_owner_mappings[] + payments_sent split_payments[] @relation("payments_from") + payments_received split_payments[] @relation("payments_to") } model account_owner_mappings { @@ -50,6 +52,19 @@ model transaction_splits { @@unique([transaction_id, participant_id]) } +model split_payments { + id Int @id @default(autoincrement()) + from_participant_id Int + to_participant_id Int + amount Decimal @db.Decimal(10, 2) + payment_date DateTime @db.Date + notes String? + linked_transaction_id Int? + created_at DateTime @default(now()) + from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id]) + to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id]) +} + model tags { id Int @id @default(autoincrement()) name String @unique diff --git a/src/app/api/split-payments/route.ts b/src/app/api/split-payments/route.ts new file mode 100644 index 0000000..1fe9d6c --- /dev/null +++ b/src/app/api/split-payments/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; +import { prisma } from "@/lib/db"; + +export async function GET(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const sp = req.nextUrl.searchParams; + const participantId = sp.get("participant_id"); + + // Return payment history between current user and a participant + const rows = await queryRaw<{ + id: number; + from_participant_id: number; + from_name: string; + to_participant_id: number; + to_name: string; + amount: number; + payment_date: string; + notes: string | null; + linked_transaction_id: number | null; + created_at: string; + }>( + `SELECT sp.id, sp.from_participant_id, pf.name as from_name, + sp.to_participant_id, pt.name as to_name, + sp.amount, sp.payment_date, sp.notes, + sp.linked_transaction_id, sp.created_at + FROM split_payments sp + JOIN participants pf ON pf.id = sp.from_participant_id + JOIN participants pt ON pt.id = sp.to_participant_id + WHERE (sp.from_participant_id = $1 OR sp.to_participant_id = $1) + AND (sp.from_participant_id = $2 OR sp.to_participant_id = $2) + ORDER BY sp.payment_date DESC, sp.created_at DESC`, + [user.id, participantId ? Number(participantId) : user.id] + ); + + return NextResponse.json(rows); +} + +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 { + from_participant_id: number; + to_participant_id: number; + amount: number; + payment_date: string; + notes?: string; + linked_transaction_id?: number; + }; + + const { from_participant_id, to_participant_id, amount, payment_date, notes, linked_transaction_id } = body; + + if (!from_participant_id || !to_participant_id || !amount || !payment_date) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + if (amount <= 0) { + return NextResponse.json({ error: "Amount must be positive" }, { status: 400 }); + } + + const payment = await prisma.split_payments.create({ + data: { + from_participant_id, + to_participant_id, + amount, + payment_date: new Date(payment_date), + notes: notes || null, + linked_transaction_id: linked_transaction_id || null, + }, + }); + + return NextResponse.json(payment); +} + +export async function DELETE(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const sp = req.nextUrl.searchParams; + const id = Number(sp.get("id")); + if (!id) return NextResponse.json({ error: "id required" }, { status: 400 }); + + await prisma.split_payments.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/shared/page.tsx b/src/app/shared/page.tsx index 6f64758..2c9bbab 100644 --- a/src/app/shared/page.tsx +++ b/src/app/shared/page.tsx @@ -4,8 +4,12 @@ import { useState } from "react"; import { useSharedTransactions, useParticipantBalances, - useSettleSplits, useCreateParticipant, + useRecordPayment, + usePaymentHistory, + useDeletePayment, + useCurrentUser, + type SplitPayment, } from "@/lib/hooks"; import type { SharedTransactionRow } from "@/lib/queries"; @@ -20,6 +24,7 @@ function formatAmount(n: number, type?: string) { return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted; } +// ── Add Participant ─────────────────────────────────────────────────────────── function AddParticipantForm({ onDone }: { onDone: () => void }) { const [name, setName] = useState(""); const [email, setEmail] = useState(""); @@ -42,32 +47,16 @@ function AddParticipantForm({ onDone }: { onDone: () => void }) {

Add Participant

- setName(e.target.value)} - className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500" - /> - setEmail(e.target.value)} - className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500" - /> - -
@@ -76,69 +65,213 @@ function AddParticipantForm({ onDone }: { onDone: () => void }) { ); } +// ── Record Payment modal ────────────────────────────────────────────────────── +function RecordPaymentModal({ + participant, + currentUserId, + currentBalance, + onClose, +}: { + participant: { id: number; name: string }; + currentUserId: number; + currentBalance: number; // positive = they owe me, negative = I owe them + onClose: () => void; +}) { + const record = useRecordPayment(); + const theyOweMe = currentBalance > 0; + + // Default direction matches the debt direction + const [amount, setAmount] = useState(Math.abs(currentBalance).toFixed(2)); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [notes, setNotes] = useState(""); + // direction: "received" = they paid me, "sent" = I paid them + const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent"); + const [error, setError] = useState(""); + + async function handleSave() { + setError(""); + const amt = parseFloat(amount); + if (!amt || amt <= 0) { setError("Enter a valid amount"); return; } + try { + await record.mutateAsync({ + from_participant_id: direction === "received" ? participant.id : currentUserId, + to_participant_id: direction === "received" ? currentUserId : participant.id, + amount: amt, + payment_date: date, + notes: notes || undefined, + }); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to record payment"); + } + } + + return ( +
+
e.stopPropagation()}> +

Record Payment

+ + {/* Direction toggle */} +
+ + +
+ +
+
+ +
+ $ + setAmount(e.target.value)} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm pl-6" /> +
+
+
+ + setDate(e.target.value)} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" /> +
+
+ +
+ + setNotes(e.target.value)} + placeholder="e.g. Bank transfer, cash" + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} + +// ── Payment history inline ──────────────────────────────────────────────────── +function PaymentHistory({ participantId, currentUserId }: { participantId: number; currentUserId: number }) { + const { data: payments = [], isLoading } = usePaymentHistory(participantId); + const deletePayment = useDeletePayment(); + + if (isLoading) return

Loading payments…

; + if (payments.length === 0) return

No payments recorded

; + + return ( +
+

Payment history

+ {payments.map((p: SplitPayment) => { + const theyPaidMe = p.to_participant_id === currentUserId; + return ( +
+ + {theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)} + + {formatDate(p.payment_date)} + {p.notes && {p.notes}} + +
+ ); + })} +
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── export default function SharedPage() { const { data: transactions = [], isLoading: txLoading } = useSharedTransactions(); const { data: balances = [], isLoading: balLoading } = useParticipantBalances(); - const settle = useSettleSplits(); - const [settling, setSettling] = useState(null); + const { data: me } = useCurrentUser(); const [addingParticipant, setAddingParticipant] = useState(false); - - async function handleSettleParticipant(participantId: number) { - setSettling(participantId); - await settle.mutateAsync({ participant_id: participantId }); - setSettling(null); - } + const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null); + const [showHistory, setShowHistory] = useState(null); return (

Shared Expenses

{!addingParticipant && ( - )}
- {addingParticipant && ( - setAddingParticipant(false)} /> - )} + {addingParticipant && setAddingParticipant(false)} />} - {/* Balance summary */} + {/* Balance cards */}
{balLoading ? (

Loading balances...

+ ) : balances.length === 0 ? ( +

No participants yet.

) : ( balances.map((b) => { const theyOweMe = b.total_owed > 0; const net = Math.abs(b.total_owed); + const settled = net < 0.005; return (

{b.name}

- {theyOweMe ? `${b.unsettled_count} unsettled` : "you owe"} + {settled ? "all square" : theyOweMe ? `owes you` : "you owe"}

-

+

${net.toFixed(2)}

-

{theyOweMe ? "owes you" : "you owe"}

- {theyOweMe && b.unsettled_count > 0 && ( + +
+ +
+ + {showHistory === b.id && me && ( + )}
); @@ -165,18 +298,14 @@ export default function SharedPage() { Description Amount Splits - Action {(transactions as SharedTransactionRow[]).map((tx) => { const splits = Array.isArray(tx.splits) ? tx.splits : []; - const unsettled = splits.filter((s) => !s.settled && s.name !== "Me"); return ( - - {formatDate(tx.transaction_date)} - + {formatDate(tx.transaction_date)}

{tx.effective_merchant || tx.description}

{tx.description}

@@ -187,35 +316,13 @@ export default function SharedPage() {
{splits.map((s) => ( - + {s.name} {s.share_percent}% - {s.settled && } ))}
- - {unsettled.length > 0 && ( - - )} - ); })} @@ -223,6 +330,16 @@ export default function SharedPage() { )}
+ + {/* Payment modal */} + {paymentModal && me && ( + setPaymentModal(null)} + /> + )}
); } diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 639a621..8939a3a 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -257,11 +257,43 @@ export function useSetSplits() { }); } -export function useSettleSplits() { +export interface SplitPayment { + id: number; + from_participant_id: number; + from_name: string; + to_participant_id: number; + to_name: string; + amount: number; + payment_date: string; + notes: string | null; + linked_transaction_id: number | null; + created_at: string; +} + +export function usePaymentHistory(participantId: number | null) { + return useQuery({ + queryKey: ["split-payments", participantId], + queryFn: async () => { + if (!participantId) return []; + const res = await fetch(`/api/split-payments?participant_id=${participantId}`); + return res.json(); + }, + enabled: !!participantId, + }); +} + +export function useRecordPayment() { const qc = useQueryClient(); return useMutation({ - mutationFn: async (body: { participant_id?: number; split_ids?: number[] }) => { - const res = await fetch("/api/splits/settle", { + mutationFn: async (body: { + from_participant_id: number; + to_participant_id: number; + amount: number; + payment_date: string; + notes?: string; + linked_transaction_id?: number; + }) => { + const res = await fetch("/api/split-payments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -269,8 +301,22 @@ export function useSettleSplits() { return res.json(); }, onSuccess: () => { - qc.invalidateQueries({ queryKey: ["shared-transactions"] }); qc.invalidateQueries({ queryKey: ["participant-balances"] }); + qc.invalidateQueries({ queryKey: ["split-payments"] }); + }, + }); +} + +export function useDeletePayment() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: number) => { + const res = await fetch(`/api/split-payments?id=${id}`, { method: "DELETE" }); + return res.json(); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["participant-balances"] }); + qc.invalidateQueries({ queryKey: ["split-payments"] }); }, }); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 7557c7f..d259ce9 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -276,16 +276,17 @@ export interface ParticipantBalance { export async function getParticipantBalances(ownerId: number) { return queryRaw(` SELECT p.id, p.name, - COALESCE(SUM(combined.signed_amount), 0)::numeric(12,2) as total_owed, - COALESCE(SUM(combined.unsettled_count), 0)::int as unsettled_count + COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2) + - COALESCE(payments.net_paid, 0)::numeric(12,2) AS total_owed, + COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count FROM participants p + + -- All split obligations (settled flag ignored — payments are the source of truth) 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 + (CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100 AS signed_amount, + 1 AS split_count FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id LEFT JOIN statements s ON s.id = t.statement_id @@ -295,17 +296,26 @@ export async function getParticipantBalances(ownerId: number) { -- 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 + -((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100) AS signed_amount, + 0 AS split_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 + ) splits ON splits.pid = p.id + + -- Net payments: positive = they paid me, negative = I paid them + LEFT JOIN ( + SELECT + CASE WHEN sp.from_participant_id != $1 THEN sp.from_participant_id ELSE sp.to_participant_id END AS pid, + SUM(CASE WHEN sp.to_participant_id = $1 THEN sp.amount ELSE -sp.amount END) AS net_paid + FROM split_payments sp + WHERE sp.from_participant_id = $1 OR sp.to_participant_id = $1 + GROUP BY pid + ) payments ON payments.pid = p.id + WHERE p.id != $1 - GROUP BY p.id, p.name + GROUP BY p.id, p.name, payments.net_paid ORDER BY p.name `, [ownerId]); }