From d53d3106f2f885b836aa9fb95dc0efebbd833b55 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sat, 14 Mar 2026 21:30:33 +1100 Subject: [PATCH] fix(shared): tag filter SQL precedence, balance cards filter by tag --- src/app/api/participants/balances/route.ts | 4 +++- src/app/shared/page.tsx | 2 +- src/lib/hooks.ts | 7 +++--- src/lib/queries.ts | 28 +++++++++++++++------- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/app/api/participants/balances/route.ts b/src/app/api/participants/balances/route.ts index e8cc9df..9987d09 100644 --- a/src/app/api/participants/balances/route.ts +++ b/src/app/api/participants/balances/route.ts @@ -6,6 +6,8 @@ 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); + const tagParam = req.nextUrl.searchParams.get("tag_ids"); + const tagIds = tagParam ? tagParam.split(",").map(Number).filter(Boolean) : undefined; + const balances = await getParticipantBalances(user.id, tagIds); return NextResponse.json(balances); } diff --git a/src/app/shared/page.tsx b/src/app/shared/page.tsx index b9e465a..1ecea8d 100644 --- a/src/app/shared/page.tsx +++ b/src/app/shared/page.tsx @@ -259,7 +259,7 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe export default function SharedPage() { const [tagIds, setTagIds] = useState([]); const { data: transactions = [], isLoading: txLoading } = useSharedTransactions(tagIds); - const { data: balances = [], isLoading: balLoading } = useParticipantBalances(); + const { data: balances = [], isLoading: balLoading } = useParticipantBalances(tagIds); const { data: me } = useCurrentUser(); const [addingParticipant, setAddingParticipant] = useState(false); const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null); diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 3237058..698bafb 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -198,11 +198,12 @@ export function useParticipants() { }); } -export function useParticipantBalances() { +export function useParticipantBalances(tagIds?: string[]) { return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({ - queryKey: ["participant-balances"], + queryKey: ["participant-balances", tagIds], queryFn: async () => { - const res = await fetch("/api/participants/balances"); + const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : ""; + const res = await fetch(`/api/participants/balances${params}`); return res.json(); }, }); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 2bce73b..e43e72b 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -273,7 +273,14 @@ export interface ParticipantBalance { unsettled_count: number; } -export async function getParticipantBalances(ownerId: number) { +export async function getParticipantBalances(ownerId: number, tagIds?: number[]) { + const params: unknown[] = [ownerId]; + let tagFilter = ""; + if (tagIds?.length) { + params.push(tagIds); + tagFilter = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`; + } + return queryRaw(` SELECT p.id, p.name, COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2) @@ -281,7 +288,6 @@ export async function getParticipantBalances(ownerId: number) { 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, @@ -291,6 +297,7 @@ export async function getParticipantBalances(ownerId: number) { 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 + ${tagFilter} UNION ALL @@ -302,9 +309,10 @@ export async function getParticipantBalances(ownerId: number) { 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 + ${tagFilter} ) splits ON splits.pid = p.id - -- Net payments: positive = they paid me, negative = I paid them + -- Net payments always unfiltered (payments are against total debt, not per-tag) LEFT JOIN ( SELECT CASE WHEN sp.from_participant_id != $1 THEN sp.from_participant_id ELSE sp.to_participant_id END AS pid, @@ -317,7 +325,7 @@ export async function getParticipantBalances(ownerId: number) { WHERE p.id != $1 GROUP BY p.id, p.name, payments.net_paid ORDER BY p.name - `, [ownerId]); + `, params); } export interface SharedTransactionRow extends TransactionRow { @@ -365,11 +373,13 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[]) 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 ( - 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 ( - 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) + ( + 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 ( + 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) + ) ) ${tagClause} GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name