final touches for beta skymoney (at least i think)

This commit is contained in:
2026-01-18 00:00:44 -06:00
parent 4eae966f96
commit f4f0ae5df2
161 changed files with 26016 additions and 1966 deletions

77
web/src/api/budget.ts Normal file
View File

@@ -0,0 +1,77 @@
import { http } from "./http";
export interface BudgetAllocationRequest {
newIncomeCents: number;
fixedExpensePercentage: number;
postedAtISO?: string;
}
export interface BudgetAllocationResponse {
fixedAllocations: Array<{
fixedPlanId: string;
amountCents: number;
source: string;
}>;
variableAllocations: Array<{
variableCategoryId: string;
amountCents: number;
}>;
totalBudgetCents: number;
fundedBudgetCents: number;
availableBudgetCents: number;
remainingBudgetCents: number;
crisis: {
active: boolean;
plans: Array<{
id: string;
name: string;
remainingCents: number;
daysUntilDue: number;
priority: number;
allocatedCents: number;
}>;
};
planStatesAfter: Array<{
id: string;
name: string;
totalCents: number;
fundedCents: number;
remainingCents: number;
daysUntilDue: number;
isCrisis: boolean;
}>;
}
export interface BudgetReconcileRequest {
bankTotalCents: number;
}
export interface BudgetReconcileResponse {
ok: boolean;
deltaCents: number;
currentTotalCents: number;
newTotalCents: number;
}
export const budgetApi = {
async allocate(data: BudgetAllocationRequest): Promise<BudgetAllocationResponse> {
return http<BudgetAllocationResponse>("/budget/allocate", {
method: "POST",
body: data,
});
},
async fund(data: BudgetAllocationRequest): Promise<BudgetAllocationResponse> {
return http<BudgetAllocationResponse>("/budget/fund", {
method: "POST",
body: data,
});
},
async reconcile(data: BudgetReconcileRequest): Promise<BudgetReconcileResponse> {
return http<BudgetReconcileResponse>("/budget/reconcile", {
method: "POST",
body: data,
});
},
};

View File

