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,21 @@
from app.models.account import Account
from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget
from app.models.category import Category
from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
from app.models.transaction import Transaction
__all__ = [
"Account",
"AppSettings",
"Asset",
"AssetValuation",
"Budget",
"Category",
"Goal",
"GoalContribution",
"RecurringTransaction",
"Transaction",
]

View File

@@ -0,0 +1,29 @@
"""An account is any place money lives: bank account, card, cash, wallet."""
from sqlalchemy import Boolean, Enum, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import AccountType
from app.models.mixins import TimestampMixin
class Account(Base, TimestampMixin):
__tablename__ = "accounts"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
type: Mapped[AccountType] = mapped_column(
Enum(AccountType, name="account_type", native_enum=False, length=20),
nullable=False,
default=AccountType.CHECKING,
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
# Hex color used for account-scoped UI accents (e.g. transaction list avatars).
color: Mapped[str | None] = mapped_column(String(7), nullable=True)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
transactions: Mapped[list["Transaction"]] = relationship(
back_populates="account",
foreign_keys="Transaction.account_id",
cascade="all, delete-orphan",
)

View File

@@ -0,0 +1,65 @@
"""A manually-tracked net-worth asset (investments, crypto, property, ...).
Cash is intentionally NOT an Asset — it's derived live from Account/Transaction
balances so there's a single source of truth for money that already flows
through the Transactions feature. See services/net_worth_service.py.
"""
from datetime import date as date_
from sqlalchemy import Date, Enum, ForeignKey, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import AssetClass, CapitalRole, RiskLevel
from app.models.mixins import TimestampMixin
class Asset(Base, TimestampMixin):
__tablename__ = "assets"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(150), nullable=False)
asset_class: Mapped[AssetClass] = mapped_column(
Enum(AssetClass, name="asset_class", native_enum=False, length=20), nullable=False
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# User-assigned, not inferred (see CapitalRole docstring).
capital_role: Mapped[CapitalRole] = mapped_column(
Enum(CapitalRole, name="capital_role", native_enum=False, length=10),
nullable=False,
default=CapitalRole.NEUTRAL,
)
# A rough, self-reported monthly net cash flow (rent collected, upkeep
# paid, ...). Informational only — real-world cash flow for an asset
# varies month to month, so this is never treated as ground truth the
# way Transaction amounts are.
monthly_cash_flow: Mapped[Numeric | None] = mapped_column(Numeric(14, 2), nullable=True)
# User-assigned, not inferred (see RiskLevel docstring) — how likely
# this asset is to lose value, independent of asset_class or capital_role.
risk_level: Mapped[RiskLevel] = mapped_column(
Enum(RiskLevel, name="risk_level", native_enum=False, length=10),
nullable=False,
default=RiskLevel.MEDIUM,
)
valuations: Mapped[list["AssetValuation"]] = relationship(
back_populates="asset", cascade="all, delete-orphan", order_by="AssetValuation.as_of_date"
)
class AssetValuation(Base):
"""One point-in-time value for an asset. The latest row (by as_of_date) is
the asset's current value; the full history drives the net-worth chart."""
__tablename__ = "asset_valuations"
__table_args__ = (UniqueConstraint("asset_id", "as_of_date", name="uq_asset_valuation_date"),)
id: Mapped[int] = mapped_column(primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), nullable=False)
value: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
as_of_date: Mapped[date_] = mapped_column(Date, nullable=False)
asset: Mapped["Asset"] = relationship(back_populates="valuations")

View File

@@ -0,0 +1,22 @@
"""A monthly spending limit for one expense category — compared against
actual spend for whichever month is being viewed (services/budget_service.py).
Not month-scoped itself: one limit per category, in effect until changed."""
from decimal import Decimal
from sqlalchemy import ForeignKey, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
class Budget(Base, TimestampMixin):
__tablename__ = "budgets"
id: Mapped[int] = mapped_column(primary_key=True)
category_id: Mapped[int] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False, unique=True
)
monthly_limit: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
category: Mapped["Category"] = relationship()

View File

@@ -0,0 +1,26 @@
"""A spending/income category, colored so it maps 1:1 to a dashboard chart slot."""
from sqlalchemy import Boolean, Enum, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import CategoryKind
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
kind: Mapped[CategoryKind] = mapped_column(
Enum(CategoryKind, name="category_kind", native_enum=False, length=10), nullable=False
)
# Lucide icon name rendered in the frontend (kept as a plain string so new
# icons don't require a migration).
icon: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Hex color drawn from the dataviz skill's validated categorical palette.
color: Mapped[str] = mapped_column(String(7), nullable=False)
# Fixed slot ordering keeps the donut chart's category order stable across renders.
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
transactions: Mapped[list["Transaction"]] = relationship(back_populates="category")

View File

@@ -0,0 +1,63 @@
"""Shared enum types used by the ORM models and API schemas."""
import enum
class AccountType(str, enum.Enum):
CHECKING = "checking"
SAVINGS = "savings"
CREDIT_CARD = "credit_card"
CASH = "cash"
INVESTMENT = "investment"
OTHER = "other"
class CategoryKind(str, enum.Enum):
INCOME = "income"
EXPENSE = "expense"
class TransactionType(str, enum.Enum):
INCOME = "income"
EXPENSE = "expense"
TRANSFER = "transfer"
class AssetClass(str, enum.Enum):
"""Net-worth categories tracked manually (Cash is derived from Account
balances instead — see services/net_worth_service.py)."""
INVESTMENTS = "investments"
CRYPTO = "crypto"
REAL_ESTATE = "real_estate"
VEHICLES = "vehicles"
PRECIOUS_METALS = "precious_metals"
OTHER = "other"
class CapitalRole(str, enum.Enum):
"""How an asset behaves month to month — set by the user, not inferred:
the same laptop can be a productive work tool (NEUTRAL) or dead weight
(DRAIN) depending on how it's actually used, which isn't derivable from
any stored data."""
INCOME = "income" # e.g. a rented-out apartment
NEUTRAL = "neutral" # e.g. a laptop used for work, furniture
DRAIN = "drain" # e.g. a personal car, an idle depreciating gadget
class RecurringFrequency(str, enum.Enum):
WEEKLY = "weekly"
MONTHLY = "monthly"
YEARLY = "yearly"
class RiskLevel(str, enum.Enum):
"""Risk of loss, not asset class — set by the user, not inferred: real
estate can be a paid-off primary home (LOW) or a leveraged rental
(HIGH), the same asset_class doesn't determine which. Cash is always
LOW (see services/net_worth_service.py) — it's the zero-risk anchor an
80/20-style allocation rule is measured against."""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"

