Initial public release

Aurum — a self-hosted personal finance operating system: cash flow,
net worth tracking, budgets, goals, recurring payments, reports,
proactive alerts, and a compound-interest returns calculator, all
bilingual (RU/EN) and mobile-first. Ships as a three-container Docker
Compose stack (Postgres + FastAPI + nginx-served React frontend).

See README.md for setup and CONTRIBUTING.md for development.
This commit is contained in:
ZProger
2026-08-16 18:39:52 +03:00
commit fe9b079c12
199 changed files with 14410 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import { api } from "@/api/client";
import type { AccountInput, AccountWithBalance } from "@/types";
export function fetchAccounts(includeArchived = false) {
return api.get<AccountWithBalance[]>(`/accounts?include_archived=${includeArchived}`);
}
export function createAccount(input: AccountInput) {
return api.post<AccountWithBalance>("/accounts", input);
}
export function updateAccount(id: number, input: Partial<AccountInput> & { is_archived?: boolean }) {
return api.patch<AccountWithBalance>(`/accounts/${id}`, input);
}
export function deleteAccount(id: number) {
return api.delete<void>(`/accounts/${id}`);
}

View File

@@ -0,0 +1,6 @@
import { api } from "@/api/client";
import type { AdviceResponse } from "@/types";
export function fetchAdvice() {
return api.get<AdviceResponse>("/advice");
}

View File

@@ -0,0 +1,22 @@
import { api } from "@/api/client";
import type { Asset, AssetInput, AssetUpdateInput, AssetValuationInput } from "@/types";
export function fetchAssets() {
return api.get<Asset[]>("/assets");
}
export function createAsset(input: AssetInput) {
return api.post<Asset>("/assets", input);
}
export function updateAsset(id: number, input: AssetUpdateInput) {
return api.patch<Asset>(`/assets/${id}`, input);
}
export function addAssetValuation(id: number, input: AssetValuationInput) {
return api.post<Asset>(`/assets/${id}/valuations`, input);
}
export function deleteAsset(id: number) {
return api.delete<void>(`/assets/${id}`);
}

View File

