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 }) {