mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 11:06:40 +07:00
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:
21
backend/app/models/__init__.py
Normal file
21
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
29
backend/app/models/account.py
Normal file
29
backend/app/models/account.py
Normal 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",
|
||||
)
|
||||
65
backend/app/models/asset.py
Normal file
65
backend/app/models/asset.py
Normal 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")
|
||||
22
backend/app/models/budget.py
Normal file
22
backend/app/models/budget.py
Normal 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()
|
||||
26
backend/app/models/category.py
Normal file
26
backend/app/models/category.py
Normal 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")
|
||||
63
backend/app/models/enums.py
Normal file
63
backend/app/models/enums.py
Normal 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"
|
||||
38
backend/app/models/goal.py
Normal file
38
backend/app/models/goal.py
Normal 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")
|
||||
14
backend/app/models/mixins.py
Normal file
14
backend/app/models/mixins.py
Normal 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()
|
||||
)
|
||||
49
backend/app/models/recurring.py
Normal file
49
backend/app/models/recurring.py
Normal 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()
|
||||
23
backend/app/models/settings.py
Normal file
23
backend/app/models/settings.py
Normal 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)
|
||||
35
backend/app/models/transaction.py
Normal file
35
backend/app/models/transaction.py
Normal 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")
|
||||
Reference in New Issue
Block a user