From 93450f7caa879ab74769accfb1401ef3497f0926 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 8 Mar 2026 16:28:03 +1100 Subject: [PATCH] =?UTF-8?q?feat(finance):=20Phase=204=20=E2=80=94=20Tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tags table: name, color; transaction_tags junction table - GET/POST /api/tags; DELETE /api/tags/:id - POST/DELETE /api/transactions/:id/tags for per-transaction tagging - Bulk tag/untag via /api/transactions/bulk (action: tag/untag) - Tags returned inline with transaction list via LATERAL join - Tag filter on Transactions page - Bulk "Tag as..." in bulk action bar - Tag pills + "+" picker on each transaction row - /tags page: create with color picker, list with counts, delete --- prisma/migrations/0004_tags/migration.sql | 13 ++ prisma/schema.prisma | 50 +++++ src/app/api/tags/[id]/route.ts | 8 + src/app/api/tags/route.ts | 20 ++ src/app/api/transactions/[id]/tags/route.ts | 24 +++ src/app/api/transactions/bulk/route.ts | 43 +++- src/app/tags/page.tsx | 104 ++++++++- src/app/transactions/page.tsx | 91 +++++++- src/components/tag-picker.tsx | 74 +++++++ src/lib/hooks.ts | 226 +++++++++++++++++++- src/lib/queries.ts | 138 ++++++++++-- 11 files changed, 770 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/0004_tags/migration.sql create mode 100644 src/app/api/tags/[id]/route.ts create mode 100644 src/app/api/tags/route.ts create mode 100644 src/app/api/transactions/[id]/tags/route.ts create mode 100644 src/components/tag-picker.tsx diff --git a/prisma/migrations/0004_tags/migration.sql b/prisma/migrations/0004_tags/migration.sql new file mode 100644 index 0000000..c30866c --- /dev/null +++ b/prisma/migrations/0004_tags/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL DEFAULT '#6366f1', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS transaction_tags ( + transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (transaction_id, tag_id) +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c99912..fc06f66 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,3 +15,53 @@ model transaction_overrides { notes String? updated_at DateTime @default(now()) @updatedAt } + +model participants { + id Int @id @default(autoincrement()) + name String @unique + email String? @unique + created_at DateTime @default(now()) + splits transaction_splits[] + account_owner_mappings account_owner_mappings[] +} + +model account_owner_mappings { + id Int @id @default(autoincrement()) + bank_name String + account_number String + owner_id Int + created_at DateTime @default(now()) + owner participants @relation(fields: [owner_id], references: [id]) + + @@unique([bank_name, account_number]) +} + +model transaction_splits { + id Int @id @default(autoincrement()) + transaction_id Int + participant_id Int + share_percent Decimal @db.Decimal(5, 2) + settled Boolean @default(false) + settled_at DateTime? + created_at DateTime @default(now()) + participant participants @relation(fields: [participant_id], references: [id]) + + @@unique([transaction_id, participant_id]) +} + +model tags { + id Int @id @default(autoincrement()) + name String @unique + color String @default("#6366f1") + created_at DateTime @default(now()) + transaction_tags transaction_tags[] +} + +model transaction_tags { + transaction_id Int + tag_id Int + created_at DateTime @default(now()) + tag tags @relation(fields: [tag_id], references: [id], onDelete: Cascade) + + @@id([transaction_id, tag_id]) +} diff --git a/src/app/api/tags/[id]/route.ts b/src/app/api/tags/[id]/route.ts new file mode 100644 index 0000000..2cc9e78 --- /dev/null +++ b/src/app/api/tags/[id]/route.ts @@ -0,0 +1,8 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRaw } from "@/lib/db"; + +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await queryRaw(`DELETE FROM tags WHERE id = $1`, [Number(id)]); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts new file mode 100644 index 0000000..d3d8b7a --- /dev/null +++ b/src/app/api/tags/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getTags } from "@/lib/queries"; +import { queryRaw } from "@/lib/db"; + +export async function GET() { + const tags = await getTags(); + return NextResponse.json(tags); +} + +export async function POST(req: NextRequest) { + const { name, color } = await req.json(); + if (!name?.trim()) { + return NextResponse.json({ error: "name required" }, { status: 400 }); + } + const rows = await queryRaw<{ id: number; name: string; color: string }>( + `INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`, + [name.trim(), color || "#6366f1"] + ); + return NextResponse.json(rows[0], { status: 201 }); +} diff --git a/src/app/api/transactions/[id]/tags/route.ts b/src/app/api/transactions/[id]/tags/route.ts new file mode 100644 index 0000000..88c70e3 --- /dev/null +++ b/src/app/api/transactions/[id]/tags/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRaw } from "@/lib/db"; + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { tag_id } = await req.json(); + if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 }); + await queryRaw( + `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [Number(id), Number(tag_id)] + ); + return NextResponse.json({ ok: true }); +} + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { tag_id } = await req.json(); + if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 }); + await queryRaw( + `DELETE FROM transaction_tags WHERE transaction_id = $1 AND tag_id = $2`, + [Number(id), Number(tag_id)] + ); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/transactions/bulk/route.ts b/src/app/api/transactions/bulk/route.ts index 2c5605b..cef9cd3 100644 --- a/src/app/api/transactions/bulk/route.ts +++ b/src/app/api/transactions/bulk/route.ts @@ -1,13 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; +import { prisma, queryRaw } from "@/lib/db"; export async function POST(req: NextRequest) { const body = await req.json(); - const { action, ids, category, merchant_normalized } = body as { + const { action, ids, category, merchant_normalized, splits, tag_id } = body as { action: string; ids: number[]; category?: string; merchant_normalized?: string; + splits?: { participant_id: number; share_percent: number }[]; + tag_id?: number; }; if (!ids || !Array.isArray(ids) || ids.length === 0) { @@ -38,5 +40,42 @@ export async function POST(req: NextRequest) { return NextResponse.json({ updated: ids.length }); } + if (action === "split" && Array.isArray(splits) && splits.length > 0) { + const total = splits.reduce((s, x) => s + x.share_percent, 0); + if (Math.abs(total - 100) > 0.01) { + return NextResponse.json({ error: "Shares must sum to 100%" }, { status: 400 }); + } + await prisma.$transaction( + ids.flatMap((id) => [ + prisma.transaction_splits.deleteMany({ where: { transaction_id: id } }), + prisma.transaction_splits.createMany({ + data: splits.map((s) => ({ + transaction_id: id, + participant_id: s.participant_id, + share_percent: s.share_percent, + })), + }), + ]) + ); + return NextResponse.json({ updated: ids.length }); + } + + if ((action === "tag" || action === "untag") && tag_id) { + if (action === "tag") { + for (const id of ids) { + await queryRaw( + `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [id, tag_id] + ); + } + } else { + await queryRaw( + `DELETE FROM transaction_tags WHERE transaction_id = ANY($1::int[]) AND tag_id = $2`, + [ids, tag_id] + ); + } + return NextResponse.json({ updated: ids.length }); + } + return NextResponse.json({ error: "Invalid action" }, { status: 400 }); } diff --git a/src/app/tags/page.tsx b/src/app/tags/page.tsx index 7ab85a2..24aca14 100644 --- a/src/app/tags/page.tsx +++ b/src/app/tags/page.tsx @@ -1,8 +1,110 @@ +"use client"; + +import { useState } from "react"; +import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks"; + +const PRESET_COLORS = [ + "#6366f1", // indigo + "#8b5cf6", // violet + "#ec4899", // pink + "#ef4444", // red + "#f97316", // orange + "#eab308", // yellow + "#22c55e", // green + "#14b8a6", // teal + "#3b82f6", // blue + "#6b7280", // gray +]; + export default function TagsPage() { + const { data: tags, isLoading } = useTags(); + const createTag = useCreateTag(); + const deleteTag = useDeleteTag(); + + const [name, setName] = useState(""); + const [color, setColor] = useState(PRESET_COLORS[0]); + const [error, setError] = useState(""); + + const handleCreate = async () => { + if (!name.trim()) return; + setError(""); + try { + await createTag.mutateAsync({ name: name.trim(), color }); + setName(""); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to create tag"); + } + }; + return (

