mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 13:28:48 +02:00
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
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>
470 lines
11 KiB
Python
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
|