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

View File

@@ -0,0 +1,44 @@
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.models.enums import AccountType
class AccountBase(BaseModel):
name: str = Field(min_length=1, max_length=100)
type: AccountType = AccountType.CHECKING
currency: str = Field(default="USD", min_length=3, max_length=3)
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
class AccountCreate(AccountBase):
pass
class AccountUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
type: AccountType | None = None
currency: str | None = Field(default=None, min_length=3, max_length=3)
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
is_archived: bool | None = None
class AccountRead(AccountBase):
"""Used wherever an account is embedded in another response (e.g.
TransactionRead.account) — deliberately balance-less so adding a field
here can never break an unrelated endpoint's serialization."""
model_config = ConfigDict(from_attributes=True)
id: int
is_archived: bool
class AccountWithBalance(AccountRead):
"""The Accounts page's shape — adds the live balance (see
services/account_service.py), summed from Transaction rows rather than
stored, the same "derive it" approach net_worth_service.py uses for
Cash. Used only by /api/accounts' own endpoints, never nested."""
balance: Decimal

View File

@@ -0,0 +1,15 @@
from typing import Literal
from pydantic import BaseModel
class AdviceItem(BaseModel):
key: str
tone: Literal["positive", "neutral", "warning"]
# Frontend-translated by key — same key+params pattern as FinancialAlert,
# extended to allow string params (category names) alongside numbers.
params: dict[str, str | int | float]
class AdviceResponse(BaseModel):
items: list[AdviceItem]

View File

@@ -0,0 +1,54 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.models.enums import AssetClass, CapitalRole, RiskLevel
class AssetValuationCreate(BaseModel):
value: Decimal = Field(ge=0, max_digits=14, decimal_places=2)
as_of_date: date_ = Field(default_factory=date_.today)
class AssetValuationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
value: Decimal
as_of_date: date_
class AssetBase(BaseModel):
name: str = Field(min_length=1, max_length=150)
asset_class: AssetClass
currency: str = Field(default="USD", min_length=3, max_length=3)
notes: str | None = None
capital_role: CapitalRole = CapitalRole.NEUTRAL
# Rough self-reported monthly net cash flow — informational, not tracked
# transactions (see Asset.monthly_cash_flow).
monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2)
risk_level: RiskLevel = RiskLevel.MEDIUM
class AssetCreate(AssetBase):
value: Decimal = Field(ge=0, max_digits=14, decimal_places=2)
as_of_date: date_ = Field(default_factory=date_.today)
class AssetUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=150)
asset_class: AssetClass | None = None
currency: str | None = Field(default=None, min_length=3, max_length=3)
notes: str | None = None
capital_role: CapitalRole | None = None
monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2)
risk_level: RiskLevel | None = None
class AssetRead(AssetBase):
model_config = ConfigDict(from_attributes=True)
id: int
current_value: Decimal
as_of_date: date_

View File

