getOrderDetail joined order_merchant_cadence on merchant_entity_id. Migration 021 rekeyed that view to merchant_key (merchant_entity_id is NULL on ~9% of the feed, and duplicate merchant entities split one shop's history); 027 and 029 moved order_feed and order_spend across, and this query was missed. Every /orders/<key> request has since failed with column rec.merchant_entity_id does not exist The reason nobody saw a 500 is the second half of this commit: the page collapsed every failure into 'That order could not be found.' A server fault wearing the costume of a data condition reads as an empty spine and gets investigated in the wrong repo. The hook now carries the status and only a genuine 404 says the order is missing.
1260 lines
38 KiB
TypeScript
1260 lines
38 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import type { TransactionRow, StatementRow, TagRow, TripRow, TripAnalytics, ParticipantBalance } from "./queries";
|
|
export type { TripRow, TripAnalytics };
|
|
import type { CurrentUser } from "./auth";
|
|
|
|
interface TransactionsResponse {
|
|
data: TransactionRow[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
|
|
interface TransactionFilters {
|
|
from?: string;
|
|
to?: string;
|
|
categories?: string[];
|
|
exclude_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;
|
|
trip_id?: string;
|
|
/** Only the trip detail view sets this — see the note on the server-side filter. */
|
|
trip_all_rows?: boolean;
|
|
}
|
|
|
|
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 if (typeof val === "boolean") {
|
|
// The route reads "1", not "true" — String(true) would silently not match.
|
|
if (val) params.set(key, "1");
|
|
} 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;
|
|
payment_method?: 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;
|
|
trip_id?: number | null;
|
|
payment_method?: string | null;
|
|
}) => {
|
|
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;
|
|
rule_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"] });
|
|
}
|
|
// A quick action can touch category, tags and splits at once, and records
|
|
// a revertible run — refresh everything it could have changed.
|
|
if (variables.action === "apply_rule") {
|
|
qc.invalidateQueries({ queryKey: ["tags"] });
|
|
qc.invalidateQueries({ queryKey: ["splits"] });
|
|
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
|
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
|
qc.invalidateQueries({ queryKey: ["analytics"] });
|
|
qc.invalidateQueries({ queryKey: ["rule-runs"] });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
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<ParticipantBalance[]>({
|
|
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 interface OrderReceipt {
|
|
/** 'email' | 'receipt-scan' | 'order-bridge' | ... — which lane wrote the row. */
|
|
source: string | null;
|
|
platform: string | null;
|
|
order_reference: string | null;
|
|
line_items: { qty: number; description: string; amount: number; options?: string[] }[];
|
|
route: { label: string; time: string | null; address: string }[];
|
|
subtotal: string | null;
|
|
amount: string | null;
|
|
currency: string | null;
|
|
card_last4: string | null;
|
|
flags: string[];
|
|
source_email_subject: string | null;
|
|
transaction_date: string | null;
|
|
}
|
|
|
|
/** The receipt behind a transaction, or null when it did not come from one. */
|
|
export function useOrderReceipt(transactionId: number) {
|
|
return useQuery<OrderReceipt | null>({
|
|
queryKey: ["order-receipt", transactionId],
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/transactions/${transactionId}/order`);
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
},
|
|
staleTime: Infinity, // a receipt never changes
|
|
});
|
|
}
|
|
|
|
export type OrderRating = "loved" | "liked" | "ok" | "bad" | "never";
|
|
export type ItemVerdict = "loved" | "never";
|
|
|
|
export interface ItemOpinion {
|
|
item: string;
|
|
verdict: ItemVerdict;
|
|
}
|
|
|
|
export interface OrderReviewRow {
|
|
transaction_id: number;
|
|
participant_id: number;
|
|
participant_name: string;
|
|
rating: OrderRating | null;
|
|
order_again: boolean | null;
|
|
note: string | null;
|
|
item_verdicts: ItemOpinion[];
|
|
}
|
|
|
|
export interface OrderReviewState {
|
|
/** One row per person who has an opinion. Empty until someone records one. */
|
|
reviews: OrderReviewRow[];
|
|
/** Current splits — an empty list means the order was not shared. */
|
|
splits: { participant_id: number; share_percent: string }[];
|
|
merchant: {
|
|
merchant: string;
|
|
history: {
|
|
transaction_id: number;
|
|
participant_id: number;
|
|
participant_name: string;
|
|
rating: OrderRating | null;
|
|
note: string | null;
|
|
transaction_date: string | null;
|
|
}[];
|
|
counts: Record<OrderRating, number>;
|
|
warn: boolean;
|
|
items: { item: string; loved: number; never: number }[];
|
|
} | null;
|
|
}
|
|
|
|
/**
|
|
* The verdict on an order and this merchant's track record.
|
|
*
|
|
* No `staleTime: Infinity` here, unlike the receipt hook next to it — a receipt
|
|
* never changes, but a verdict is the one part of an order that does.
|
|
*/
|
|
export function useOrderReview(transactionId: number) {
|
|
return useQuery<OrderReviewState>({
|
|
queryKey: ["order-review", transactionId],
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/transactions/${transactionId}/review`);
|
|
if (!res.ok) return { reviews: [], splits: [], merchant: null };
|
|
return res.json();
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useSetOrderReview() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
transactionId,
|
|
participantId,
|
|
rating,
|
|
note,
|
|
itemVerdicts,
|
|
}: {
|
|
transactionId: number;
|
|
participantId: number;
|
|
rating: OrderRating | null;
|
|
note?: string | null;
|
|
/** Omit to leave existing item opinions untouched. */
|
|
itemVerdicts?: ItemOpinion[];
|
|
}) => {
|
|
const res = await fetch(`/api/transactions/${transactionId}/review`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
participant_id: participantId,
|
|
rating,
|
|
note,
|
|
...(itemVerdicts === undefined ? {} : { item_verdicts: itemVerdicts }),
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Failed to save verdict");
|
|
}
|
|
return res.json();
|
|
},
|
|
// Every order from the same merchant now shows a different track record,
|
|
// so invalidate the whole key rather than this one transaction.
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["order-review"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
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"] });
|
|
// The order panel shows share state from this same data.
|
|
qc.invalidateQueries({ queryKey: ["order-review"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove every split from a transaction — the un-share half of a toggle.
|
|
*
|
|
* Separate from `useSetSplits` because that endpoint requires a set of shares
|
|
* totalling 100%, and "no split at all" is not a set of shares. Posting `[]` to
|
|
* it was rejected, which is why the order panel's toggle could not be turned
|
|
* off.
|
|
*/
|
|
export function useClearSplits() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (transactionId: number) => {
|
|
const res = await fetch(`/api/transactions/${transactionId}/splits`, {
|
|
method: "DELETE",
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Failed to clear splits");
|
|
}
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["splits"] });
|
|
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
|
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
|
qc.invalidateQueries({ queryKey: ["order-review"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
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;
|
|
/** Which tab this payment settles. null = the ongoing household tab. */
|
|
trip_id: number | null;
|
|
trip_name: string | 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;
|
|
/** Which tab this settles. null/omitted = the ongoing household tab. */
|
|
trip_id?: number | null;
|
|
}) => {
|
|
const res = await fetch("/api/split-payments", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to record payment");
|
|
}
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
|
qc.invalidateQueries({ queryKey: ["split-payments"] });
|
|
// A trip-scoped payment changes that trip's owed figures too.
|
|
qc.invalidateQueries({ queryKey: ["trip-analytics"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
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;
|
|
/** Excluded from the apply-all run; fired by hand as a quick action instead. */
|
|
manual_only?: 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 interface RuleMatchChange {
|
|
field: "category" | "merchant" | "tags" | "split";
|
|
from: string | null;
|
|
to: string;
|
|
}
|
|
export interface RuleMatchRow {
|
|
id: number;
|
|
transaction_date: string;
|
|
description: string;
|
|
amount: number;
|
|
amount_aud: number | null;
|
|
currency: string;
|
|
bank_name: string;
|
|
effective_merchant: string;
|
|
effective_category: string;
|
|
changes: RuleMatchChange[];
|
|
}
|
|
export interface RuleMatches {
|
|
rule: { id: number; name: string; matches_everything: boolean };
|
|
total_matched: number;
|
|
would_change: number;
|
|
already_correct: number;
|
|
transactions: RuleMatchRow[];
|
|
truncated: boolean;
|
|
}
|
|
|
|
/** Dry run — what a rule would change. Writes nothing. */
|
|
export function useRuleMatches(ruleId: number | null) {
|
|
return useQuery<RuleMatches>({
|
|
queryKey: ["rule-matches", ruleId],
|
|
enabled: ruleId != null,
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/rules/${ruleId}/matches`);
|
|
if (!res.ok) throw new Error("Failed to load rule matches");
|
|
return res.json();
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Apply a rule to a hand-picked set of transactions (conditions ignored). */
|
|
export function useApplyRuleToSelection() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ ruleId, ids }: { ruleId: number; ids: number[] }) => {
|
|
const res = await fetch("/api/transactions/bulk", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action: "apply_rule", rule_id: ruleId, ids }),
|
|
});
|
|
if (!res.ok) throw new Error((await res.json()).error || "Failed to apply rule");
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
|
qc.invalidateQueries({ queryKey: ["rule-matches"] });
|
|
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;
|
|
// Provenance (migration 0017). NULL on runs recorded before it existed.
|
|
rule_id: number | null;
|
|
rule_name: string | null;
|
|
source: "all" | "rule" | "selection" | null;
|
|
}
|
|
|
|
export interface RuleRunDetail {
|
|
run: RuleRun & { transactions_affected: number };
|
|
transactions: {
|
|
id: number;
|
|
transaction_date: string;
|
|
description: string;
|
|
amount: number;
|
|
amount_aud: number | null;
|
|
bank_name: string;
|
|
merchant: string | null;
|
|
changes: { field: string; from: string | null; to: string | null }[];
|
|
}[];
|
|
still_changed: number;
|
|
}
|
|
|
|
/** What a run actually did — loaded on demand when a row is expanded. */
|
|
export function useRuleRunDetail(runId: number | null) {
|
|
return useQuery<RuleRunDetail>({
|
|
queryKey: ["rule-run-detail", runId],
|
|
enabled: runId != null,
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/rules/runs/${runId}`);
|
|
if (!res.ok) throw new Error("Failed to load run detail");
|
|
return res.json();
|
|
},
|
|
});
|
|
}
|
|
|
|
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();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sparse by day-of-month; a missing day means zero.
|
|
* daily → { "2026-07": { 3: 42.10 } }
|
|
* byCategory → { "2026-07": { dining: { 3: 42.10 } } }
|
|
*/
|
|
export interface DailySpend {
|
|
daily: Record<string, Record<number, number>>;
|
|
byCategory: Record<string, Record<string, Record<number, number>>>;
|
|
}
|
|
|
|
export function useDailySpend(months?: number) {
|
|
const m = months || 12;
|
|
return useQuery<DailySpend>({
|
|
queryKey: ["analytics", "daily", m],
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/analytics/daily?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 interface FeePeriod {
|
|
months: number;
|
|
from: string | null;
|
|
to: string | null;
|
|
all_time: boolean;
|
|
}
|
|
|
|
/** `months = 0` means all time. */
|
|
export function useFees(months = 12) {
|
|
return useQuery<{
|
|
by_bank: FeeBankRow[];
|
|
transactions: FeeTxnRow[];
|
|
total_fees: number;
|
|
total_interest: number;
|
|
period: FeePeriod;
|
|
}>({
|
|
queryKey: ["analytics", "fees", months],
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/analytics/fees?months=${months}`);
|
|
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,
|
|
});
|
|
}
|
|
|
|
// ─── Trips ───────────────────────────────────────────────────────────────────
|
|
|
|
export function useTrips() {
|
|
return useQuery<TripRow[]>({
|
|
queryKey: ["trips"],
|
|
queryFn: async () => (await fetch("/api/trips")).json(),
|
|
});
|
|
}
|
|
|
|
export function useTrip(id: number) {
|
|
return useQuery<TripRow>({
|
|
queryKey: ["trip", id],
|
|
queryFn: async () => (await fetch(`/api/trips/${id}`)).json(),
|
|
enabled: id > 0,
|
|
});
|
|
}
|
|
|
|
export function useTripAnalytics(id: number) {
|
|
return useQuery<TripAnalytics>({
|
|
queryKey: ["trip-analytics", id],
|
|
queryFn: async () => (await fetch(`/api/trips/${id}/analytics`)).json(),
|
|
enabled: id > 0,
|
|
});
|
|
}
|
|
|
|
export function useCreateTrip() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: Omit<TripRow, "id" | "owner_id" | "created_at" | "total_spend" | "transaction_count">) => {
|
|
const res = await fetch("/api/trips", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
|
return res.json() as Promise<TripRow>;
|
|
},
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["trips"] }),
|
|
});
|
|
}
|
|
|
|
export function useUpdateTrip() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ id, ...data }: Partial<TripRow> & { id: number }) => {
|
|
const res = await fetch(`/api/trips/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
|
return res.json() as Promise<TripRow>;
|
|
},
|
|
onSuccess: (_d, { id }) => {
|
|
qc.invalidateQueries({ queryKey: ["trips"] });
|
|
qc.invalidateQueries({ queryKey: ["trip", id] });
|
|
qc.invalidateQueries({ queryKey: ["trip-analytics", id] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteTrip() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (id: number) => {
|
|
await fetch(`/api/trips/${id}`, { method: "DELETE" });
|
|
},
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["trips"] }),
|
|
});
|
|
}
|
|
|
|
export function useAssignTransactionsToTrip() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ tripId, transactionIds }: { tripId: number | null; transactionIds: number[] }) => {
|
|
const res = await fetch("/api/transactions/bulk", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action: "assign_trip", ids: transactionIds, trip_id: tripId }),
|
|
});
|
|
if (!res.ok) throw new Error("Failed to assign trip");
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
|
qc.invalidateQueries({ queryKey: ["trips"] });
|
|
qc.invalidateQueries({ queryKey: ["trip-analytics"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------- orders --
|
|
// Browse surface over the ingestion spine. See lib/order-feed.ts for why the
|
|
// spine is read raw and why /orders is gated by an explicit viewer allowlist.
|
|
|
|
import type { OrderRow, OrderFacets, OrderDetail, OrderFilters } from "./order-feed";
|
|
export type { OrderRow, OrderFacets, OrderDetail, OrderFilters };
|
|
|
|
interface OrdersResponse {
|
|
data: OrderRow[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
facets: OrderFacets;
|
|
}
|
|
|
|
export function useOrders(filters: Record<string, unknown>) {
|
|
return useQuery<OrdersResponse>({
|
|
queryKey: ["orders", filters],
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/orders?${buildParams(filters as never)}`);
|
|
if (!res.ok) throw new Error("Failed to load orders");
|
|
return res.json();
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useOrderDetail(entityKey: string | null) {
|
|
return useQuery<OrderDetail>({
|
|
queryKey: ["order", entityKey],
|
|
enabled: Boolean(entityKey),
|
|
// An order's history does not change while you are looking at it; the
|
|
// lifecycle only moves when the ingestion runner ticks.
|
|
staleTime: 60_000,
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/orders/${encodeURIComponent(entityKey!)}`);
|
|
if (!res.ok) {
|
|
// Carry the status. Collapsing every failure into one Error is how a
|
|
// 500 on this route rendered as "that order could not be found" on
|
|
// every single order for weeks — a server fault wearing the costume of
|
|
// a data condition, which reads as "the spine is empty" and gets
|
|
// investigated nowhere near the actual bug.
|
|
const err = new Error(res.status === 404 ? "Order not found" : "Failed to load order");
|
|
(err as Error & { status?: number }).status = res.status;
|
|
throw err;
|
|
}
|
|
return res.json();
|
|
},
|
|
});
|
|
}
|