mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +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:
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
44
backend/app/schemas/account.py
Normal file
44
backend/app/schemas/account.py
Normal 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
|
||||
15
backend/app/schemas/advice.py
Normal file
15
backend/app/schemas/advice.py
Normal 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]
|
||||
54
backend/app/schemas/asset.py
Normal file
54
backend/app/schemas/asset.py
Normal 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_
|
||||
166
backend/app/schemas/backup.py
Normal file
166
backend/app/schemas/backup.py
Normal 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"))
|
||||
45
backend/app/schemas/budget.py
Normal file
45
backend/app/schemas/budget.py
Normal 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]
|
||||
21
backend/app/schemas/cash_flow.py
Normal file
21
backend/app/schemas/cash_flow.py
Normal 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
|
||||
29
backend/app/schemas/category.py
Normal file
29
backend/app/schemas/category.py
Normal 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
|
||||
22
backend/app/schemas/dashboard.py
Normal file
22
backend/app/schemas/dashboard.py
Normal 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]
|
||||
35
backend/app/schemas/goal.py
Normal file
35
backend/app/schemas/goal.py
Normal 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
|
||||
14
backend/app/schemas/insights.py
Normal file
14
backend/app/schemas/insights.py
Normal 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]
|
||||
66
backend/app/schemas/net_worth.py
Normal file
66
backend/app/schemas/net_worth.py
Normal 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]
|
||||
58
backend/app/schemas/recurring.py
Normal file
58
backend/app/schemas/recurring.py
Normal 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
|
||||
40
backend/app/schemas/reports.py
Normal file
40
backend/app/schemas/reports.py
Normal 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]
|
||||
26
backend/app/schemas/settings.py
Normal file
26
backend/app/schemas/settings.py
Normal 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)
|
||||
63
backend/app/schemas/transaction.py
Normal file
63
backend/app/schemas/transaction.py
Normal 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
|
||||
Reference in New Issue
Block a user