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
This commit is contained in:
2026-03-14 21:09:00 +11:00
parent 5206388958
commit 85e7801407
6 changed files with 383 additions and 94 deletions
@@ -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);
+15
View File
@@ -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
+88
View File
@@ -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 });
}
+195 -78
View File
@@ -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 }) {
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 space-y-3">
<p className="text-sm font-medium">Add Participant</p>
<div className="flex gap-2">
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => 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"
/>
<input
type="email"
placeholder="Email (optional)"
value={email}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={create.isPending}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
<input type="text" placeholder="Name" value={name} onChange={(e) => 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" />
<input type="email" placeholder="Email (optional)" value={email} onChange={(e) => 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" />
<button type="submit" disabled={create.isPending}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
{create.isPending ? "Adding..." : "Add"}
</button>
<button
type="button"
onClick={onDone}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-lg text-sm"
>
<button type="button" onClick={onDone}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-lg text-sm">
Cancel
</button>
</div>
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-sm mx-4 shadow-2xl p-6 space-y-4"
onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold text-sm text-zinc-300">Record Payment</h3>
{/* Direction toggle */}
<div className="flex rounded-lg overflow-hidden border border-zinc-700 text-sm">
<button
type="button"
onClick={() => setDirection("received")}
className={`flex-1 py-1.5 transition-colors ${direction === "received" ? "bg-emerald-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
>
{participant.name} paid me
</button>
<button
type="button"
onClick={() => setDirection("sent")}
className={`flex-1 py-1.5 transition-colors ${direction === "sent" ? "bg-blue-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
>
I paid {participant.name}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">$</span>
<input type="number" step="0.01" min="0.01" value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm pl-6" />
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Date</label>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
<input value={notes} onChange={(e) => 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" />
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={onClose}
className="flex-1 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
Cancel
</button>
<button type="button" onClick={handleSave} disabled={record.isPending}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
{record.isPending ? "Saving…" : "Record"}
</button>
</div>
</div>
</div>
);
}
// ── Payment history inline ────────────────────────────────────────────────────
function PaymentHistory({ participantId, currentUserId }: { participantId: number; currentUserId: number }) {
const { data: payments = [], isLoading } = usePaymentHistory(participantId);
const deletePayment = useDeletePayment();
if (isLoading) return <p className="text-xs text-zinc-600 mt-2">Loading payments</p>;
if (payments.length === 0) return <p className="text-xs text-zinc-600 italic mt-2">No payments recorded</p>;
return (
<div className="mt-3 space-y-1.5">
<p className="text-xs text-zinc-500 font-medium">Payment history</p>
{payments.map((p: SplitPayment) => {
const theyPaidMe = p.to_participant_id === currentUserId;
return (
<div key={p.id} className="flex items-center gap-2 text-xs">
<span className={`font-mono font-medium ${theyPaidMe ? "text-emerald-400" : "text-blue-400"}`}>
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)}
</span>
<span className="text-zinc-500">{formatDate(p.payment_date)}</span>
{p.notes && <span className="text-zinc-600 truncate flex-1">{p.notes}</span>}
<button
onClick={() => deletePayment.mutate(p.id)}
className="text-zinc-600 hover:text-red-400 leading-none ml-auto flex-shrink-0"
title="Delete payment"
>
×
</button>
</div>
);
})}
</div>
);
}
// ── 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<number | null>(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<number | null>(null);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Shared Expenses</h2>
{!addingParticipant && (
<button
onClick={() => setAddingParticipant(true)}
className="text-sm px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
>
<button onClick={() => setAddingParticipant(true)}
className="text-sm px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg">
+ Add Participant
</button>
)}
</div>
{addingParticipant && (
<AddParticipantForm onDone={() => setAddingParticipant(false)} />
)}
{addingParticipant && <AddParticipantForm onDone={() => setAddingParticipant(false)} />}
{/* Balance summary */}
{/* Balance cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{balLoading ? (
<p className="text-zinc-500 text-sm col-span-3">Loading balances...</p>
) : balances.length === 0 ? (
<p className="text-zinc-500 text-sm col-span-3">No participants yet.</p>
) : (
balances.map((b) => {
const theyOweMe = b.total_owed > 0;
const net = Math.abs(b.total_owed);
const settled = net < 0.005;
return (
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-medium">{b.name}</p>
<p className="text-xs text-zinc-500">
{theyOweMe ? `${b.unsettled_count} unsettled` : "you owe"}
{settled ? "all square" : theyOweMe ? `owes you` : "you owe"}
</p>
</div>
<div className="text-right">
<p className={`text-lg font-semibold ${theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
<p className={`text-lg font-semibold ${settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
${net.toFixed(2)}
</p>
<p className="text-xs text-zinc-500">{theyOweMe ? "owes you" : "you owe"}</p>
</div>
</div>
{theyOweMe && b.unsettled_count > 0 && (
<div className="flex gap-2">
<button
onClick={() => handleSettleParticipant(b.id)}
disabled={settling === b.id}
className="w-full py-1.5 text-xs font-medium bg-emerald-700 hover:bg-emerald-600 text-white rounded-lg disabled:opacity-50"
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
>
{settling === b.id ? "Settling..." : "Mark All Settled"}
Record Payment
</button>
<button
onClick={() => setShowHistory(showHistory === b.id ? null : b.id)}
className={`px-3 py-1.5 text-xs rounded-lg ${showHistory === b.id ? "bg-zinc-700 text-white" : "bg-zinc-800 text-zinc-500 hover:text-zinc-300"}`}
>
History
</button>
</div>
{showHistory === b.id && me && (
<PaymentHistory participantId={b.id} currentUserId={me.id} />
)}
</div>
);
@@ -165,18 +298,14 @@ export default function SharedPage() {
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Description</th>
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Amount</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
<th className="px-4 py-2 text-xs text-zinc-500 font-medium">Action</th>
</tr>
</thead>
<tbody>
{(transactions as SharedTransactionRow[]).map((tx) => {
const splits = Array.isArray(tx.splits) ? tx.splits : [];
const unsettled = splits.filter((s) => !s.settled && s.name !== "Me");
return (
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
{formatDate(tx.transaction_date)}
</td>
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
<td className="px-4 py-3">
<p className="font-medium truncate max-w-48">{tx.effective_merchant || tx.description}</p>
<p className="text-xs text-zinc-500 truncate max-w-48">{tx.description}</p>
@@ -187,35 +316,13 @@ export default function SharedPage() {
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{splits.map((s) => (
<span
key={s.participant_id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
s.settled
? "bg-zinc-800 text-zinc-500"
: "bg-amber-900/40 text-amber-300"
}`}
>
<span key={s.participant_id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
{s.name} {s.share_percent}%
{s.settled && <span className="text-emerald-500"></span>}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-center">
{unsettled.length > 0 && (
<button
onClick={() =>
settle.mutateAsync({
split_ids: unsettled.map((s) => (s as unknown as { split_id: number }).split_id),
})
}
disabled={settle.isPending}
className="text-xs px-2 py-1 bg-emerald-800 hover:bg-emerald-700 text-white rounded disabled:opacity-50"
>
Settle
</button>
)}
</td>
</tr>
);
})}
@@ -223,6 +330,16 @@ export default function SharedPage() {
</table>
)}
</div>
{/* Payment modal */}
{paymentModal && me && (
<RecordPaymentModal
participant={{ id: paymentModal.id, name: paymentModal.name }}
currentUserId={me.id}
currentBalance={paymentModal.balance}
onClose={() => setPaymentModal(null)}
/>
)}
</div>
);
}
+50 -4
View File
@@ -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<SplitPayment[]>({
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"] });
},
});
}
+22 -12
View File
@@ -276,16 +276,17 @@ export interface ParticipantBalance {
export async function getParticipantBalances(ownerId: number) {
return queryRaw<ParticipantBalance>(`
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]);
}