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:
Carlos Escalante
2026-06-10 13:38:28 -06:00
parent 881d879ed1
commit 74886fbf98
7 changed files with 414 additions and 72 deletions

View File

@@ -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")