View File

@@ -0,0 +1,38 @@
"""A savings goal, tracked by a manual contribution log (GoalContribution) —
same "you record it, we sum it" shape as Asset/AssetValuation, except
contributions are deltas added together, not point-in-time snapshots."""
from datetime import date as date_
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
class Goal(Base, TimestampMixin):
__tablename__ = "goals"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(150), nullable=False)
target_amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
target_date: Mapped[date_ | None] = mapped_column(Date, nullable=True)
contributions: Mapped[list["GoalContribution"]] = relationship(
back_populates="goal", cascade="all, delete-orphan", order_by="GoalContribution.date"
)
class GoalContribution(Base):
"""One deposit (or, with a negative amount, a withdrawal) toward a goal.
A goal's current amount is the sum of all its contributions."""
__tablename__ = "goal_contributions"
id: Mapped[int] = mapped_column(primary_key=True)
goal_id: Mapped[int] = mapped_column(ForeignKey("goals.id", ondelete="CASCADE"), nullable=False)
amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
date: Mapped[date_] = mapped_column(Date, nullable=False)
note: Mapped[str | None] = mapped_column(String(200), nullable=True)
goal: Mapped["Goal"] = relationship(back_populates="contributions")

View File

@@ -0,0 +1,14 @@
"""Reusable model mixins."""
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
class TimestampMixin:
"""Adds created_at / updated_at columns managed by the database itself."""
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

@@ -0,0 +1,49 @@
"""A template for a transaction that repeats on a schedule (rent, salary,
subscriptions). Posting is manual, one-click, not a background job — see
services/recurring_service.py for how next_due_date/is_due are computed
from anchor_date + last_posted_date, and how "Post now" creates a real
Transaction row from the template.
"""
from datetime import date as date_
from sqlalchemy import Boolean, Date, Enum, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import RecurringFrequency, TransactionType
from app.models.mixins import TimestampMixin
class RecurringTransaction(Base, TimestampMixin):
__tablename__ = "recurring_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
# Destination account for TRANSFER-type rows only — same shape as Transaction.
transfer_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
type: Mapped[TransactionType] = mapped_column(
Enum(TransactionType, name="recurring_transaction_type", native_enum=False, length=10), nullable=False
)
amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
description: Mapped[str] = mapped_column(String(255), nullable=False)
merchant: Mapped[str | None] = mapped_column(String(150), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
frequency: Mapped[RecurringFrequency] = mapped_column(
Enum(RecurringFrequency, name="recurring_frequency", native_enum=False, length=10), nullable=False
)
# First due date if never posted; also the day-of-month/weekday/month-day
# the schedule is anchored to (see services/recurring_service._advance).
anchor_date: Mapped[date_] = mapped_column(Date, nullable=False)
last_posted_date: Mapped[date_ | None] = mapped_column(Date, nullable=True)
# Pausing (e.g. a cancelled subscription you might resume) without
# losing the template and its posting history.
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
account: Mapped["Account"] = relationship(foreign_keys=[account_id])
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
category: Mapped["Category | None"] = relationship()

View File

@@ -0,0 +1,23 @@
"""App-wide configuration that isn't tied to any single account/asset — a
single-row table (id is always 1). Primary display currency (see UPDATES.md
for why this doesn't do currency conversion) and the proactive-alert
thresholds consumed by services/insights_service.py."""
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AppSettings(Base):
__tablename__ = "app_settings"
id: Mapped[int] = mapped_column(primary_key=True)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
# Consecutive complete months of negative cash flow / declining net worth
# before insights_service.py raises the corresponding alert.
negative_cash_flow_threshold_months: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
net_worth_decline_threshold_months: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
# Max % of total capital (cash + assets) allowed in medium/high risk
# tiers before insights_service.py raises risky_allocation_exceeded —
# the classic "80% at zero risk, 20% at most exposed" rule.
risky_allocation_threshold_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=20)

View File

@@ -0,0 +1,35 @@
"""A single money movement: income, expense, or a transfer between accounts."""
from datetime import date as date_
from sqlalchemy import Date, Enum, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import TransactionType
from app.models.mixins import TimestampMixin
class Transaction(Base, TimestampMixin):
__tablename__ = "transactions"
id: Mapped[int] = mapped_column(primary_key=True)
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
# Destination account for TRANSFER-type rows only.
transfer_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
type: Mapped[TransactionType] = mapped_column(
Enum(TransactionType, name="transaction_type", native_enum=False, length=10), nullable=False
)
# Always stored positive; `type` carries the sign/direction.
amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
description: Mapped[str] = mapped_column(String(255), nullable=False)
merchant: Mapped[str | None] = mapped_column(String(150), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
date: Mapped[date_] = mapped_column(Date, nullable=False)
account: Mapped["Account"] = relationship(back_populates="transactions", foreign_keys=[account_id])
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
category: Mapped["Category | None"] = relationship(back_populates="transactions")