Files
SkyMoney/api/clients/ts/sdk.ts

239 lines
6.4 KiB
TypeScript

/* 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;
fetch?: FetchLike;
requestIdFactory?: () => string; // to set x-request-id if desired
};
function makeQuery(params: Record<string, unknown | undefined>): 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;
constructor(opts: SDKOptions = {}) {
this.baseUrl = (opts.baseUrl || "http://localhost:8080").replace(/\/+$/, "");
this.f = opts.fetch || fetch;
this.reqId = opts.requestIdFactory;
}
private async request<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
body?: unknown,
query?: Record<string, unknown>,
headers?: Record<string, string>
): Promise<T> {
const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`;
const h: Record<string, string> = { ...(headers || {}) };
if (this.reqId) h["x-request-id"] = this.reqId();
const hasBody = body !== undefined && body !== null;
const res = await this.f(url, {
method,
credentials: "include",
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<DashboardResponse>("GET", "/dashboard"),
};
// ---- Income
income = {
preview: (amountCents: number) =>
this.request<IncomePreviewResponse>("POST", "/income/preview", { amountCents }),
create: (amountCents: number) =>
this.request<IncomeAllocationResponse>("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<TransactionList>("GET", "/transactions", undefined, args),
create: (payload: {
kind: TransactionKind;
amountCents: number;
occurredAtISO: string;
categoryId?: string;
planId?: string;
}) => this.request<Transaction>("POST", "/transactions", payload),
};
// ---- Variable Categories
variableCategories = {
create: (payload: {
name: string;
percent: number;
isSavings: boolean;
priority: number;
}) => this.request<OkResponse>("POST", "/variable-categories", payload),
update: (id: string, patch: Partial<{
name: string;
percent: number;
isSavings: boolean;
priority: number;
}>) => this.request<OkResponse>("PATCH", `/variable-categories/${encodeURIComponent(id)}`, patch),
delete: (id: string) =>
this.request<OkResponse>("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<OkResponse>("POST", "/fixed-plans", payload),
update: (id: string, patch: Partial<{
name: string;
totalCents: number;
fundedCents: number;
priority: number;
dueOn: string;
cycleStart: string;
}>) => this.request<OkResponse>("PATCH", `/fixed-plans/${encodeURIComponent(id)}`, patch),
delete: (id: string) =>
this.request<OkResponse>("DELETE", `/fixed-plans/${encodeURIComponent(id)}`),
};
}
// ---------- helpers ----------
function safeJson(s: string) {
try { return JSON.parse(s) } catch { return s }
}