Files
finance-app/src/lib/hooks.ts
T
siddharthd b8cd1b0f89 fix(reconcile): prevent split/tag double-counting on reconciled transactions
Move splits, tags and overrides from manual to statement side on reconcile
(delete from manual after copying) instead of just copying. Add read-time
filter to exclude reconciled manual transactions from balance and shared
transaction queries. Also adds participant filter to shared expenses page.
2026-05-11 19:15:41 +10:00

804 lines
22 KiB
TypeScript

"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;
categories?: string[];
bank_names?: string[];
tag_ids?: string[];
transaction_types?: string[];
search?: string;
statement_id?: string;
sort_by?: string;
sort_dir?: string;
limit?: number;
offset?: number;
amount_min?: number;
amount_max?: number;
has_split?: string;
}
function buildParams(filters: TransactionFilters): string {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, val]) => {
if (val === undefined || val === "") return;
if (Array.isArray(val)) {
if (val.length > 0) params.set(key, val.join(","));
} else {
params.set(key, String(val));
}
});
return params.toString();
}
export function useTransactions(filters: TransactionFilters) {
return useQuery<TransactionsResponse>({
queryKey: ["transactions", filters],
queryFn: async () => {
const res = await fetch(`/api/transactions?${buildParams(filters)}`);
return res.json();
},
});
}
export function useTransaction(id: number) {
return useQuery<TransactionRow>({
queryKey: ["transaction", id],
queryFn: async () => {
const res = await fetch(`/api/transactions/${id}`);
return res.json();
},
});
}
export function useStatements() {
return useQuery<StatementRow[]>({
queryKey: ["statements"],
queryFn: async () => {
const res = await fetch("/api/statements");
return res.json();
},
});
}
export function useStatement(id: number) {
return useQuery<StatementRow>({
queryKey: ["statement", id],
queryFn: async () => {
const res = await fetch(`/api/statements/${id}`);
return res.json();
},
});
}
export function useBanks() {
return useQuery<string[]>({
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(tagIds?: string[]) {
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({
queryKey: ["participant-balances", tagIds],
queryFn: async () => {
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
const res = await fetch(`/api/participants/balances${params}`);
return res.json();
},
});
}
export function useSharedTransactions(tagIds?: string[], participantId?: number) {
return useQuery({
queryKey: ["shared-transactions", tagIds, participantId],
queryFn: async () => {
const sp = new URLSearchParams();
if (tagIds?.length) sp.set("tag_ids", tagIds.join(","));
if (participantId) sp.set("participant_id", String(participantId));
const query = sp.toString() ? `?${sp.toString()}` : "";
const res = await fetch(`/api/shared-transactions${query}`);
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 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: {
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),
});
return res.json();
},
onSuccess: () => {
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"] });
},
});
}
export function useCurrentUser() {
return useQuery<CurrentUser>({
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<RuleRow[]>({
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<RuleRow, "id" | "created_at">) => {
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<RuleRow> & { 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 (args?: { splitFrom?: string; ruleId?: number }) => {
const res = await fetch("/api/rules/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ splitFrom: args?.splitFrom || null, ruleId: args?.ruleId || 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<RuleRun[]>;
},
});
}
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<string, number>;
budget: Record<string, number>;
txCount: Record<string, number>;
}
export interface MonthlyAnalytics {
months: string[];
rows: MonthlyAnalyticsRow[];
income: Record<string, number>;
investments: Record<string, number>;
totals: Record<string, { spent: number; income: number; investments: number; net: number }>;
}
export function useBudgets(month: string) {
return useQuery<BudgetRow[]>({
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<MonthlyAnalytics>({
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<string, number>;
}
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;
}
// --- CSV Import & Reconcile ---
export function useImportCSV() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: {
bank_name: string;
transactions: {
date: string; description: string; amount: number; transaction_type: string;
merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string; category?: string;
}[];
}) => {
const res = await fetch("/api/import/csv", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json()).error || "Import failed");
return res.json() as Promise<{ inserted: number }>;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
},
});
}
import type { ManualTxWithMatches } from "./queries";
export function usePendingReconciliations() {
return useQuery<ManualTxWithMatches[]>({
queryKey: ["reconcile-pending"],
queryFn: async () => {
const res = await fetch("/api/reconcile/pending");
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
},
});
}
export function useReconcile() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (matches: { manual_id: number; statement_tx_id: number }[]) => {
const res = await fetch("/api/transactions/reconcile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ matches }),
});
if (!res.ok) throw new Error((await res.json()).error || "Reconcile failed");
return res.json() as Promise<{ reconciled: number }>;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
},
});
}
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,
});
}