mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 11:08:47 +02:00
Migrate all 29 money columns from float to NUMERIC/Decimal (BE-02)
Models use Money = Annotated[Decimal, PlainSerializer(float)] so storage is exact NUMERIC(15,2) (rates 15,6) while JSON keeps emitting plain numbers — the frontend contract is unchanged, pinned by test_models_serialization.py. Service layers coerce to float at their boundaries (projection math, rate multipliers, agent tool output, the savings constants become Decimal) so no naive Decimal/float mixing can raise. Migration 7f567c8bafdb applied to dev: per-row values and the table sum byte-identical before/after; endpoints verified live on the new schema. 59/59 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import enum
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from pydantic import PlainSerializer
|
||||
from sqlalchemy import JSON, Column, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
@@ -12,6 +14,13 @@ from app.timeutil import utcnow
|
||||
# SQLite (tests).
|
||||
JSON_OR_JSONB = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
# Monetary values: exact NUMERIC storage, but serialized to JSON as plain
|
||||
# numbers so the API contract (frontend expects `number`) is unchanged.
|
||||
# Pinned by tests/test_models_serialization.py.
|
||||
Money = Annotated[
|
||||
Decimal, PlainSerializer(float, return_type=float, when_used="json")
|
||||
]
|
||||
|
||||
|
||||
class RecurringItemType(str, enum.Enum):
|
||||
INCOME = "INCOME"
|
||||
@@ -106,9 +115,9 @@ class AccountBase(SQLModel):
|
||||
bank: Bank
|
||||
currency: Currency
|
||||
label: str
|
||||
balance: float = 0.0
|
||||
balance: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
account_type: AccountType = AccountType.BANK
|
||||
next_payment: Optional[float] = None
|
||||
next_payment: Optional[Money] = Field(default=None, max_digits=15, decimal_places=2)
|
||||
|
||||
|
||||
class Account(AccountBase, table=True):
|
||||
@@ -126,16 +135,16 @@ class AccountRead(AccountBase):
|
||||
|
||||
|
||||
class AccountUpdate(SQLModel):
|
||||
balance: Optional[float] = None
|
||||
balance: Optional[Money] = None
|
||||
label: Optional[str] = None
|
||||
next_payment: Optional[float] = None
|
||||
next_payment: Optional[Money] = None
|
||||
|
||||
|
||||
# --- Transaction ---
|
||||
|
||||
|
||||
class TransactionBase(SQLModel):
|
||||
amount: float
|
||||
amount: Money = Field(max_digits=15, decimal_places=2)
|
||||
currency: Currency = Currency.CRC
|
||||
merchant: str
|
||||
city: Optional[str] = None
|
||||
@@ -171,7 +180,7 @@ class TransactionRead(TransactionBase):
|
||||
|
||||
|
||||
class TransactionUpdate(SQLModel):
|
||||
amount: Optional[float] = None
|
||||
amount: Optional[Money] = None
|
||||
currency: Optional[Currency] = None
|
||||
merchant: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
@@ -189,14 +198,14 @@ class TransactionUpdate(SQLModel):
|
||||
class ExchangeRate(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
date: datetime
|
||||
buy_rate: float
|
||||
sell_rate: float
|
||||
buy_rate: Decimal = Field(max_digits=15, decimal_places=6)
|
||||
sell_rate: Decimal = Field(max_digits=15, decimal_places=6)
|
||||
fetched_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
class ExchangeRateRead(SQLModel):
|
||||
buy_rate: float
|
||||
sell_rate: float
|
||||
buy_rate: Money
|
||||
sell_rate: Money
|
||||
date: datetime
|
||||
fetched_at: datetime
|
||||
|
||||
@@ -254,7 +263,7 @@ class UserSettingsUpdate(SQLModel):
|
||||
|
||||
class RecurringItemBase(SQLModel):
|
||||
name: str
|
||||
amount: float
|
||||
amount: Money = Field(max_digits=15, decimal_places=2)
|
||||
currency: Currency = Currency.CRC
|
||||
item_type: RecurringItemType
|
||||
frequency: RecurringFrequency = RecurringFrequency.MONTHLY
|
||||
@@ -287,7 +296,7 @@ class RecurringItemRead(RecurringItemBase):
|
||||
|
||||
class RecurringItemUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
amount: Optional[float] = None
|
||||
amount: Optional[Money] = None
|
||||
currency: Optional[Currency] = None
|
||||
item_type: Optional[RecurringItemType] = None
|
||||
frequency: Optional[RecurringFrequency] = None
|
||||
@@ -323,15 +332,15 @@ class PensionSnapshotBase(SQLModel):
|
||||
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
|
||||
saldo_anterior: Money = Field(max_digits=15, decimal_places=2)
|
||||
aportes: Money = Field(max_digits=15, decimal_places=2)
|
||||
rendimientos: Money = Field(max_digits=15, decimal_places=2)
|
||||
retiros: Money = Field(max_digits=15, decimal_places=2)
|
||||
traslados: Money = Field(max_digits=15, decimal_places=2)
|
||||
comision: Money = Field(max_digits=15, decimal_places=2)
|
||||
correccion: Money = Field(max_digits=15, decimal_places=2)
|
||||
bonificacion: Money = Field(max_digits=15, decimal_places=2)
|
||||
saldo_final: Money = Field(max_digits=15, decimal_places=2)
|
||||
source_filename: str
|
||||
|
||||
|
||||
@@ -356,20 +365,20 @@ class BalanceOverride(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
year: int
|
||||
month: int
|
||||
override_balance: float
|
||||
override_balance: Money = Field(max_digits=15, decimal_places=2)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
updated_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
class BalanceOverrideCreate(SQLModel):
|
||||
override_balance: float
|
||||
override_balance: Money
|
||||
|
||||
|
||||
class BalanceOverrideRead(SQLModel):
|
||||
id: int
|
||||
year: int
|
||||
month: int
|
||||
override_balance: float
|
||||
override_balance: Money
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -379,8 +388,8 @@ class BalanceOverrideRead(SQLModel):
|
||||
class SavingsAccrualBase(SQLModel):
|
||||
year: int
|
||||
month: int
|
||||
memp_amount: float = 200000.0
|
||||
mpat_amount: float = 200000.0
|
||||
memp_amount: Money = Field(default=Decimal("200000"), max_digits=15, decimal_places=2)
|
||||
mpat_amount: Money = Field(default=Decimal("200000"), max_digits=15, decimal_places=2)
|
||||
trigger_transaction_id: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
@@ -401,8 +410,8 @@ class SavingsAccrualRead(SavingsAccrualBase):
|
||||
|
||||
|
||||
class SavingsAccrualUpdate(SQLModel):
|
||||
memp_amount: Optional[float] = None
|
||||
mpat_amount: Optional[float] = None
|
||||
memp_amount: Optional[Money] = None
|
||||
mpat_amount: Optional[Money] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@@ -418,10 +427,10 @@ class MunicipalReceiptBase(SQLModel):
|
||||
holder_name: str
|
||||
holder_cedula: str
|
||||
holder_address: str
|
||||
subtotal: float
|
||||
interests: float
|
||||
iva: float
|
||||
total: float
|
||||
subtotal: Money = Field(max_digits=15, decimal_places=2)
|
||||
interests: Money = Field(max_digits=15, decimal_places=2)
|
||||
iva: Money = Field(max_digits=15, decimal_places=2)
|
||||
total: Money = Field(max_digits=15, decimal_places=2)
|
||||
raw_charges: list[dict] = Field(
|
||||
default_factory=list,
|
||||
sa_column=Column(JSON, nullable=False, server_default="[]"),
|
||||
@@ -453,13 +462,13 @@ class MunicipalReceiptRead(MunicipalReceiptBase):
|
||||
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
|
||||
reading_previous: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
reading_current: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
consumption_m3: Money = Field(max_digits=15, decimal_places=2)
|
||||
agua_potable: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
serv_ambientales: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
alcant_sanitario: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
iva: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
is_historical: bool = False
|
||||
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user