@@ -0,0 +1,31 @@
import { api } from "@/api/client";
import { t } from "@/lib/i18n";
export async function exportBackup(): Promise<void> {
const response = await fetch("/api/backup/export");
if (!response.ok) {
throw new Error(await response.text());
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const date = new Date().toISOString().slice(0, 10);
const link = document.createElement("a");
link.href = url;
link.download = `aurum-backup-${date}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export async function importBackup(file: File): Promise<void> {
const text = await file.text();
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
throw new Error(t("backup.invalidFile"));
}
await api.post<{ status: string }>("/backup/import", payload);
}

View File

@@ -0,0 +1,22 @@
import { api } from "@/api/client";
import type { Budget, BudgetInput, BudgetStatusResponse } from "@/types";
export function fetchBudgets() {
return api.get<Budget[]>("/budgets");
}
export function fetchBudgetStatus(year: number, month: number) {
return api.get<BudgetStatusResponse>(`/budgets/status?year=${year}&month=${month}`);
}
export function createBudget(input: BudgetInput) {
return api.post<Budget>("/budgets", input);
}
export function updateBudget(id: number, monthlyLimit: string) {
return api.patch<Budget>(`/budgets/${id}`, { monthly_limit: monthlyLimit });
}
export function deleteBudget(id: number) {
return api.delete<void>(`/budgets/${id}`);
}

View File

@@ -0,0 +1,10 @@
import { api } from "@/api/client";
import type { CashFlowResponse } from "@/types";
export function fetchCashFlow(startDate?: string, endDate?: string) {
const params = new URLSearchParams();
if (startDate) params.set("start_date", startDate);
if (endDate) params.set("end_date", endDate);
const query = params.toString();
return api.get<CashFlowResponse>(`/cash-flow${query ? `?${query}` : ""}`);
}

View File

@@ -0,0 +1,6 @@
import { api } from "@/api/client";
import type { Category } from "@/types";
export function fetchCategories() {
return api.get<Category[]>("/categories");
}

View File

@@ -0,0 +1,43 @@
const API_BASE = "/api";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...init,
});
if (!response.ok) {
const body = await response.text();
let message = body || response.statusText;
try {
const parsed = JSON.parse(body) as { detail?: unknown };
if (typeof parsed.detail === "string") message = parsed.detail;
} catch {
// body wasn't JSON — fall back to the raw text set above
}
throw new ApiError(response.status, message);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) 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) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};

View File

@@ -0,0 +1,6 @@
import { api } from "@/api/client";
import type { DashboardSummary } from "@/types";
export function fetchDashboardSummary(year: number, month: number) {
return api.get<DashboardSummary>(`/dashboard/summary?year=${year}&month=${month}`);
}

22
frontend/src/api/goals.ts Normal file
View File

@@ -0,0 +1,22 @@
import { api } from "@/api/client";
import type { Goal, GoalContributionInput, GoalInput } from "@/types";
export function fetchGoals() {
return api.get<Goal[]>("/goals");
}
export function createGoal(input: GoalInput) {
return api.post<Goal>("/goals", input);
}
export function updateGoal(id: number, input: Partial<GoalInput>) {
return api.patch<Goal>(`/goals/${id}`, input);
}
export function deleteGoal(id: number) {
return api.delete<void>(`/goals/${id}`);
}
export function addGoalContribution(id: number, input: GoalContributionInput) {
return api.post<Goal>(`/goals/${id}/contributions`, input);
}

View File

@@ -0,0 +1,6 @@
import { api } from "@/api/client";
import type { FinancialAlert } from "@/types";
export function fetchFinancialAlerts() {
return api.get<{ alerts: FinancialAlert[] }>("/insights/alerts");
}

View File

@@ -0,0 +1,6 @@
import { api } from "@/api/client";
import type { NetWorthRange, NetWorthSummary } from "@/types";
export function fetchNetWorthSummary(range: NetWorthRange) {
return api.get<NetWorthSummary>(`/net-worth/summary?range=${range}`);
}

View File

@@ -0,0 +1,22 @@
import { api } from "@/api/client";
import type { RecurringTransaction, RecurringTransactionInput } from "@/types";
export function fetchRecurring() {
return api.get<RecurringTransaction[]>("/recurring");
}
export function createRecurring(input: RecurringTransactionInput) {
return api.post<RecurringTransaction>("/recurring", input);
}
export function updateRecurring(id: number, input: Partial<RecurringTransactionInput>) {
return api.patch<RecurringTransaction>(`/recurring/${id}`, input);
}
export function deleteRecurring(id: number) {
return api.delete<void>(`/recurring/${id}`);
}
export function postRecurring(id: number) {
return api.post<RecurringTransaction>(`/recurring/${id}/post`, {});
}

View File

@@ -0,0 +1,16 @@
import { api } from "@/api/client";
import type { CategoryKind, CategoryRankingReport, CategorySpendingReport } from "@/types";
export function fetchCategorySpendingReport(categoryId: number, startDate?: string, endDate?: string) {
const params = new URLSearchParams({ category_id: String(categoryId) });
if (startDate) params.set("start_date", startDate);
if (endDate) params.set("end_date", endDate);
return api.get<CategorySpendingReport>(`/reports/category-spending?${params.toString()}`);
}
export function fetchCategoryRanking(kind: CategoryKind, startDate?: string, endDate?: string) {
const params = new URLSearchParams({ kind });
if (startDate) params.set("start_date", startDate);
if (endDate) params.set("end_date", endDate);
return api.get<CategoryRankingReport>(`/reports/category-ranking?${params.toString()}`);
}

View File

@@ -0,0 +1,12 @@
import { api } from "@/api/client";
import type { AppSettings } from "@/types";
export function fetchSettings() {
return api.get<AppSettings>("/settings");
}
// Partial — the backend applies whatever fields are present (PATCH
// semantics), so callers only send the field(s) they're changing.
export function updateSettings(input: Partial<AppSettings>) {
return api.patch<AppSettings>("/settings", input);
}

View File

@@ -0,0 +1,37 @@
import { api } from "@/api/client";
import type { Transaction, TransactionInput, TransactionPage } from "@/types";
export type TransactionSort = "date_desc" | "amount_desc" | "amount_asc";
export interface TransactionFilters {
year?: number;
month?: number;
start_date?: string;
end_date?: string;
account_id?: number;
category_id?: number;
type?: string;
sort?: TransactionSort;
page?: number;
page_size?: number;
}
export function fetchTransactions(filters: TransactionFilters = {}) {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null) params.set(key, String(value));
});
return api.get<TransactionPage>(`/transactions?${params.toString()}`);
}
export function createTransaction(input: TransactionInput) {
return api.post<Transaction>("/transactions", input);
}
export function updateTransaction(id: number, input: Partial<TransactionInput>) {
return api.patch<Transaction>(`/transactions/${id}`, input);
}
export function deleteTransaction(id: number) {
return api.delete<void>(`/transactions/${id}`);
}