Files
WealthySmart/backend/app/models/models.py
Carlos Escalante d929ed6573
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
Remove Ahorro from budget UI, add SALARY type and savings auto-accrual
Ahorro was already deducted from gross salary so displaying it in
budget projections was misleading. This removes the Ahorro card,
summary line, Proyecciones column, and Ahorro Anual card from the UI,
and strips all savings fields from budget API responses.

Adds SALARY TransactionType so salary deposits can be distinguished
from generic DEPOSITO transfers. When a SALARY transaction arrives,
the system auto-increments MEMP and MPAT savings account balances
(+200K CRC each) once per month via an idempotent accrual log.

New CRUD endpoints at /api/v1/savings-accrual/ allow manual correction
of the accrual history. Feb+Mar 2026 are seeded as historical baseline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:13:29 -06:00

470 lines
11 KiB
Python

import enum
from datetime import date, datetime
from typing import Optional
from sqlalchemy import JSON, Column, UniqueConstraint
from sqlmodel import Field, Relationship, SQLModel
class RecurringItemType(str, enum.Enum):
INCOME = "INCOME"
EXPENSE = "EXPENSE"
SAVINGS = "SAVINGS"
class RecurringFrequency(str, enum.Enum):
WEEKLY = "WEEKLY"
MONTHLY = "MONTHLY"
QUARTERLY = "QUARTERLY"
BIANNUAL = "BIANNUAL"
YEARLY = "YEARLY"
class TransactionType(str, enum.Enum):
COMPRA = "COMPRA"
DEVOLUCION = "DEVOLUCION"
DEPOSITO = "DEPOSITO"
SALARY = "SALARY"
class TransactionSource(str, enum.Enum):
CREDIT_CARD = "CREDIT_CARD"
CASH = "CASH"
TRANSFER = "TRANSFER"
class Currency(str, enum.Enum):
CRC = "CRC"
USD = "USD"
EUR = "EUR"
BTC = "BTC"
XMR = "XMR"
class Bank(str, enum.Enum):
BAC = "BAC"
BCR = "BCR"
DAVIVIENDA = "DAVIVIENDA"
FCL = "FCL"
ROP = "ROP"
VOL = "VOL"
MEMP = "MEMP"
MPAT = "MPAT"
MORTGAGE = "MORTGAGE"
class AccountType(str, enum.Enum):
BANK = "BANK"
PENSION = "PENSION"
CRYPTO = "CRYPTO"
SAVINGS = "SAVINGS"
LIABILITY = "LIABILITY"
# --- Category ---
class CategoryBase(SQLModel):
name: str = Field(index=True, unique=True)
icon: str = "tag"
auto_match_patterns: Optional[str] = Field(
default=None,
description="Comma-separated merchant substrings for auto-matching",
)
class Category(CategoryBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
transactions: list["Transaction"] = Relationship(back_populates="category")
class CategoryCreate(CategoryBase):
pass
class CategoryRead(CategoryBase):
id: int
class CategoryUpdate(SQLModel):
name: Optional[str] = None
icon: Optional[str] = None
auto_match_patterns: Optional[str] = None
# --- Account ---
class AccountBase(SQLModel):
bank: Bank
currency: Currency
label: str
balance: float = 0.0
account_type: AccountType = AccountType.BANK
next_payment: Optional[float] = None
class Account(AccountBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class AccountCreate(AccountBase):
pass
class AccountRead(AccountBase):
id: int
updated_at: datetime
class AccountUpdate(SQLModel):
balance: Optional[float] = None
label: Optional[str] = None
next_payment: Optional[float] = None
# --- Transaction ---
class TransactionBase(SQLModel):
amount: float
currency: Currency = Currency.CRC
merchant: str
city: Optional[str] = None
date: datetime
card_type: Optional[str] = None
card_last4: Optional[str] = None
authorization_code: Optional[str] = None
reference: Optional[str] = Field(default=None, index=True)
transaction_type: TransactionType = TransactionType.COMPRA
source: TransactionSource = TransactionSource.CREDIT_CARD
bank: Bank = Bank.BAC
notes: Optional[str] = None
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
deferred_to_next_cycle: bool = Field(default=False)
class Transaction(TransactionBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
category: Optional[Category] = Relationship(back_populates="transactions")
class TransactionCreate(TransactionBase):
pass
class TransactionRead(TransactionBase):
id: int
created_at: datetime
category: Optional[CategoryRead] = None
class TransactionUpdate(SQLModel):
amount: Optional[float] = None
currency: Optional[Currency] = None
merchant: Optional[str] = None
city: Optional[str] = None
date: Optional[datetime] = None
transaction_type: Optional[TransactionType] = None
source: Optional[TransactionSource] = None
notes: Optional[str] = None
category_id: Optional[int] = None
deferred_to_next_cycle: Optional[bool] = None
# --- Exchange Rate ---
class ExchangeRate(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
date: datetime
buy_rate: float
sell_rate: float
fetched_at: datetime = Field(default_factory=datetime.utcnow)
class ExchangeRateRead(SQLModel):
buy_rate: float
sell_rate: float
date: datetime
fetched_at: datetime
# --- API Token ---
class APIToken(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
token_hash: str = Field(index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: Optional[datetime] = None
is_active: bool = True
class APITokenCreate(SQLModel):
name: str
expires_days: Optional[int] = None
class APITokenRead(SQLModel):
id: int
name: str
created_at: datetime
expires_at: Optional[datetime]
is_active: bool
# --- User Settings ---
class UserSettings(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
key: str = Field(index=True, unique=True, default="default")
data: dict = Field(
default_factory=dict,
sa_column=Column(JSON, nullable=False, server_default="{}"),
)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class UserSettingsRead(SQLModel):
key: str
data: dict
updated_at: datetime
class UserSettingsUpdate(SQLModel):
data: dict
# --- Recurring Item ---
class RecurringItemBase(SQLModel):
name: str
amount: float
currency: Currency = Currency.CRC
item_type: RecurringItemType
frequency: RecurringFrequency = RecurringFrequency.MONTHLY
day_of_month: Optional[int] = None
month_of_year: Optional[int] = None
override_amounts: Optional[dict] = Field(
default=None,
sa_column=Column(JSON, nullable=True),
)
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
is_active: bool = True
notes: Optional[str] = None
class RecurringItem(RecurringItemBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
category: Optional[Category] = Relationship()
class RecurringItemCreate(RecurringItemBase):
pass
class RecurringItemRead(RecurringItemBase):
id: int
created_at: datetime
category: Optional[CategoryRead] = None
class RecurringItemUpdate(SQLModel):
name: Optional[str] = None
amount: Optional[float] = None
currency: Optional[Currency] = None
item_type: Optional[RecurringItemType] = None
frequency: Optional[RecurringFrequency] = None
day_of_month: Optional[int] = None
month_of_year: Optional[int] = None
override_amounts: Optional[dict] = None
category_id: Optional[int] = None
is_active: Optional[bool] = None
notes: Optional[str] = None
# --- Push Subscription ---
class PushSubscription(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
endpoint: str = Field(unique=True)
p256dh: str
auth: str
created_at: datetime = Field(default_factory=datetime.utcnow)
class PushSubscriptionCreate(SQLModel):
endpoint: str
keys: dict # {"p256dh": "...", "auth": "..."}
# --- Pension Snapshot ---
class PensionSnapshotBase(SQLModel):
fund: Bank
contract_number: str
period_start: date
period_end: date
saldo_anterior: float
aportes: float
rendimientos: float
retiros: float
traslados: float
comision: float
correccion: float
bonificacion: float
saldo_final: float
source_filename: str
class PensionSnapshot(PensionSnapshotBase, table=True):
__table_args__ = (
UniqueConstraint("fund", "period_start", "period_end"),
)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
class PensionSnapshotRead(PensionSnapshotBase):
id: int
created_at: datetime
# --- Balance Override ---
class BalanceOverride(SQLModel, table=True):
__table_args__ = (UniqueConstraint("year", "month"),)
id: Optional[int] = Field(default=None, primary_key=True)
year: int
month: int
override_balance: float
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class BalanceOverrideCreate(SQLModel):
override_balance: float
class BalanceOverrideRead(SQLModel):
id: int
year: int
month: int
override_balance: float
updated_at: datetime
# --- Savings Accrual ---
class SavingsAccrualBase(SQLModel):
year: int
month: int
memp_amount: float = 200000.0
mpat_amount: float = 200000.0
trigger_transaction_id: Optional[int] = None
notes: Optional[str] = None
class SavingsAccrual(SavingsAccrualBase, table=True):
__table_args__ = (UniqueConstraint("year", "month"),)
id: Optional[int] = Field(default=None, primary_key=True)
applied_at: datetime = Field(default_factory=datetime.utcnow)
class SavingsAccrualCreate(SavingsAccrualBase):
pass
class SavingsAccrualRead(SavingsAccrualBase):
id: int
applied_at: datetime
class SavingsAccrualUpdate(SQLModel):
memp_amount: Optional[float] = None
mpat_amount: Optional[float] = None
notes: Optional[str] = None
# --- Municipal Receipt ---
class MunicipalReceiptBase(SQLModel):
receipt_date: date
due_date: date
period: str # "YYYY-MM"
account: str
finca: str
holder_name: str
holder_cedula: str
holder_address: str
subtotal: float
interests: float
iva: float
total: float
raw_charges: list[dict] = Field(
default_factory=list,
sa_column=Column(JSON, nullable=False, server_default="[]"),
)
source_filename: str
class MunicipalReceipt(MunicipalReceiptBase, table=True):
__table_args__ = (UniqueConstraint("account", "period"),)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
water_readings: list["WaterMeterReading"] = Relationship(
back_populates="receipt",
)
class MunicipalReceiptCreate(MunicipalReceiptBase):
pass
class MunicipalReceiptRead(MunicipalReceiptBase):
id: int
created_at: datetime
# --- Water Meter Reading ---
class WaterMeterReadingBase(SQLModel):
meter_id: str
period: str # "YYYY-MM"
reading_previous: float = 0
reading_current: float = 0
consumption_m3: float
agua_potable: float = 0
serv_ambientales: float = 0
alcant_sanitario: float = 0
iva: float = 0
is_historical: bool = False
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
class WaterMeterReading(WaterMeterReadingBase, table=True):
__table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
receipt: Optional[MunicipalReceipt] = Relationship(
back_populates="water_readings",
)
class WaterMeterReadingRead(WaterMeterReadingBase):
id: int
created_at: datetime