Initial commit: MyMidas personal finance tracker
Full-stack self-hosted finance app with FastAPI backend and React frontend. Features: - Accounts, transactions, budgets, investments with GBP base currency - CSV import with auto-detection for 10 UK bank formats - ML predictions: spending forecast, net worth projection, Monte Carlo - 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger) - Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF) - AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log - Encrypted nightly backups + key rotation script - Mobile-responsive layout with bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
61a7884ee5
127 changed files with 13323 additions and 0 deletions
107
frontend/src/api/accounts.ts
Normal file
107
frontend/src/api/accounts.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { api } from "./client";
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
institution: string | null;
|
||||
type: string;
|
||||
currency: string;
|
||||
current_balance: number;
|
||||
credit_limit: number | null;
|
||||
interest_rate: number | null;
|
||||
is_active: boolean;
|
||||
include_in_net_worth: boolean;
|
||||
color: string;
|
||||
icon: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AccountCreate {
|
||||
name: string;
|
||||
institution?: string;
|
||||
type: string;
|
||||
currency?: string;
|
||||
credit_limit?: number;
|
||||
interest_rate?: number;
|
||||
include_in_net_worth?: boolean;
|
||||
color?: string;
|
||||
opening_balance?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export async function getAccounts(): Promise<Account[]> {
|
||||
const res = await api.get("/accounts");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createAccount(data: AccountCreate): Promise<Account> {
|
||||
const res = await api.post("/accounts", data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateAccount(id: string, data: Partial<AccountCreate>): Promise<Account> {
|
||||
const res = await api.put(`/accounts/${id}`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteAccount(id: string): Promise<void> {
|
||||
await api.delete(`/accounts/${id}`);
|
||||
}
|
||||
|
||||
export interface CsvMapping {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: string | null;
|
||||
debit: string | null;
|
||||
credit: string | null;
|
||||
balance: string | null;
|
||||
reference: string | null;
|
||||
}
|
||||
|
||||
export interface ImportPreview {
|
||||
detected_format: string | null;
|
||||
headers: string[];
|
||||
mapping: CsvMapping;
|
||||
total_rows: number;
|
||||
preview: {
|
||||
date_raw: string;
|
||||
description_raw: string;
|
||||
amount_raw: number | null;
|
||||
balance_raw?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function previewImport(accountId: string, file: File): Promise<ImportPreview> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await api.post(`/accounts/${accountId}/import/preview`, form);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function importCsvToAccount(
|
||||
accountId: string,
|
||||
file: File,
|
||||
mapping: CsvMapping
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("date_col", mapping.date);
|
||||
form.append("description_col", mapping.description);
|
||||
form.append("amount_col", mapping.amount ?? "");
|
||||
form.append("debit_col", mapping.debit ?? "");
|
||||
form.append("credit_col", mapping.credit ?? "");
|
||||
const res = await api.post(`/accounts/${accountId}/import`, form);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getNetWorth(): Promise<{
|
||||
total_assets: number;
|
||||
total_liabilities: number;
|
||||
net_worth: number;
|
||||
base_currency: string;
|
||||
}> {
|
||||
const res = await api.get("/accounts/net-worth");
|
||||
return res.data;
|
||||
}
|
||||
78
frontend/src/api/auth.ts
Normal file
78
frontend/src/api/auth.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { api } from "./client";
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
totp_required?: boolean;
|
||||
challenge_token?: string;
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<LoginResponse> {
|
||||
const res = await api.post("/auth/login", { email, password });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function loginTotp(challengeToken: string, totpCode: string): Promise<LoginResponse> {
|
||||
const res = await api.post("/auth/login/totp", {
|
||||
challenge_token: challengeToken,
|
||||
totp_code: totpCode,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post("/auth/logout");
|
||||
}
|
||||
|
||||
export async function getMe() {
|
||||
const res = await api.get("/users/me");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getTotpSetup() {
|
||||
const res = await api.get("/auth/totp/setup");
|
||||
return res.data as { secret: string; qr_code_png_b64: string; backup_codes: string[] };
|
||||
}
|
||||
|
||||
export async function enableTotp(secret: string, code: string): Promise<void> {
|
||||
await api.post("/auth/totp/enable", { secret, code });
|
||||
}
|
||||
|
||||
export async function getSessions() {
|
||||
const res = await api.get("/auth/sessions");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string): Promise<void> {
|
||||
await api.delete(`/auth/sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
export async function revokeAllSessions(): Promise<void> {
|
||||
await api.post("/auth/logout-all");
|
||||
}
|
||||
|
||||
export async function disableTotp(password: string): Promise<void> {
|
||||
await api.delete("/auth/totp", { data: { password } });
|
||||
}
|
||||
|
||||
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
await api.post("/users/me/password", {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProfile(data: { display_name?: string; base_currency?: string }): Promise<void> {
|
||||
await api.put("/users/me", data);
|
||||
}
|
||||
|
||||
export async function exportData(): Promise<void> {
|
||||
const res = await api.get("/users/me/export", { responseType: "blob" });
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `transactions_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
65
frontend/src/api/budgets.ts
Normal file
65
frontend/src/api/budgets.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { api } from "./client";
|
||||
|
||||
export interface Budget {
|
||||
id: string;
|
||||
category_id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
period: string;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
rollover: boolean;
|
||||
alert_threshold: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BudgetSummaryItem {
|
||||
budget_id: string;
|
||||
budget_name: string;
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
period: string;
|
||||
budget_amount: number;
|
||||
spent_amount: number;
|
||||
remaining_amount: number;
|
||||
percent_used: number;
|
||||
is_over_budget: boolean;
|
||||
alert_triggered: boolean;
|
||||
currency: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
}
|
||||
|
||||
export interface BudgetCreate {
|
||||
category_id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
period: "weekly" | "monthly" | "quarterly" | "yearly";
|
||||
start_date: string;
|
||||
end_date?: string | null;
|
||||
rollover?: boolean;
|
||||
alert_threshold?: number;
|
||||
}
|
||||
|
||||
export async function getBudgets(activeOnly = true): Promise<Budget[]> {
|
||||
const r = await api.get("/api/v1/budgets", { params: { active_only: activeOnly } });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function getBudgetSummary(): Promise<BudgetSummaryItem[]> {
|
||||
const r = await api.get("/api/v1/budgets/summary");
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function createBudget(data: BudgetCreate): Promise<Budget> {
|
||||
const r = await api.post("/api/v1/budgets", data);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function deleteBudget(id: string): Promise<void> {
|
||||
await api.delete(`/api/v1/budgets/${id}`);
|
||||
}
|
||||
56
frontend/src/api/client.ts
Normal file
56
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import axios from "axios";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: "/api/v1",
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Attach bearer token + CSRF header to every request
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// CSRF double-submit: read cookie and send as header
|
||||
const csrfCookie = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("csrf_token="))
|
||||
?.split("=")[1];
|
||||
if (csrfCookie && config.method !== "get") {
|
||||
config.headers["X-CSRF-Token"] = csrfCookie;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// Auto-refresh on 401
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const original = error.config;
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true;
|
||||
try {
|
||||
const res = await axios.post(
|
||||
"/api/v1/auth/refresh",
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
const { access_token } = res.data;
|
||||
// Update store - we need to get current user info from the existing token
|
||||
const store = useAuthStore.getState();
|
||||
if (store.userId && store.displayName) {
|
||||
store.setToken(access_token, store.userId, store.displayName);
|
||||
}
|
||||
original.headers.Authorization = `Bearer ${access_token}`;
|
||||
return api(original);
|
||||
} catch {
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
111
frontend/src/api/investments.ts
Normal file
111
frontend/src/api/investments.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { api } from "./client";
|
||||
|
||||
export interface AssetSearchResult {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
type: string;
|
||||
currency: string;
|
||||
exchange: string | null;
|
||||
last_price: number | null;
|
||||
price_change_24h: number | null;
|
||||
data_source: string;
|
||||
}
|
||||
|
||||
export interface HoldingResponse {
|
||||
id: string;
|
||||
account_id: string;
|
||||
asset_id: string;
|
||||
symbol: string;
|
||||
asset_name: string;
|
||||
asset_type: string;
|
||||
quantity: number;
|
||||
avg_cost_basis: number;
|
||||
current_price: number | null;
|
||||
current_value: number | null;
|
||||
cost_basis_total: number;
|
||||
unrealised_gain: number | null;
|
||||
unrealised_gain_pct: number | null;
|
||||
currency: string;
|
||||
price_change_24h: number | null;
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
total_value: number;
|
||||
total_cost: number;
|
||||
total_gain: number;
|
||||
total_gain_pct: number;
|
||||
currency: string;
|
||||
holdings: HoldingResponse[];
|
||||
}
|
||||
|
||||
export interface InvestmentTxn {
|
||||
id: string;
|
||||
holding_id: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fees: number;
|
||||
total_amount: number;
|
||||
currency: string;
|
||||
date: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PricePoint {
|
||||
date: string;
|
||||
open: number | null;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
close: number;
|
||||
volume: number | null;
|
||||
}
|
||||
|
||||
export async function getPortfolio(): Promise<PortfolioSummary> {
|
||||
const r = await api.get("/api/v1/investments/portfolio");
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function searchAssets(q: string): Promise<AssetSearchResult[]> {
|
||||
const r = await api.get("/api/v1/assets/search", { params: { q } });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function getPriceHistory(assetId: string, days = 365): Promise<PricePoint[]> {
|
||||
const r = await api.get(`/api/v1/assets/${assetId}/prices`, { params: { days } });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function createHolding(data: {
|
||||
account_id: string;
|
||||
asset_id: string;
|
||||
quantity: number;
|
||||
avg_cost_basis: number;
|
||||
currency?: string;
|
||||
}): Promise<HoldingResponse> {
|
||||
const r = await api.post("/api/v1/investments/holdings", data);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function deleteHolding(id: string): Promise<void> {
|
||||
await api.delete(`/api/v1/investments/holdings/${id}`);
|
||||
}
|
||||
|
||||
export async function addInvestmentTransaction(data: {
|
||||
holding_id: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fees?: number;
|
||||
currency?: string;
|
||||
date: string;
|
||||
notes?: string;
|
||||
}): Promise<InvestmentTxn> {
|
||||
const r = await api.post("/api/v1/investments/transactions", data);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function getHoldingTransactions(holdingId: string): Promise<InvestmentTxn[]> {
|
||||
const r = await api.get(`/api/v1/investments/holdings/${holdingId}/transactions`);
|
||||
return r.data;
|
||||
}
|
||||
109
frontend/src/api/predictions.ts
Normal file
109
frontend/src/api/predictions.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { api } from "./client";
|
||||
|
||||
export interface CategoryForecast {
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
monthly_avg: number;
|
||||
actuals: { date: string; amount: number }[];
|
||||
forecast: { date: string; amount: number; lower: number; upper: number }[];
|
||||
}
|
||||
|
||||
export interface SpendingForecastResponse {
|
||||
categories: CategoryForecast[];
|
||||
}
|
||||
|
||||
export interface NetWorthProjectionResponse {
|
||||
history: { date: string; value: number }[];
|
||||
projections: {
|
||||
conservative: { date: string; value: number }[];
|
||||
base: { date: string; value: number }[];
|
||||
optimistic: { date: string; value: number }[];
|
||||
};
|
||||
insufficient_data: boolean;
|
||||
}
|
||||
|
||||
export interface PercentilePath {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface MonteCarloResponse {
|
||||
dates: string[];
|
||||
percentiles: {
|
||||
p10: PercentilePath[];
|
||||
p25: PercentilePath[];
|
||||
p50: PercentilePath[];
|
||||
p75: PercentilePath[];
|
||||
p90: PercentilePath[];
|
||||
};
|
||||
current_value: number;
|
||||
expected_value: number;
|
||||
probability_of_gain: number;
|
||||
insufficient_data: boolean;
|
||||
}
|
||||
|
||||
export interface BudgetForecastItem {
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
budget_amount: number;
|
||||
spent_so_far: number;
|
||||
forecast_month_total: number;
|
||||
daily_velocity: number;
|
||||
probability_overspend: number;
|
||||
days_remaining: number;
|
||||
}
|
||||
|
||||
export interface BudgetForecastResponse {
|
||||
forecasts: BudgetForecastItem[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CashFlowDay {
|
||||
date: string;
|
||||
balance: number;
|
||||
avg_inflow: number;
|
||||
avg_outflow: number;
|
||||
negative_risk: boolean;
|
||||
}
|
||||
|
||||
export interface CashFlowResponse {
|
||||
current_balance: number;
|
||||
avg_daily_inflow: number;
|
||||
avg_daily_outflow: number;
|
||||
forecast: CashFlowDay[];
|
||||
negative_risk_days: string[];
|
||||
history_days: number;
|
||||
}
|
||||
|
||||
export async function getSpendingForecast(): Promise<SpendingForecastResponse> {
|
||||
const res = await api.get("/predictions/spending");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getNetWorthProjection(years = 5): Promise<NetWorthProjectionResponse> {
|
||||
const res = await api.get("/predictions/net-worth", { params: { years } });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function postMonteCarlo(params: {
|
||||
years?: number;
|
||||
n_simulations?: number;
|
||||
annual_contribution?: number;
|
||||
}): Promise<MonteCarloResponse> {
|
||||
const res = await api.post("/predictions/monte-carlo", {
|
||||
years: params.years ?? 5,
|
||||
n_simulations: params.n_simulations ?? 1000,
|
||||
annual_contribution: params.annual_contribution ?? 0,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getBudgetForecast(): Promise<BudgetForecastResponse> {
|
||||
const res = await api.get("/predictions/budget-forecast");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCashFlowForecast(): Promise<CashFlowResponse> {
|
||||
const res = await api.get("/predictions/cashflow");
|
||||
return res.data;
|
||||
}
|
||||
123
frontend/src/api/reports.ts
Normal file
123
frontend/src/api/reports.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { api } from "./client";
|
||||
|
||||
export interface NetWorthPoint {
|
||||
date: string;
|
||||
total_assets: number;
|
||||
total_liabilities: number;
|
||||
net_worth: number;
|
||||
base_currency: string;
|
||||
}
|
||||
|
||||
export interface NetWorthReport {
|
||||
points: NetWorthPoint[];
|
||||
current_net_worth: number;
|
||||
change_30d: number;
|
||||
change_30d_pct: number;
|
||||
base_currency: string;
|
||||
}
|
||||
|
||||
export interface IncomeExpensePoint {
|
||||
month: string;
|
||||
income: number;
|
||||
expenses: number;
|
||||
net: number;
|
||||
}
|
||||
|
||||
export interface IncomeExpenseReport {
|
||||
points: IncomeExpensePoint[];
|
||||
total_income: number;
|
||||
total_expenses: number;
|
||||
avg_monthly_income: number;
|
||||
avg_monthly_expenses: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface CashFlowPoint {
|
||||
date: string;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
net: number;
|
||||
running_balance: number;
|
||||
}
|
||||
|
||||
export interface CashFlowReport {
|
||||
points: CashFlowPoint[];
|
||||
total_inflow: number;
|
||||
total_outflow: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface CategoryBreakdownItem {
|
||||
category_id: string | null;
|
||||
category_name: string;
|
||||
amount: number;
|
||||
percent: number;
|
||||
transaction_count: number;
|
||||
}
|
||||
|
||||
export interface CategoryBreakdownReport {
|
||||
items: CategoryBreakdownItem[];
|
||||
total: number;
|
||||
currency: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
}
|
||||
|
||||
export interface BudgetVsActualReport {
|
||||
items: Array<{
|
||||
budget_id: string;
|
||||
budget_name: string;
|
||||
category_name: string;
|
||||
budgeted: number;
|
||||
actual: number;
|
||||
variance: number;
|
||||
percent_used: number;
|
||||
}>;
|
||||
total_budgeted: number;
|
||||
total_actual: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface SpendingTrendsReport {
|
||||
points: Array<{ month: string; category_name: string; amount: number }>;
|
||||
categories: string[];
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export async function getNetWorthReport(months = 12): Promise<NetWorthReport> {
|
||||
const r = await api.get("/api/v1/reports/net-worth", { params: { months } });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function getIncomeExpenseReport(months = 12): Promise<IncomeExpenseReport> {
|
||||
const r = await api.get("/api/v1/reports/income-vs-expense", { params: { months } });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function getCashFlowReport(dateFrom?: string, dateTo?: string): Promise<CashFlowReport> {
|
||||
const r = await api.get("/api/v1/reports/cash-flow", {
|
||||
params: { date_from: dateFrom, date_to: dateTo },
|
||||
});
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function getCategoryBreakdown(
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
type = "expense"
|
||||
): Promise<CategoryBreakdownReport> {
|
||||
const r = await api.get("/api/v1/reports/category-breakdown", {
|
||||
params: { date_from: dateFrom, date_to: dateTo, type },
|
||||
});
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function getBudgetVsActual(): Promise<BudgetVsActualReport> {
|
||||
const r = await api.get("/api/v1/reports/budget-vs-actual");
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function getSpendingTrends(months = 6): Promise<SpendingTrendsReport> {
|
||||
const r = await api.get("/api/v1/reports/spending-trends", { params: { months } });
|
||||
return r.data;
|
||||
}
|
||||
128
frontend/src/api/transactions.ts
Normal file
128
frontend/src/api/transactions.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { api } from "./client";
|
||||
|
||||
export interface AttachmentRef {
|
||||
id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
stored_name: string;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
account_id: string;
|
||||
transfer_account_id: string | null;
|
||||
category_id: string | null;
|
||||
type: "income" | "expense" | "transfer" | "investment";
|
||||
status: "pending" | "cleared" | "reconciled" | "void";
|
||||
amount: number;
|
||||
amount_base: number | null;
|
||||
currency: string;
|
||||
base_currency: string;
|
||||
date: string;
|
||||
description: string;
|
||||
merchant: string | null;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
is_recurring: boolean;
|
||||
attachment_refs: AttachmentRef[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TransactionCreate {
|
||||
account_id: string;
|
||||
transfer_account_id?: string;
|
||||
category_id?: string;
|
||||
type: Transaction["type"];
|
||||
status?: Transaction["status"];
|
||||
amount: number;
|
||||
currency?: string;
|
||||
date: string;
|
||||
description: string;
|
||||
merchant?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface TransactionPage {
|
||||
items: Transaction[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
account_id?: string;
|
||||
category_id?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export async function getTransactions(filters: TransactionFilters = {}): Promise<TransactionPage> {
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(filters).filter(([, v]) => v !== undefined && v !== "")
|
||||
);
|
||||
const res = await api.get("/transactions", { params });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createTransaction(data: TransactionCreate): Promise<Transaction> {
|
||||
const res = await api.post("/transactions", data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateTransaction(id: string, data: Partial<TransactionCreate>): Promise<Transaction> {
|
||||
const res = await api.put(`/transactions/${id}`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteTransaction(id: string): Promise<void> {
|
||||
await api.delete(`/transactions/${id}`);
|
||||
}
|
||||
|
||||
export async function getCategories(): Promise<{ id: string; name: string; type: string; icon: string | null; color: string | null }[]> {
|
||||
const res = await api.get("/categories");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function importCsv(
|
||||
file: File,
|
||||
accountId: string,
|
||||
colMap: { date: string; description: string; amount: string }
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("account_id", accountId);
|
||||
form.append("date_col", colMap.date);
|
||||
form.append("description_col", colMap.description);
|
||||
form.append("amount_col", colMap.amount);
|
||||
const res = await api.post("/transactions/import", form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function uploadAttachment(txnId: string, file: File): Promise<AttachmentRef> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await api.post(`/transactions/${txnId}/attachments`, form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export function getAttachmentUrl(txnId: string, attachmentId: string): string {
|
||||
const base = (api.defaults.baseURL ?? "").replace(/\/$/, "");
|
||||
return `${base}/transactions/${txnId}/attachments/${attachmentId}`;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(txnId: string, attachmentId: string): Promise<void> {
|
||||
await api.delete(`/transactions/${txnId}/attachments/${attachmentId}`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue