b8cd1b0f89
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.
804 lines
22 KiB
TypeScript
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,
|
|
});
|
|
}
|