Tags

-

Coming soon - tag transactions for trips, projects, and more.

+ + {/* Create form */} +
+

New Tag

+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + className="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48" + /> +
+ {PRESET_COLORS.map((c) => ( +
+ +
+ {error &&

{error}

} +
+ + {/* Tags list */} + {isLoading ? ( +

Loading...

+ ) : !tags?.length ? ( +

No tags yet. Create one above.

+ ) : ( +
+ {tags.map((tag) => ( +
+
+ + {tag.name} + + {tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""} + +
+ +
+ ))} +
+ )}
); } diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx index a86554a..b56cb09 100644 --- a/src/app/transactions/page.tsx +++ b/src/app/transactions/page.tsx @@ -1,8 +1,10 @@ "use client"; import { useState, useCallback } from "react"; -import { useTransactions, useBanks, useUpdateTransaction, useBulkAction } from "@/lib/hooks"; +import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags } from "@/lib/hooks"; import { CATEGORIES, formatCategory } from "@/lib/categories"; +import { SplitModal } from "@/components/split-modal"; +import { TagPicker } from "@/components/tag-picker"; function formatDate(d: string) { return new Date(d).toLocaleDateString("en-AU", { @@ -102,6 +104,7 @@ export default function TransactionsPage() { category: "", bank_name: "", search: "", + tag_id: "", sort_by: "transaction_date", sort_dir: "desc", limit: 50, @@ -109,9 +112,12 @@ export default function TransactionsPage() { }); const [selected, setSelected] = useState>(new Set()); const [bulkCategory, setBulkCategory] = useState(""); + const [bulkTagId, setBulkTagId] = useState(""); + const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string } | null>(null); const { data, isLoading } = useTransactions(filters); const { data: banks } = useBanks(); + const { data: tags } = useTags(); const updateTxn = useUpdateTransaction(); const bulkAction = useBulkAction(); @@ -196,6 +202,16 @@ export default function TransactionsPage() { ))} + {/* Bulk action bar */} @@ -224,6 +240,39 @@ export default function TransactionsPage() { > Apply + + + + )) )} @@ -326,6 +400,17 @@ export default function TransactionsPage() { + {/* Split modal */} + {splitModal && ( + { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }} + /> + )} + {/* Pagination */} {data && data.total > filters.limit && (
diff --git a/src/components/tag-picker.tsx b/src/components/tag-picker.tsx new file mode 100644 index 0000000..ebe99b7 --- /dev/null +++ b/src/components/tag-picker.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { useTags, useAddTransactionTag, useRemoveTransactionTag } from "@/lib/hooks"; +import type { TagRow } from "@/lib/queries"; + +interface Props { + transactionId: number; + currentTags: TagRow[]; +} + +export function TagPicker({ transactionId, currentTags }: Props) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const { data: allTags } = useTags(); + const addTag = useAddTransactionTag(); + const removeTag = useRemoveTransactionTag(); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const currentIds = new Set(currentTags.map((t) => t.id)); + + const toggle = (tag: TagRow) => { + if (currentIds.has(tag.id)) { + removeTag.mutate({ transactionId, tagId: tag.id }); + } else { + addTag.mutate({ transactionId, tagId: tag.id }); + } + }; + + return ( +
+ + {open && ( +
+ {!allTags?.length ? ( +

No tags yet — create on Tags page

+ ) : ( + allTags.map((tag) => { + const active = currentIds.has(tag.id); + return ( + + ); + }) + )} +
+ )} +
+ ); +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 7274aec..7d3f8e8 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -1,7 +1,8 @@ "use client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import type { TransactionRow, StatementRow } from "./queries"; +import type { TransactionRow, StatementRow, TagRow } from "./queries"; +import type { CurrentUser } from "./auth"; interface TransactionsResponse { data: TransactionRow[]; @@ -17,6 +18,7 @@ interface TransactionFilters { bank_name?: string; search?: string; statement_id?: string; + tag_id?: string; sort_by?: string; sort_dir?: string; limit?: number; @@ -115,16 +117,238 @@ export function useBulkAction() { ids: number[]; category?: string; merchant_normalized?: string; + splits?: { participant_id: number; share_percent: number }[]; + tag_id?: number; }) => { const res = await fetch("/api/transactions/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Bulk action failed"); + } + return res.json(); + }, + onSuccess: (_data, variables) => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + if (variables.action === "split") { + qc.invalidateQueries({ queryKey: ["splits"] }); + qc.invalidateQueries({ queryKey: ["shared-transactions"] }); + qc.invalidateQueries({ queryKey: ["participant-balances"] }); + } + if (variables.action === "tag" || variables.action === "untag") { + qc.invalidateQueries({ queryKey: ["tags"] }); + } + }, + }); +} + +export function useParticipants() { + return useQuery<{ id: number; name: string; created_at: string }[]>({ + queryKey: ["participants"], + queryFn: async () => { + const res = await fetch("/api/participants"); + return res.json(); + }, + }); +} + +export function useParticipantBalances() { + return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({ + queryKey: ["participant-balances"], + queryFn: async () => { + const res = await fetch("/api/participants/balances"); + return res.json(); + }, + }); +} + +export function useSharedTransactions() { + return useQuery({ + queryKey: ["shared-transactions"], + queryFn: async () => { + const res = await fetch("/api/shared-transactions"); + return res.json(); + }, + }); +} + +export function useTransactionSplits(transactionId: number) { + return useQuery({ + queryKey: ["splits", transactionId], + queryFn: async () => { + const res = await fetch(`/api/transactions/${transactionId}/splits`); + return res.json(); + }, + }); +} + +export function useSetSplits() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ + transactionId, + splits, + }: { + transactionId: number; + splits: { participant_id: number; share_percent: number }[]; + }) => { + const res = await fetch(`/api/transactions/${transactionId}/splits`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ splits }), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to set splits"); + } + return res.json(); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["splits"] }); + qc.invalidateQueries({ queryKey: ["shared-transactions"] }); + qc.invalidateQueries({ queryKey: ["participant-balances"] }); + }, + }); +} + +export function useSettleSplits() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { participant_id?: number; split_ids?: number[] }) => { + const res = await fetch("/api/splits/settle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); return res.json(); }, onSuccess: () => { + qc.invalidateQueries({ queryKey: ["shared-transactions"] }); + qc.invalidateQueries({ queryKey: ["participant-balances"] }); + }, + }); +} + +export function useCurrentUser() { + return useQuery({ + queryKey: ["me"], + queryFn: async () => { + const res = await fetch("/api/me"); + if (!res.ok) throw new Error("Not authenticated"); + return res.json(); + }, + }); +} + +export function useUpdateStatement() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, owner_id }: { id: number; owner_id: number }) => { + const res = await fetch(`/api/statements/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ owner_id }), + }); + return res.json(); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["statements"] }); qc.invalidateQueries({ queryKey: ["transactions"] }); }, }); } + +export function useTags() { + return useQuery<(TagRow & { transaction_count: number })[]>({ + queryKey: ["tags"], + queryFn: async () => { + const res = await fetch("/api/tags"); + return res.json(); + }, + }); +} + +export function useCreateTag() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ name, color }: { name: string; color?: string }) => { + const res = await fetch("/api/tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, color }), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to create tag"); + } + return res.json(); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["tags"] }), + }); +} + +export function useDeleteTag() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: number) => { + await fetch(`/api/tags/${id}`, { method: "DELETE" }); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["tags"] }); + qc.invalidateQueries({ queryKey: ["transactions"] }); + }, + }); +} + +export function useAddTransactionTag() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ transactionId, tagId }: { transactionId: number; tagId: number }) => { + await fetch(`/api/transactions/${transactionId}/tags`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tag_id: tagId }), + }); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }), + }); +} + +export function useRemoveTransactionTag() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ transactionId, tagId }: { transactionId: number; tagId: number }) => { + await fetch(`/api/transactions/${transactionId}/tags`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tag_id: tagId }), + }); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }), + }); +} + +export function useCreateParticipant() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ name, email }: { name: string; email?: string }) => { + const res = await fetch("/api/participants", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, email }), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to create participant"); + } + return res.json(); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["participants"] }); + qc.invalidateQueries({ queryKey: ["participant-balances"] }); + }, + }); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 018c722..a0ac789 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,5 +1,11 @@ import { queryRaw } from "./db"; +export interface TagRow { + id: number; + name: string; + color: string; +} + export interface TransactionRow { id: number; statement_id: number; @@ -23,6 +29,10 @@ export interface TransactionRow { effective_merchant: string; // statement context bank_name: string; + owner_id: number; + owner_name: string; + // tags + tags: TagRow[]; } export interface StatementRow { @@ -31,6 +41,7 @@ export interface StatementRow { card_name: string | null; account_number: string; account_type: string | null; + account_holder_name: string | null; billing_start_date: string | null; billing_end_date: string | null; total_amount_due: number; @@ -45,6 +56,8 @@ export interface StatementRow { credit_limit: number | null; currency: string; tier_used: string | null; + owner_id: number; + owner_name: string; created_at: string; transaction_count: number; } @@ -56,16 +69,17 @@ interface TransactionFilters { bank_name?: string; search?: string; statement_id?: string; + tag_id?: string; sort_by?: string; sort_dir?: string; limit?: number; offset?: number; } -export async function getTransactions(filters: TransactionFilters) { - const conditions: string[] = []; - const params: unknown[] = []; - let paramIdx = 1; +export async function getTransactions(ownerId: number, filters: TransactionFilters) { + const conditions: string[] = [`s.owner_id = $1`]; + const params: unknown[] = [ownerId]; + let paramIdx = 2; if (filters.from) { conditions.push(`t.transaction_date >= $${paramIdx++}`); @@ -92,15 +106,18 @@ export async function getTransactions(filters: TransactionFilters) { conditions.push(`t.statement_id = $${paramIdx++}`); params.push(Number(filters.statement_id)); } + if (filters.tag_id) { + conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`); + params.push(Number(filters.tag_id)); + } - const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const where = `WHERE ${conditions.join(" AND ")}`; const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date"; const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC"; const limit = filters.limit || 50; const offset = filters.offset || 0; - // Count query const countSql = ` SELECT COUNT(*)::int as total FROM transactions t @@ -111,23 +128,35 @@ export async function getTransactions(filters: TransactionFilters) { const countResult = await queryRaw<{ total: number }>(countSql, params); const total = countResult[0]?.total || 0; - // Data query const dataSql = ` SELECT t.*, o.category_override, o.merchant_normalized as merchant_override, o.notes, COALESCE(o.category_override, t.category) as effective_category, COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, - s.bank_name + s.bank_name, s.owner_id, + p.name as owner_name, + txn_tags.tags FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id JOIN statements s ON s.id = t.statement_id + LEFT JOIN participants p ON p.id = s.owner_id + LEFT JOIN LATERAL ( + SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags + FROM transaction_tags tt + JOIN tags tg ON tg.id = tt.tag_id + WHERE tt.transaction_id = t.id + ) txn_tags ON true ${where} ORDER BY ${sortCol} ${sortDir}, t.row_index ASC LIMIT $${paramIdx++} OFFSET $${paramIdx++} `; params.push(limit, offset); - const data = await queryRaw(dataSql, params); + const raw = await queryRaw(dataSql, params); + const data = raw.map((r) => ({ + ...r, + tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []), + })) as TransactionRow[]; return { data, total, limit, offset }; } @@ -138,31 +167,38 @@ export async function getTransactionById(id: number) { o.category_override, o.merchant_normalized as merchant_override, o.notes, COALESCE(o.category_override, t.category) as effective_category, COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, - s.bank_name + s.bank_name, s.owner_id, + p.name as owner_name FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id JOIN statements s ON s.id = t.statement_id + LEFT JOIN participants p ON p.id = s.owner_id WHERE t.id = $1 `; const rows = await queryRaw(sql, [id]); return rows[0] || null; } -export async function getStatements() { +export async function getStatements(ownerId: number) { const sql = ` SELECT s.*, - (SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count + (SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count, + p.name as owner_name FROM statements s + LEFT JOIN participants p ON p.id = s.owner_id + WHERE s.owner_id = $1 ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC `; - return queryRaw(sql); + return queryRaw(sql, [ownerId]); } export async function getStatementById(id: number) { const sql = ` SELECT s.*, - (SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count + (SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count, + p.name as owner_name FROM statements s + LEFT JOIN participants p ON p.id = s.owner_id WHERE s.id = $1 `; const rows = await queryRaw(sql, [id]); @@ -185,3 +221,77 @@ export async function getBankNames() { const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`; return queryRaw<{ bank_name: string }>(sql); } + +export interface ParticipantBalance { + id: number; + name: string; + total_owed: number; + unsettled_count: number; +} + +export async function getParticipantBalances(ownerId: number) { + return queryRaw(` + SELECT p.id, p.name, + COALESCE(SUM(CASE WHEN ts.settled = false THEN t.amount * ts.share_percent / 100 ELSE 0 END), 0)::numeric(12,2) as total_owed, + COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count + FROM participants p + LEFT JOIN transaction_splits ts ON ts.participant_id = p.id + LEFT JOIN transactions t ON t.id = ts.transaction_id + LEFT JOIN statements s ON s.id = t.statement_id + WHERE (s.owner_id = $1 OR s.id IS NULL) + GROUP BY p.id, p.name + ORDER BY p.name + `, [ownerId]); +} + +export interface SharedTransactionRow extends TransactionRow { + splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[]; +} + +export async function getTags() { + return queryRaw(` + SELECT tg.id, tg.name, tg.color, + COUNT(tt.transaction_id)::int as transaction_count + FROM tags tg + LEFT JOIN transaction_tags tt ON tt.tag_id = tg.id + GROUP BY tg.id + ORDER BY tg.name + `); +} + +export async function getSharedTransactions(ownerId: number) { + const rows = await queryRaw(` + SELECT t.*, + o.category_override, o.merchant_normalized as merchant_override, o.notes, + COALESCE(o.category_override, t.category) as effective_category, + COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, + s.bank_name, s.owner_id, + p_owner.name as owner_name, + json_agg(json_build_object( + 'split_id', ts.id, + 'participant_id', ts.participant_id, + 'name', p.name, + 'share_percent', ts.share_percent, + 'settled', ts.settled + ) ORDER BY p.name) as split_data + FROM transactions t + JOIN transaction_splits ts ON ts.transaction_id = t.id + JOIN participants p ON p.id = ts.participant_id + LEFT JOIN transaction_overrides o ON o.transaction_id = t.id + JOIN statements s ON s.id = t.statement_id + LEFT JOIN participants p_owner ON p_owner.id = s.owner_id + WHERE s.owner_id = $1 + AND EXISTS ( + SELECT 1 FROM transaction_splits ts2 + JOIN participants p2 ON p2.id = ts2.participant_id + WHERE ts2.transaction_id = t.id AND p2.name != 'Me' + ) + GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name + ORDER BY t.transaction_date DESC + `, [ownerId]); + + return rows.map((r) => ({ + ...r, + splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data, + })); +}