/* SkyMoney SDK: zero-dep, Fetch-based, TypeScript-first. Usage: import { SkyMoney } from "./sdk"; const api = new SkyMoney({ baseUrl: import.meta.env.VITE_API_URL }); const dash = await api.dashboard.get(); */ export type TransactionKind = "variable_spend" | "fixed_payment"; export interface OkResponse { ok: true } export interface ErrorResponse { ok: false; code: string; message: string; requestId: string; } export interface VariableCategory { id: string; userId?: string; name: string; percent: number; // 0..100 isSavings: boolean; priority: number; balanceCents?: number; } export interface FixedPlan { id: string; userId?: string; name: string; totalCents?: number; fundedCents?: number; priority: number; dueOn: string; // ISO cycleStart?: string;// ISO } export interface Transaction { id: string; userId?: string; kind: TransactionKind; amountCents: number; occurredAt: string; // ISO categoryId?: string | null; planId?: string | null; } export interface TransactionList { items: Transaction[]; page: number; limit: number; total: number; } export interface DashboardResponse { totals: { incomeCents: number; variableBalanceCents: number; fixedRemainingCents: number; }; percentTotal: number; variableCategories: VariableCategory[]; fixedPlans: FixedPlan[]; recentTransactions: Transaction[]; } export interface IncomeRequest { amountCents: number; } export interface AllocationItem { id: string; name: string; amountCents: number; } export interface IncomePreviewResponse { fixed: AllocationItem[]; variable: AllocationItem[]; unallocatedCents: number; } // allocateIncome returns a richer object; tests expect these fields: export interface IncomeAllocationResponse { fixedAllocations?: AllocationItem[]; variableAllocations?: AllocationItem[]; remainingUnallocatedCents?: number; // allow any extra fields without type errors: // eslint-disable-next-line @typescript-eslint/no-explicit-any [k: string]: any; } export type FetchLike = typeof fetch; export type SDKOptions = { baseUrl?: string; userId?: string; fetch?: FetchLike; requestIdFactory?: () => string; // to set x-request-id if desired }; function makeQuery(params: Record): string { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === "") continue; sp.set(k, String(v)); } const s = sp.toString(); return s ? `?${s}` : ""; } export class SkyMoney { readonly baseUrl: string; private readonly f: FetchLike; private readonly reqId?: () => string; userId?: string; constructor(opts: SDKOptions = {}) { this.baseUrl = (opts.baseUrl || "http://localhost:8080").replace(/\/+$/, ""); this.userId = opts.userId ?? ( // Try localStorage if present (browser) typeof localStorage !== "undefined" ? localStorage.getItem("x-user-id") || undefined : undefined ); this.f = opts.fetch || fetch; this.reqId = opts.requestIdFactory; } private async request( method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown, query?: Record, headers?: Record ): Promise { const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`; const h: Record = { ...(headers || {}) }; if (this.userId) h["x-user-id"] = this.userId; if (this.reqId) h["x-request-id"] = this.reqId(); const hasBody = body !== undefined && body !== null; const res = await this.f(url, { method, headers: { ...(hasBody ? { "content-type": "application/json" } : {}), ...h, }, body: hasBody ? JSON.stringify(body) : undefined, }); // Attempt to parse JSON; fall back to text const text = await res.text(); const data = text ? safeJson(text) : undefined; if (!res.ok) { const err = new Error((data as any)?.message || `HTTP ${res.status}`); (err as any).status = res.status; (err as any).body = data ?? text; throw err; } return data as T; } // ---- Health health = { get: () => this.request<{ ok: true }>("GET", "/health"), db: () => this.request<{ ok: true; nowISO: string; latencyMs: number }>("GET", "/health/db"), }; // ---- Dashboard dashboard = { get: () => this.request("GET", "/dashboard"), }; // ---- Income income = { preview: (amountCents: number) => this.request("POST", "/income/preview", { amountCents }), create: (amountCents: number) => this.request("POST", "/income", { amountCents }), }; // ---- Transactions transactions = { list: (args: { from?: string; // YYYY-MM-DD to?: string; // YYYY-MM-DD kind?: TransactionKind; q?: string; page?: number; limit?: number; }) => this.request("GET", "/transactions", undefined, args), create: (payload: { kind: TransactionKind; amountCents: number; occurredAtISO: string; categoryId?: string; planId?: string; }) => this.request("POST", "/transactions", payload), }; // ---- Variable Categories variableCategories = { create: (payload: { name: string; percent: number; isSavings: boolean; priority: number; }) => this.request("POST", "/variable-categories", payload), update: (id: string, patch: Partial<{ name: string; percent: number; isSavings: boolean; priority: number; }>) => this.request("PATCH", `/variable-categories/${encodeURIComponent(id)}`, patch), delete: (id: string) => this.request("DELETE", `/variable-categories/${encodeURIComponent(id)}`), }; // ---- Fixed Plans fixedPlans = { create: (payload: { name: string; totalCents: number; fundedCents?: number; priority: number; dueOn: string; // ISO cycleStart?: string; // ISO }) => this.request("POST", "/fixed-plans", payload), update: (id: string, patch: Partial<{ name: string; totalCents: number; fundedCents: number; priority: number; dueOn: string; cycleStart: string; }>) => this.request("PATCH", `/fixed-plans/${encodeURIComponent(id)}`, patch), delete: (id: string) => this.request("DELETE", `/fixed-plans/${encodeURIComponent(id)}`), }; } // ---------- helpers ---------- function safeJson(s: string) { try { return JSON.parse(s) } catch { return s } }