@@ -0,0 +1,166 @@
"""Shape of a full-database backup file (see services/backup_service.py)."""
from datetime import date as date_
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.models.enums import (
AccountType,
AssetClass,
CapitalRole,
CategoryKind,
RecurringFrequency,
RiskLevel,
TransactionType,
)
class AccountBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
type: AccountType
currency: str
color: str | None
is_archived: bool
class CategoryBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
kind: CategoryKind
icon: str | None
color: str
sort_order: int
is_default: bool
class TransactionBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int
category_id: int | None
transfer_account_id: int | None
type: TransactionType
amount: Decimal
description: str
merchant: str | None
notes: str | None
date: date_
class AssetBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
asset_class: AssetClass
currency: str
notes: str | None
# Defaulted so a backup exported before capital_role/monthly_cash_flow
# existed still imports cleanly under the same format version.
capital_role: CapitalRole = CapitalRole.NEUTRAL
monthly_cash_flow: Decimal | None = None
# Defaulted so a backup exported before risk_level existed still
# imports cleanly under the same format version.
risk_level: RiskLevel = RiskLevel.MEDIUM
class AssetValuationBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
asset_id: int
value: Decimal
as_of_date: date_
class BudgetBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
category_id: int
monthly_limit: Decimal
class GoalBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
target_amount: Decimal
target_date: date_ | None
class GoalContributionBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
goal_id: int
amount: Decimal
date: date_
note: str | None
class RecurringTransactionBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int
category_id: int | None
transfer_account_id: int | None
type: TransactionType
amount: Decimal
description: str
merchant: str | None
notes: str | None
frequency: RecurringFrequency
anchor_date: date_
last_posted_date: date_ | None
is_active: bool
class AppSettingsBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
currency: str
# Defaulted so a backup exported before these thresholds existed still
# imports cleanly under the same format version.
negative_cash_flow_threshold_months: int = 2
net_worth_decline_threshold_months: int = 2
# Defaulted so a backup exported before this threshold existed still
# imports cleanly under the same format version.
risky_allocation_threshold_percent: int = 20
class BackupPayload(BaseModel):
"""A full, portable snapshot of every table. `aurum_backup_version` is
checked on import so an incompatible/future file is rejected cleanly
instead of half-applied."""
aurum_backup_version: int
exported_at: datetime
app_version: str
accounts: list[AccountBackup]
categories: list[CategoryBackup]
transactions: list[TransactionBackup]
assets: list[AssetBackup]
asset_valuations: list[AssetValuationBackup]
# Defaulted so a backup exported before budgets existed still imports
# cleanly under the same format version.
budgets: list[BudgetBackup] = Field(default_factory=list)
# Defaulted so a backup exported before goals existed still imports
# cleanly under the same format version.
goals: list[GoalBackup] = Field(default_factory=list)
goal_contributions: list[GoalContributionBackup] = Field(default_factory=list)
# Defaulted so a backup exported before recurring transactions existed
# still imports cleanly under the same format version.
recurring_transactions: list[RecurringTransactionBackup] = Field(default_factory=list)
# Defaulted so a backup exported before the currency setting existed
# still imports cleanly under the same format version.
app_settings: AppSettingsBackup = Field(default_factory=lambda: AppSettingsBackup(currency="USD"))

View File

@@ -0,0 +1,45 @@
from decimal import Decimal
from pydantic import BaseModel, Field
class BudgetCreate(BaseModel):
category_id: int
monthly_limit: Decimal = Field(gt=0)
class BudgetUpdate(BaseModel):
monthly_limit: Decimal = Field(gt=0)
class BudgetRead(BaseModel):
id: int
category_id: int
category_name: str
category_color: str
category_icon: str | None
monthly_limit: Decimal
class BudgetStatus(BaseModel):
"""One budgeted category's actual spend for a given month, compared
against its limit."""
budget_id: int
category_id: int
category_name: str
category_color: str
category_icon: str | None
monthly_limit: Decimal
spent: Decimal
remaining: Decimal
# Can exceed 100 — the UI clamps the progress bar fill, not this number,
# so "spent 150% of budget" is still visible in the raw value.
percent: float
is_over_budget: bool
class BudgetStatusResponse(BaseModel):
year: int
month: int
items: list[BudgetStatus]

View File

@@ -0,0 +1,21 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel
class CashFlowPoint(BaseModel):
year: int
month: int
income: Decimal
expense: Decimal
net: Decimal
class CashFlowResponse(BaseModel):
start_date: date_ | None
end_date: date_ | None
points: list[CashFlowPoint]
total_income: Decimal
total_expense: Decimal
total_net: Decimal

View File

@@ -0,0 +1,29 @@
from pydantic import BaseModel, ConfigDict, Field
from app.models.enums import CategoryKind
class CategoryBase(BaseModel):
name: str = Field(min_length=1, max_length=100)
kind: CategoryKind
icon: str | None = None
color: str = Field(pattern=r"^#[0-9a-fA-F]{6}$")
sort_order: int = 0
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
icon: str | None = None
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
sort_order: int | None = None
class CategoryRead(CategoryBase):
model_config = ConfigDict(from_attributes=True)
id: int
is_default: bool

View File

@@ -0,0 +1,22 @@
from decimal import Decimal
from pydantic import BaseModel
class CategoryBreakdownItem(BaseModel):
category_id: int | None
name: str
color: str
icon: str | None
amount: Decimal
percent: float
class DashboardSummary(BaseModel):
year: int
month: int
real_income: Decimal
spent: Decimal
net: Decimal
transferred_out: Decimal
spending_by_category: list[CategoryBreakdownItem]

