"use client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { TransactionRow, StatementRow, TagRow } from "./queries"; import type { CurrentUser } from "./auth"; interface TransactionsResponse { data: TransactionRow[]; total: number; limit: number; offset: number; } interface TransactionFilters { from?: string; to?: string; category?: string; bank_name?: string; search?: string; statement_id?: string; tag_id?: string; sort_by?: string; sort_dir?: string; limit?: number; offset?: number; } function buildParams(filters: TransactionFilters): string { const params = new URLSearchParams(); Object.entries(filters).forEach(([key, val]) => { if (val !== undefined && val !== "") params.set(key, String(val)); }); return params.toString(); } export function useTransactions(filters: TransactionFilters) { return useQuery({ queryKey: ["transactions", filters], queryFn: async () => { const res = await fetch(`/api/transactions?${buildParams(filters)}`); return res.json(); }, }); } export function useTransaction(id: number) { return useQuery({ queryKey: ["transaction", id], queryFn: async () => { const res = await fetch(`/api/transactions/${id}`); return res.json(); }, }); } export function useStatements() { return useQuery({ queryKey: ["statements"], queryFn: async () => { const res = await fetch("/api/statements"); return res.json(); }, }); } export function useStatement(id: number) { return useQuery({ queryKey: ["statement", id], queryFn: async () => { const res = await fetch(`/api/statements/${id}`); return res.json(); }, }); } export function useBanks() { return useQuery({ queryKey: ["banks"], queryFn: async () => { const res = await fetch("/api/merchants?type=banks"); return res.json(); }, }); } export function useCreateTransaction() { const qc = useQueryClient(); return useMutation({ mutationFn: async (data: { date: string; description: string; amount: number; transaction_type?: string; merchant_normalized?: string; category?: string; splits?: { participant_id: number; share_percent: number }[]; }) => { const res = await fetch("/api/transactions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!res.ok) throw new Error((await res.json()).error || "Failed to create transaction"); return res.json(); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["splits"] }); qc.invalidateQueries({ queryKey: ["shared-transactions"] }); qc.invalidateQueries({ queryKey: ["participant-balances"] }); }, }); } export function useUpdateTransaction() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ id, ...data }: { id: number; category?: string; merchant_normalized?: string; notes?: string; transaction_type?: string; my_share_percent?: number | null; description?: string; amount?: number; transaction_date?: string; }) => { const res = await fetch(`/api/transactions/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); return res.json(); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["transaction"] }); qc.invalidateQueries({ queryKey: ["analytics"] }); }, }); } export function useBulkAction() { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: { action: string; 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"] }); }, }); } // --- Rules --- export interface RuleRow { id: number; name: string; conditions: { field: string; operator: string; value: string }[]; actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: { participant_id: number; share_percent: number }[] }; enabled: boolean; priority: number; created_at: string; } export function useRules() { return useQuery({ queryKey: ["rules"], queryFn: async () => { const res = await fetch("/api/rules"); return res.json(); }, }); } export function useCreateRule() { const qc = useQueryClient(); return useMutation({ mutationFn: async (data: Omit) => { const res = await fetch("/api/rules", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!res.ok) throw new Error("Failed to create rule"); return res.json(); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }), }); } export function useUpdateRule() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ id, ...data }: Partial & { id: number }) => { const res = await fetch(`/api/rules/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!res.ok) throw new Error("Failed to update rule"); return res.json(); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }), }); } export function useDeleteRule() { const qc = useQueryClient(); return useMutation({ mutationFn: async (id: number) => { await fetch(`/api/rules/${id}`, { method: "DELETE" }); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }), }); } export function useApplyRules() { const qc = useQueryClient(); return useMutation({ mutationFn: async (splitFrom?: string) => { const res = await fetch("/api/rules/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ splitFrom: splitFrom || null }), }); if (!res.ok) throw new Error("Failed to apply rules"); return res.json() as Promise<{ id: number; matched: number; transactions_affected: number }>; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["rules"] }); qc.invalidateQueries({ queryKey: ["rule-runs"] }); }, }); } export interface RuleRun { id: number; applied_at: string; split_from: string | null; matched: number; transactions_affected: number; reverted_at: string | null; } export function useRuleRuns() { return useQuery({ queryKey: ["rule-runs"], queryFn: async () => { const res = await fetch("/api/rules/apply"); if (!res.ok) throw new Error("Failed to fetch rule runs"); return res.json() as Promise; }, }); } export function useRevertRuleRun() { const qc = useQueryClient(); return useMutation({ mutationFn: async (runId: number) => { const res = await fetch(`/api/rules/apply/${runId}/revert`, { method: "POST" }); if (!res.ok) throw new Error("Failed to revert run"); return res.json() as Promise<{ reverted: number }>; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["rule-runs"] }); }, }); } // --- Budgets & Analytics --- export interface BudgetRow { id: number; category: string; month: string; amount_limit: number; } export interface MonthlyAnalyticsRow { category: string; spent: Record; budget: Record; txCount: Record; } export interface MonthlyAnalytics { months: string[]; rows: MonthlyAnalyticsRow[]; income: Record; investments: Record; totals: Record; } export function useBudgets(month: string) { return useQuery({ queryKey: ["budgets", month], queryFn: async () => { const res = await fetch(`/api/budgets?month=${month}`); return res.json(); }, }); } export function useUpsertBudget() { const qc = useQueryClient(); return useMutation({ mutationFn: async (data: { category: string; month: string; amount_limit: number }) => { const res = await fetch("/api/budgets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!res.ok) throw new Error("Failed to save budget"); return res.json(); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }), }); } export function useDeleteBudget() { const qc = useQueryClient(); return useMutation({ mutationFn: async (id: number) => { await fetch(`/api/budgets/${id}`, { method: "DELETE" }); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }), }); } export function useMonthlyAnalytics(months?: number) { const m = months || 6; return useQuery({ queryKey: ["analytics", "monthly", m], queryFn: async () => { const res = await fetch(`/api/analytics/monthly?months=${m}`); return res.json(); }, }); } export interface SubscriptionRow { merchant: string; category: string; frequency: string; avg_amount: number; monthly_equiv: number; first_seen: string; last_seen: string; occurrences: number; total_paid: number; is_active: boolean; } export function useSubscriptions() { return useQuery<{ subscriptions: SubscriptionRow[]; total_monthly_equiv: number }>({ queryKey: ["analytics", "subscriptions"], queryFn: async () => { const res = await fetch("/api/analytics/subscriptions"); return res.json(); }, }); } export interface FeeBankRow { bank_name: string; fees: number; interest: number; total: number; } export interface FeeTxnRow { id: number; transaction_date: string; description: string; merchant_name: string | null; transaction_type: string; my_amount: number; bank_name: string; } export function useFees() { return useQuery<{ by_bank: FeeBankRow[]; transactions: FeeTxnRow[]; total_fees: number; total_interest: number; }>({ queryKey: ["analytics", "fees"], queryFn: async () => { const res = await fetch("/api/analytics/fees"); return res.json(); }, }); } export interface MerchantRow { merchant: string; category: string; debit_count: number; refund_count: number; gross_spend: number; total_refunds: number; net_spend: number; avg_debit: number; first_seen: string; last_seen: string; months_active: number; monthly_trend: Record; } export function useMerchants(months = 12) { return useQuery<{ merchants: MerchantRow[]; months: number }>({ queryKey: ["analytics", "merchants", months], queryFn: async () => { const res = await fetch(`/api/analytics/merchants?months=${months}`); return res.json(); }, }); } export interface MerchantTxnRow { id: number; transaction_date: string; description: string; amount: number; amount_aud: number | null; my_amount: number; transaction_type: string; category: string; bank_name: string; statement_id: number; } export function useMerchantTransactions(merchant: string | null) { return useQuery<{ transactions: MerchantTxnRow[] }>({ queryKey: ["analytics", "merchant-txns", merchant], queryFn: async () => { const res = await fetch(`/api/analytics/merchants/${encodeURIComponent(merchant!)}`); return res.json(); }, enabled: !!merchant, }); }