fc22a61a43
- Add POST /api/transactions to create manual transactions (statement_id=NULL, owner_id set directly) - Queries switch from JOIN to LEFT JOIN statements so manual transactions are visible - COALESCE(t.owner_id, s.owner_id) throughout for owner resolution - Add "Manual" bank filter option in getTransactions - Search extended to include merchant_normalized override - Split data fetched via lateral subquery on every transaction row - getParticipantBalances rewritten as UNION for bidirectional net balances (credits/refunds negate, split from either side of the relationship) - getSharedTransactions: remove my_share_percent from SELECT (fixes GROUP BY error), WHERE rewritten as two distinct cases (owner with others split vs participant on others' txn) - getTransactions: OR EXISTS condition so split participants see shared transactions - add-transaction-modal component for creating manual transactions with splits - 0008_my_share_percent migration adds my_share_percent to transaction_overrides
684 lines
19 KiB
TypeScript
684 lines
19 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;
|
|
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<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() {
|
|
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<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 (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<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;
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|