View File

@@ -0,0 +1,35 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel, Field
class GoalCreate(BaseModel):
name: str = Field(min_length=1, max_length=150)
target_amount: Decimal = Field(gt=0)
target_date: date_ | None = None
class GoalUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=150)
target_amount: Decimal | None = Field(default=None, gt=0)
target_date: date_ | None = None
class GoalContributionCreate(BaseModel):
# Negative allowed — a withdrawal from the goal is still a contribution
# to its running total, just in the other direction. Zero is pointless.
amount: Decimal = Field(ne=0)
date: date_
note: str | None = Field(default=None, max_length=200)
class GoalRead(BaseModel):
id: int
name: str
target_amount: Decimal
target_date: date_ | None
current_amount: Decimal
remaining: Decimal
percent: float
is_reached: bool

View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel
class FinancialAlert(BaseModel):
key: str
severity: str
# Machine-readable params for the frontend to interpolate into its own
# localized message template (keyed by `key`) — keeps alert copy out of
# the backend so it can be translated without an API change.
params: dict[str, int]
class AlertsResponse(BaseModel):
alerts: list[FinancialAlert]

View File

@@ -0,0 +1,66 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel
class NetWorthPoint(BaseModel):
date: date_
value: Decimal
class NetWorthBreakdownItem(BaseModel):
key: str
name: str
color: str
icon: str
amount: Decimal
percent: float
class CapitalRoleSummary(BaseModel):
"""Assets grouped by how they behave month to month (see CapitalRole) —
a cross-cut of the asset-class breakdown, not a replacement for it."""
role: str
label: str
color: str
total_value: Decimal
monthly_cash_flow: Decimal
count: int
class RiskLevelItem(BaseModel):
"""One holding within a risk tier — Cash (key="cash") or a single asset
(key=f"asset:{id}"). This list IS the diversification view: a tier with
one item at 100% is concentrated, several items with even shares aren't,
no separate score needed."""
key: str
name: str
amount: Decimal
percent: float # share of this tier, not of total capital
class RiskLevelSummary(BaseModel):
"""Cash + assets grouped by user-tagged risk of loss (see RiskLevel) —
another cross-cut, like capital_roles, but Cash participates here since
it's the zero-risk anchor an 80/20-style allocation rule needs."""
risk_level: str
label: str
color: str
total_value: Decimal
percent: float # share of total capital (cash + assets)
items: list[RiskLevelItem]
class NetWorthSummary(BaseModel):
range: str
current: Decimal
change_amount: Decimal
change_percent: float | None
series: list[NetWorthPoint]
breakdown: list[NetWorthBreakdownItem]
capital_roles: list[CapitalRoleSummary]
risk_levels: list[RiskLevelSummary]

View File

@@ -0,0 +1,58 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel, Field
from app.models.enums import RecurringFrequency, TransactionType
class RecurringTransactionCreate(BaseModel):
account_id: int
category_id: int | None = None
transfer_account_id: int | None = None
type: TransactionType
amount: Decimal = Field(gt=0)
description: str = Field(min_length=1, max_length=255)
merchant: str | None = None
notes: str | None = None
frequency: RecurringFrequency
anchor_date: date_
class RecurringTransactionUpdate(BaseModel):
account_id: int | None = None
category_id: int | None = None
transfer_account_id: int | None = None
type: TransactionType | None = None
amount: Decimal | None = Field(default=None, gt=0)
description: str | None = Field(default=None, min_length=1, max_length=255)
merchant: str | None = None
notes: str | None = None
frequency: RecurringFrequency | None = None
anchor_date: date_ | None = None
is_active: bool | None = None
class RecurringTransactionRead(BaseModel):
id: int
account_id: int
account_name: str
category_id: int | None
category_name: str | None
category_color: str | None
category_icon: str | None
transfer_account_id: int | None
transfer_account_name: str | None
type: TransactionType
amount: Decimal
description: str
merchant: str | None
notes: str | None
frequency: RecurringFrequency
anchor_date: date_
last_posted_date: date_ | None
is_active: bool
# Computed, not stored — see services/recurring_service.py.
next_due_date: date_
is_due: bool
days_until_due: int

