final touches for beta skymoney (at least i think)
This commit is contained in:
77
web/src/api/budget.ts
Normal file
77
web/src/api/budget.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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", {}),
|
||||
};
|
||||
|
||||
@@ -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) }),
|
||||
};
|
||||
@@ -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`, {}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user