245 lines
6.6 KiB
TypeScript
245 lines
6.6 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;
|
|
userId?: 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;
|
|
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<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.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<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 }
|
|
}
|