added api logic, vitest, minimal testing ui
This commit is contained in:
244
api/clients/ts/sdk.ts
Normal file
244
api/clients/ts/sdk.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/* 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user