diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 0000000..3118c47 --- /dev/null +++ b/src/api/client.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { apiGet } from "./client"; +import { NetWorthSchema } from "./schemas"; +beforeEach(() => vi.restoreAllMocks()); +describe("apiGet", () => { + it("fetches and validates", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ assets: "1", liabilities: "0", total: "1" }), { status: 200 }))); + const nw = await apiGet("/networth", NetWorthSchema); + expect(nw.total).toBe("1"); + }); + it("throws on http error", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 401 }))); + await expect(apiGet("/networth", NetWorthSchema)).rejects.toThrow(); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..9373a14 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +export class ApiError extends Error { + constructor( + public status: number, + msg: string, + ) { + super(msg); + } +} + +async function req(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`/api/v1${path}`, { + method, + credentials: "same-origin", + headers: body === undefined ? undefined : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (res.status === 401) { + if (typeof window !== "undefined") window.location.href = "/login"; + throw new ApiError(401, "unauthorized"); + } + if (!res.ok) throw new ApiError(res.status, await res.text()); + return res.status === 204 ? null : res.json(); +} + +export async function apiGet(path: string, schema: z.ZodType): Promise { + return schema.parse(await req("GET", path)); +} + +export async function apiSend( + method: "POST" | "PUT" | "DELETE", + path: string, + body?: unknown, + schema?: z.ZodType, +): Promise { + const data = await req(method, path, body); + return schema ? schema.parse(data) : (data as T); +} diff --git a/src/api/hooks/mutations.ts b/src/api/hooks/mutations.ts new file mode 100644 index 0000000..1617615 --- /dev/null +++ b/src/api/hooks/mutations.ts @@ -0,0 +1,205 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiSend } from "../client"; +import { keys } from "../keys"; +import { + BudgetSchema, + SettingsSchema, + LendingSchema, + RevalueResultSchema, + SubscriptionSchema, +} from "../schemas"; + +// --- transactions --- + +export const useCategorize = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (b: { matchKey: string; category: string; kind?: "income" | "expense" }) => + apiSend("POST", "/transactions/categorize", b), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + qc.invalidateQueries({ queryKey: ["analyze"] }); + qc.invalidateQueries({ queryKey: keys.budget }); + }, + }); +}; + +export const useRenameTxn = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (b: { matchKey: string; name: string }) => apiSend("POST", "/transactions/rename", b), + onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }), + }); +}; + +export const useSetNote = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (b: { id: number; note: string }) => apiSend("PUT", `/transactions/${b.id}/note`, { note: b.note }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }), + }); +}; + +// --- budget --- + +export const usePutBudget = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (b: { + dayLimit: string; + weekLimit: string; + monthLimit: string; + plannedIncomeManual: string; + categories: { name: string; day: string; week: string; month: string }[]; + }) => apiSend("PUT", "/budget", b, BudgetSchema), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.budget }), + }); +}; + +// --- settings --- + +export const useSaveSettings = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (b: { + holderName: string; + employer: string; + salaryKeywords: string[]; + payDays: number[]; + ownAccounts: string[]; + peerAccounts: string[]; + hideAmounts?: boolean; + }) => apiSend("PUT", "/settings", b, SettingsSchema), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.settings }), + }); +}; + +// --- categories --- + +export const useCategoryMutations = () => { + const qc = useQueryClient(); + const invalidate = () => { + qc.invalidateQueries({ queryKey: keys.categories }); + qc.invalidateQueries({ queryKey: keys.budget }); + qc.invalidateQueries({ queryKey: ["transactions"] }); + }; + const add = useMutation({ + mutationFn: (b: { name: string; kind?: "income" | "expense"; parent?: string; icon?: string }) => + apiSend("POST", "/categories", b), + onSuccess: invalidate, + }); + const update = useMutation({ + mutationFn: (b: { oldName: string; newName?: string; parent?: string | null; icon?: string | null }) => + apiSend("PUT", "/categories", b), + onSuccess: invalidate, + }); + const remove = useMutation({ + mutationFn: (name: string) => apiSend("DELETE", `/categories?name=${encodeURIComponent(name)}`), + onSuccess: invalidate, + }); + return { add, update, delete: remove }; +}; + +// --- manual assets --- + +export const useManualAssetMutations = () => { + const qc = useQueryClient(); + const invalidate = () => { + qc.invalidateQueries({ queryKey: keys.manualAssets }); + qc.invalidateQueries({ queryKey: keys.networth }); + }; + const add = useMutation({ + mutationFn: (b: { + name: string; + category?: string; + value: string; + acquiredValue?: string; + currency?: string; + condition?: string; + isLiability?: boolean; + }) => apiSend("POST", "/manual-assets", b), + onSuccess: invalidate, + }); + const remove = useMutation({ + mutationFn: (name: string) => apiSend("DELETE", `/manual-assets/${encodeURIComponent(name)}`), + onSuccess: invalidate, + }); + const revalue = useMutation({ + mutationFn: (name: string) => apiSend("POST", `/manual-assets/${encodeURIComponent(name)}/revalue`, undefined, RevalueResultSchema), + onSuccess: invalidate, + }); + return { add, delete: remove, revalue }; +}; + +// --- lending --- + +export const useLendingMutations = () => { + const qc = useQueryClient(); + const invalidate = () => qc.invalidateQueries({ queryKey: keys.lending }); + const create = useMutation({ + mutationFn: (b: { person: string; amount: string; lentOn: string; dueOn?: string; note?: string; txnId?: number }) => + apiSend("POST", "/lending", b, LendingSchema), + onSuccess: invalidate, + }); + const remove = useMutation({ + mutationFn: (id: number) => apiSend("DELETE", `/lending/${id}`), + onSuccess: invalidate, + }); + const addRepayment = useMutation({ + mutationFn: (b: { id: number; amount: string; paidOn: string; note?: string; txnId?: number }) => + apiSend("POST", `/lending/${b.id}/repayments`, { amount: b.amount, paidOn: b.paidOn, note: b.note, txnId: b.txnId }, LendingSchema), + onSuccess: invalidate, + }); + const deleteRepayment = useMutation({ + mutationFn: (b: { id: number; repaymentId: number }) => apiSend("DELETE", `/lending/${b.id}/repayments/${b.repaymentId}`), + onSuccess: invalidate, + }); + return { create, delete: remove, addRepayment, deleteRepayment }; +}; + +// --- subscriptions --- + +export const useSubscriptionMutations = () => { + const qc = useQueryClient(); + const invalidate = () => { + qc.invalidateQueries({ queryKey: keys.subscriptions }); + qc.invalidateQueries({ queryKey: keys.budget }); + }; + const setSubscription = useMutation({ + mutationFn: (b: { matchKey: string; name?: string; active: boolean }) => apiSend("POST", "/subscriptions", b), + onSuccess: invalidate, + }); + const createManualSub = useMutation({ + mutationFn: (b: { name: string; amount: string; category?: string; nextDue?: string }) => + apiSend("POST", "/manual-subscriptions", b, SubscriptionSchema), + onSuccess: invalidate, + }); + const deleteManualSub = useMutation({ + mutationFn: (id: number) => apiSend("DELETE", `/manual-subscriptions/${id}`), + onSuccess: invalidate, + }); + return { setSubscription, createManualSub, deleteManualSub }; +}; + +// --- savings goals --- + +export const useSavingsGoalMutations = () => { + const qc = useQueryClient(); + const invalidate = () => qc.invalidateQueries({ queryKey: keys.budget }); + const post = useMutation({ + mutationFn: (b: { + originalName?: string; + name: string; + target: string; + monthlyContribution?: string; + accountId?: number; + targetDate?: string; + }) => apiSend("POST", "/savings-goals", b, BudgetSchema), + onSuccess: invalidate, + }); + const remove = useMutation({ + mutationFn: (name: string) => apiSend("DELETE", `/savings-goals/${encodeURIComponent(name)}`, undefined, BudgetSchema), + onSuccess: invalidate, + }); + return { post, delete: remove }; +}; diff --git a/src/api/hooks/reads.ts b/src/api/hooks/reads.ts new file mode 100644 index 0000000..cd7bb10 --- /dev/null +++ b/src/api/hooks/reads.ts @@ -0,0 +1,125 @@ +import { useQuery } from "@tanstack/react-query"; +import { z } from "zod"; +import { apiGet } from "../client"; +import { keys } from "../keys"; +import { + NetWorthSchema, + AnalyzeSchema, + BudgetSchema, + TxnSchema, + CategorySchema, + SubscriptionSchema, + ManualAssetSchema, + LendingSchema, + SettingsSchema, + ConnectionSchema, + UserSchema, +} from "../schemas"; + +/** Local date (not UTC) as YYYY-MM-DD — dependency-free, matches the backend's + * `?from=&to=` parsing (time.Parse("2006-01-02", v)). */ +export function todayLocalDate(d: Date = new Date()): string { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export const useNetWorth = () => + useQuery({ queryKey: keys.networth, queryFn: () => apiGet("/networth", NetWorthSchema) }); + +export const useAnalyzeMonth = () => + useQuery({ queryKey: keys.analyzeMonth, queryFn: () => apiGet("/analyze", AnalyzeSchema) }); + +export const useAnalyzeToday = () => { + const d = todayLocalDate(); + return useQuery({ + queryKey: keys.analyzeToday(d), + queryFn: () => apiGet(`/analyze?from=${d}&to=${d}`, AnalyzeSchema), + }); +}; + +export const useBudget = () => useQuery({ queryKey: keys.budget, queryFn: () => apiGet("/budget", BudgetSchema) }); + +export interface TransactionsParams { + from?: string; + to?: string; + direction?: "income" | "expense"; + category?: string; + account?: number | string; + limit?: number; +} + +function txnQueryString(params?: TransactionsParams): string { + if (!params) return ""; + const qs = new URLSearchParams(); + if (params.from) qs.set("from", params.from); + if (params.to) qs.set("to", params.to); + if (params.direction) qs.set("direction", params.direction); + if (params.category) qs.set("category", params.category); + if (params.account !== undefined) qs.set("account", String(params.account)); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + const s = qs.toString(); + return s ? `?${s}` : ""; +} + +const TransactionsResponseSchema = z.object({ transactions: z.array(TxnSchema) }); + +export const useTransactions = (params?: TransactionsParams) => { + const qs = txnQueryString(params); + return useQuery({ + queryKey: keys.transactions(qs), + queryFn: async () => (await apiGet(`/transactions${qs}`, TransactionsResponseSchema)).transactions, + }); +}; + +const CategoriesResponseSchema = z.object({ categories: z.array(CategorySchema) }); + +export const useCategories = () => + useQuery({ + queryKey: keys.categories, + queryFn: async () => (await apiGet("/categories", CategoriesResponseSchema)).categories, + }); + +const SubscriptionsResponseSchema = z.object({ + subscriptions: z.array(SubscriptionSchema), + bills: z.array(SubscriptionSchema), +}); + +export const useSubscriptions = () => + useQuery({ queryKey: keys.subscriptions, queryFn: () => apiGet("/subscriptions", SubscriptionsResponseSchema) }); + +const ManualAssetsResponseSchema = z.object({ manualAssets: z.array(ManualAssetSchema) }); + +export const useManualAssets = () => + useQuery({ + queryKey: keys.manualAssets, + queryFn: async () => (await apiGet("/manual-assets", ManualAssetsResponseSchema)).manualAssets, + }); + +const LendingResponseSchema = z.object({ entries: z.array(LendingSchema) }); + +export const useLending = () => + useQuery({ + queryKey: keys.lending, + queryFn: async () => (await apiGet("/lending", LendingResponseSchema)).entries, + }); + +export const useSettings = () => + useQuery({ queryKey: keys.settings, queryFn: () => apiGet("/settings", SettingsSchema) }); + +const ConnectionsResponseSchema = z.object({ connections: z.array(ConnectionSchema) }); + +export const useConnections = () => + useQuery({ + queryKey: keys.connections, + queryFn: async () => (await apiGet("/connections", ConnectionsResponseSchema)).connections, + }); + +const MeResponseSchema = z.object({ user: UserSchema }); + +export const useMe = () => + useQuery({ + queryKey: keys.me, + queryFn: async () => (await apiGet("/me", MeResponseSchema)).user, + }); diff --git a/src/api/keys.ts b/src/api/keys.ts new file mode 100644 index 0000000..9113483 --- /dev/null +++ b/src/api/keys.ts @@ -0,0 +1,14 @@ +export const keys = { + networth: ["networth"] as const, + analyzeMonth: ["analyze", "month"] as const, + analyzeToday: (d: string) => ["analyze", "today", d] as const, + budget: ["budget"] as const, + transactions: (p: string) => ["transactions", p] as const, + categories: ["categories"] as const, + subscriptions: ["subscriptions"] as const, + manualAssets: ["manualAssets"] as const, + lending: ["lending"] as const, + settings: ["settings"] as const, + connections: ["connections"] as const, + me: ["me"] as const, +} as const; diff --git a/src/api/queryClient.tsx b/src/api/queryClient.tsx new file mode 100644 index 0000000..194c1f1 --- /dev/null +++ b/src/api/queryClient.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +// Module-level singleton: one QueryClient shared across the app (client-side +// only — Next's RSC boundary means this module is only ever instantiated in +// the browser via the "use client" directive above). +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + }, + }, +}); + +export function AppQueryProvider({ children }: { children: ReactNode }) { + return {children}; +} + +export { queryClient };