View File

@@ -0,0 +1,40 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel
class CategorySpendingPoint(BaseModel):
year: int
month: int
amount: Decimal
class CategorySpendingReport(BaseModel):
category_id: int
category_name: str
category_color: str
category_icon: str | None
start_date: date_ | None
end_date: date_ | None
total_amount: Decimal
transaction_count: int
average_per_month: Decimal
series: list[CategorySpendingPoint]
class CategoryRankingItem(BaseModel):
category_id: int
name: str
color: str
icon: str | None
amount: Decimal
percent: float
transaction_count: int
class CategoryRankingReport(BaseModel):
start_date: date_ | None
end_date: date_ | None
total_amount: Decimal
items: list[CategoryRankingItem]

View File

@@ -0,0 +1,26 @@
from pydantic import BaseModel, Field
class AppSettingsRead(BaseModel):
currency: str
negative_cash_flow_threshold_months: int
net_worth_decline_threshold_months: int
risky_allocation_threshold_percent: int
class AppSettingsUpdate(BaseModel):
"""All fields optional — the route applies a partial update
(`exclude_unset`), same as CategoryUpdate, so a single-field PATCH from
e.g. CurrencyCard doesn't need to resend the alert thresholds."""
# ISO 4217 code, e.g. "USD"/"UAH"/"EUR" — the frontend only ever sends
# values from its curated currency list, but validate the shape anyway
# since Intl.NumberFormat would otherwise silently accept garbage.
currency: str | None = Field(default=None, min_length=3, max_length=3, pattern=r"^[A-Z]{3}$")
# Consecutive complete months before the corresponding alert fires.
# Capped at 24 to match insights_service.py's MAX_LOOKBACK_MONTHS.
negative_cash_flow_threshold_months: int | None = Field(default=None, ge=1, le=24)
net_worth_decline_threshold_months: int | None = Field(default=None, ge=1, le=24)
# Max % of capital allowed in medium/high risk tiers (the 80/20 rule's
# "20% at most exposed" half) before risky_allocation_exceeded fires.
risky_allocation_threshold_percent: int | None = Field(default=None, ge=1, le=100)

View File

@@ -0,0 +1,63 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.models.enums import TransactionType
from app.schemas.account import AccountRead
from app.schemas.category import CategoryRead
class TransactionBase(BaseModel):
account_id: int
category_id: int | None = None
transfer_account_id: int | None = None
type: TransactionType
amount: Decimal = Field(gt=0, max_digits=14, decimal_places=2)
description: str = Field(min_length=1, max_length=255)
merchant: str | None = Field(default=None, max_length=150)
notes: str | None = None
date: date_
@model_validator(mode="after")
def _validate_type_specific_fields(self) -> "TransactionBase":
if self.type == TransactionType.TRANSFER and not self.transfer_account_id:
raise ValueError("transfer_account_id is required for transfer transactions")
if self.type == TransactionType.TRANSFER and self.transfer_account_id == self.account_id:
raise ValueError("transfer_account_id must differ from account_id")
if self.type != TransactionType.TRANSFER and self.transfer_account_id:
raise ValueError("transfer_account_id is only valid for transfer transactions")
if self.type == TransactionType.TRANSFER and self.category_id:
raise ValueError("category_id is not valid for transfer transactions")
return self
class TransactionCreate(TransactionBase):
pass
class TransactionUpdate(BaseModel):
account_id: int | None = None
category_id: int | None = None
transfer_account_id: int | None = None
type: TransactionType | None = None
amount: Decimal | None = Field(default=None, gt=0, max_digits=14, decimal_places=2)
description: str | None = Field(default=None, min_length=1, max_length=255)
merchant: str | None = Field(default=None, max_length=150)
notes: str | None = None
date: date_ | None = None
class TransactionRead(TransactionBase):
model_config = ConfigDict(from_attributes=True)
id: int
account: AccountRead
category: CategoryRead | None = None
class TransactionPage(BaseModel):
items: list[TransactionRead]
total: int
page: int
page_size: int