@@ -1,4 +1,4 @@
import { request } from "./client";
import { apiDelete, apiPatch, apiPost } from "./http";
export type NewCategory = {
name: string;
@@ -9,22 +9,8 @@ export type NewCategory = {
export type UpdateCategory = Partial<NewCategory>;
export const categoriesApi = {
create: (body: NewCategory) =>
request<{ id: number }>("/variable-categories", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
}),
update: (id: number, body: UpdateCategory) =>
request(`/variable-categories/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
}),
delete: (id: number) =>
request(`/variable-categories/${id}`, {
method: "DELETE"
})
};
create: (body: NewCategory) => apiPost<{ id: string }>("/variable-categories", body),
update: (id: string, body: UpdateCategory) => apiPatch(`/variable-categories/${id}`, body),
delete: (id: string) => apiDelete(`/variable-categories/${id}`),
rebalance: () => apiPost<{ ok: boolean; applied?: boolean; totalBalance?: number }>("/variable-categories/rebalance", {}),
};

View File

@@ -1,30 +0,0 @@
export type ApiError = { status: number; message: string };
const base = "/api";
const KEY = "skymoney:userId";
export function getUserId(): string {
let id = localStorage.getItem(KEY);
if (!id) { id = "1"; localStorage.setItem(KEY, id); }
return id;
}
export function setUserId(id: string) {
localStorage.setItem(KEY, String(id || "1"));
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
headers: { "Content-Type": "application/json", "x-user-id": getUserId(), ...(init?.headers || {}) },
...init
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw { status: res.status, message: data?.message || res.statusText } as ApiError;
return data as T;
}
export const api = {
get: <T,>(path: string) => request<T>(path),
post: <T,>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
};

View File

@@ -1,4 +1,11 @@
import { request } from "./client";
import { apiDelete, apiGet, apiPatch, apiPost } from "./http";
export type PaymentScheduleInput = {
dayOfMonth?: number;
dayOfWeek?: number;
everyNDays?: number;
minFundingPercent?: number;
};
export type NewPlan = {
name: string;
@@ -6,22 +13,81 @@ export type NewPlan = {
fundedCents?: number; // optional, default 0
priority: number; // int
dueOn: string; // ISO date
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
autoPayEnabled?: boolean;
paymentSchedule?: PaymentScheduleInput | null;
nextPaymentDate?: string | null;
maxRetryAttempts?: number;
};
export type UpdatePlan = Partial<NewPlan>;
export const fixedPlansApi = {
create: (body: NewPlan) =>
request<{ id: number }>("/fixed-plans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
update: (id: number, body: UpdatePlan) =>
request(`/fixed-plans/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
delete: (id: number) =>
request(`/fixed-plans/${id}`, { method: "DELETE" }),
};
create: (body: NewPlan) => apiPost<{ id: string }>("/fixed-plans", body),
update: (id: string, body: UpdatePlan) =>
apiPatch(`/fixed-plans/${id}`, body),
delete: (id: string) => apiDelete(`/fixed-plans/${id}`),
due: (query?: { asOf?: string; daysAhead?: number }) =>
apiGet<{ items: Array<{ id: string; name: string; dueOn: string; remainingCents: number; percentFunded: number; isDue: boolean; isOverdue: boolean }>; asOfISO: string }>(
"/fixed-plans/due",
query as any
),
attemptFinalFunding: (id: string) =>
apiPost<{
ok: boolean;
planId: string;
status: "fully_funded" | "overdue";
fundedCents: number;
totalCents: number;
isOverdue: boolean;
overdueAmount?: number;
message: string;
}>(`/fixed-plans/${id}/attempt-final-funding`, {}),
payNow: (
id: string,
body: {
occurredAtISO?: string;
overrideDueOnISO?: string;
fundingSource?: "funded" | "savings" | "deficit";
savingsCategoryId?: string;
note?: string;
}
) =>
apiPost<{
ok: boolean;
planId: string;
transactionId: string;
nextDueOn: string | null;
savingsUsed: boolean;
deficitCovered: boolean;
shortageCents: number;
}>(`/fixed-plans/${id}/pay-now`, body),
markUnpaid: (id: string) =>
apiPatch<{
ok: boolean;
planId: string;
isOverdue: boolean;
overdueAmount: number;
}>(`/fixed-plans/${id}/mark-unpaid`, {}),
fundFromAvailable: (id: string) =>
apiPost<{
ok: boolean;
planId: string;
funded: boolean;
fundedAmountCents: number;
fundedCents: number;
totalCents: number;
availableBudget?: number;
message: string;
}>(`/fixed-plans/${id}/fund-from-available`, {}),
catchUpFunding: (id: string) =>
apiPost<{
ok: boolean;
planId: string;
funded: boolean;
fundedAmountCents: number;
fundedCents: number;
totalCents: number;
availableBudget?: number;
message: string;
}>(`/fixed-plans/${id}/catch-up-funding`, {}),
};

View File

@@ -2,13 +2,14 @@
const BASE =
(typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_URL) ||
""; // e.g. "http://localhost:8080" or proxy
"/api"; // default to proxy prefix when no explicit API URL
type FetchOpts = {
method?: "GET" | "POST" | "PATCH" | "DELETE";
headers?: Record<string, string>;
body?: any;
query?: Record<string, string | number | boolean | undefined>;
skipAuthRedirect?: boolean;
};
function toQS(q?: FetchOpts["query"]) {
@@ -22,19 +23,48 @@ function toQS(q?: FetchOpts["query"]) {
return s ? `?${s}` : "";
}
async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const { method = "GET", headers = {}, body, query } = opts;
function getCookie(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
const cookies = document.cookie.split(";").map((cookie) => cookie.trim());
for (const entry of cookies) {
if (!entry) continue;
const [key, ...rest] = entry.split("=");
if (key === name) {
return decodeURIComponent(rest.join("="));
}
}
return undefined;
}
export async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const {
method = "GET",
headers = {},
body,
query,
skipAuthRedirect = false,
} = opts;
const url = `${BASE}${path}${toQS(query)}`;
const hasBody = body !== undefined;
const requestHeaders: Record<string, string> = { ...headers };
// Only set Content-Type header if we have a body to send
if (hasBody) {
requestHeaders["Content-Type"] = "application/json";
}
if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
const csrfToken = getCookie("csrf");
if (csrfToken && !requestHeaders["x-csrf-token"]) {
requestHeaders["x-csrf-token"] = csrfToken;
}
}
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
// The API defaults x-user-id if missing; add your own if you want:
// "x-user-id": localStorage.getItem("userId") ?? "demo-user-1",
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
credentials: "include",
headers: requestHeaders,
body: hasBody ? JSON.stringify(body) : undefined,
});
// Try to parse JSON either way
@@ -42,11 +72,44 @@ async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const json = text ? JSON.parse(text) : null;
if (!res.ok) {
if (
res.status === 401 &&
!skipAuthRedirect &&
typeof window !== "undefined"
) {
const next = `${window.location.pathname}${window.location.search}`;
const encoded = encodeURIComponent(next);
const dest =
encoded && encoded !== "%2F" ? `/login?next=${encoded}` : "/login";
window.location.assign(dest);
}
const msg =
(json && (json.message || json.error)) ||
`${res.status} ${res.statusText}` ||
"Request failed";
throw new Error(msg);
const err = new Error(msg) as Error & {
status?: number;
code?: string;
data?: any;
overdraftAmount?: number;
categoryName?: string;
currentBalance?: number;
availableBudget?: number;
shortage?: number;
};
err.status = res.status;
if (json && typeof json === "object") {
const payload = json as any;
if (payload.code) err.code = payload.code;
err.data = payload;
if (payload.overdraftAmount !== undefined) err.overdraftAmount = payload.overdraftAmount;
if (payload.categoryName !== undefined) err.categoryName = payload.categoryName;
if (payload.currentBalance !== undefined) err.currentBalance = payload.currentBalance;
if (payload.availableBudget !== undefined) err.availableBudget = payload.availableBudget;
if (payload.shortage !== undefined) err.shortage = payload.shortage;
}
throw err;
}
return json as T;
}

View File

@@ -1,4 +1,4 @@
import { request } from "./client";
import { apiGet, apiPost } from "./http";
import { TransactionsList, type TransactionsListT } from "./schemas";
export type TxQuery = {
@@ -10,11 +10,26 @@ export type TxQuery = {
limit?: number; // default 20
};
export async function listTransactions(params: TxQuery): Promise<TransactionsListT> {
const u = new URL("/api/transactions", location.origin);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") u.searchParams.set(k, String(v));
}
const data = await request<unknown>(u.pathname + "?" + u.searchParams.toString());
export type CreateTransactionPayload = {
kind: "variable_spend" | "fixed_payment";
amountCents: number;
occurredAtISO: string;
categoryId?: string;
planId?: string;
note?: string;
receiptUrl?: string;
isReconciled?: boolean;
};
export async function listTransactions(
params: TxQuery
): Promise<TransactionsListT> {
const data = await apiGet<unknown>("/transactions", params);
return TransactionsList.parse(data);
}
}
export async function createTransaction(
payload: CreateTransactionPayload
): Promise<{ id: string; nextDueOn?: string }> {
return await apiPost("/transactions", payload);
}