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

@@ -0,0 +1,263 @@
"""money columns to numeric
Revision ID: 7f567c8bafdb
Revises: c3da001a0eb3
Create Date: 2026-06-10 13:37:26.281634
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '7f567c8bafdb'
down_revision: Union[str, Sequence[str], None] = 'c3da001a0eb3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('account', 'balance',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('account', 'next_payment',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=True)
op.alter_column('balanceoverride', 'override_balance',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('exchangerate', 'buy_rate',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=6),
existing_nullable=False)
op.alter_column('exchangerate', 'sell_rate',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=6),
existing_nullable=False)
op.alter_column('municipalreceipt', 'subtotal',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('municipalreceipt', 'interests',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('municipalreceipt', 'iva',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('municipalreceipt', 'total',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'saldo_anterior',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'aportes',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'rendimientos',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'retiros',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'traslados',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'comision',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'correccion',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'bonificacion',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'saldo_final',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('recurringitem', 'amount',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('savingsaccrual', 'memp_amount',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('savingsaccrual', 'mpat_amount',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('transaction', 'amount',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'reading_previous',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'reading_current',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'consumption_m3',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'agua_potable',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'serv_ambientales',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'alcant_sanitario',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'iva',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('watermeterreading', 'iva',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'alcant_sanitario',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'serv_ambientales',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'agua_potable',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'consumption_m3',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'reading_current',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'reading_previous',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('transaction', 'amount',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('savingsaccrual', 'mpat_amount',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('savingsaccrual', 'memp_amount',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('recurringitem', 'amount',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'saldo_final',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'bonificacion',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'correccion',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'comision',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'traslados',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'retiros',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'rendimientos',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'aportes',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'saldo_anterior',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('municipalreceipt', 'total',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('municipalreceipt', 'iva',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('municipalreceipt', 'interests',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('municipalreceipt', 'subtotal',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('exchangerate', 'sell_rate',
existing_type=sa.Numeric(precision=15, scale=6),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('exchangerate', 'buy_rate',
existing_type=sa.Numeric(precision=15, scale=6),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('balanceoverride', 'override_balance',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('account', 'next_payment',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=True)
op.alter_column('account', 'balance',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -66,9 +66,9 @@ def get_accounts() -> list[dict]:
"bank": a.bank.value, "bank": a.bank.value,
"label": a.label, "label": a.label,
"currency": a.currency.value, "currency": a.currency.value,
"balance": a.balance, "balance": float(a.balance),
"account_type": a.account_type.value, "account_type": a.account_type.value,
"next_payment": a.next_payment, "next_payment": float(a.next_payment) if a.next_payment is not None else None,
} }
for a in rows for a in rows
] ]
@@ -79,15 +79,15 @@ def get_net_worth() -> dict:
USD/EUR balances are converted at the latest exchange rate.""" USD/EUR balances are converted at the latest exchange rate."""
accounts = _s().exec(select(Account)).all() accounts = _s().exec(select(Account)).all()
rate = get_current_rate(_s()) rate = get_current_rate(_s())
sell = rate.sell_rate if rate else 600.0 sell = float(rate.sell_rate) if rate else 600.0
assets_crc = 0.0 assets_crc = 0.0
liabilities_crc = 0.0 liabilities_crc = 0.0
for a in accounts: for a in accounts:
amt = a.balance amt = float(a.balance)
if a.currency.value == "USD": if a.currency.value == "USD":
amt = a.balance * sell amt = float(a.balance) * sell
elif a.currency.value == "EUR": elif a.currency.value == "EUR":
amt = a.balance * sell * 1.08 # rough; real conversion is endpoint-side amt = float(a.balance) * sell * 1.08 # rough; real conversion is endpoint-side
if a.account_type.value == "LIABILITY": if a.account_type.value == "LIABILITY":
liabilities_crc += amt liabilities_crc += amt
else: else:
@@ -139,7 +139,7 @@ def get_recent_transactions(
"id": t.id, "id": t.id,
"date": t.date.isoformat(), "date": t.date.isoformat(),
"merchant": t.merchant, "merchant": t.merchant,
"amount": t.amount, "amount": float(t.amount),
"currency": t.currency.value, "currency": t.currency.value,
"source": t.source.value, "source": t.source.value,
"transaction_type": t.transaction_type.value, "transaction_type": t.transaction_type.value,
@@ -243,7 +243,7 @@ def list_recurring_items() -> list[dict]:
{ {
"id": r.id, "id": r.id,
"name": r.name, "name": r.name,
"amount": r.amount, "amount": float(r.amount),
"currency": r.currency.value, "currency": r.currency.value,
"item_type": r.item_type.value, "item_type": r.item_type.value,
"frequency": r.frequency.value, "frequency": r.frequency.value,
@@ -281,12 +281,12 @@ def get_pension_snapshots(
"fund": r.fund.value, "fund": r.fund.value,
"period_start": r.period_start.isoformat(), "period_start": r.period_start.isoformat(),
"period_end": r.period_end.isoformat(), "period_end": r.period_end.isoformat(),
"saldo_anterior": r.saldo_anterior, "saldo_anterior": float(r.saldo_anterior),
"aportes": r.aportes, "aportes": float(r.aportes),
"rendimientos": r.rendimientos, "rendimientos": float(r.rendimientos),
"retiros": r.retiros, "retiros": float(r.retiros),
"comision": r.comision, "comision": float(r.comision),
"saldo_final": r.saldo_final, "saldo_final": float(r.saldo_final),
} }
for r in rows for r in rows
] ]
@@ -334,11 +334,11 @@ def get_municipal_receipts(
"period": r.period, "period": r.period,
"account": r.account, "account": r.account,
"finca": r.finca, "finca": r.finca,
"subtotal": r.subtotal, "subtotal": float(r.subtotal),
"interests": r.interests, "interests": float(r.interests),
"iva": r.iva, "iva": float(r.iva),
"total": r.total, "total": float(r.total),
"water_consumption_m3": sum(w.consumption_m3 for w in readings), "water_consumption_m3": float(sum(w.consumption_m3 for w in readings)),
} }
) )
return out return out
@@ -436,8 +436,8 @@ def get_exchange_rate() -> dict:
if not rate: if not rate:
return {"buy_rate": None, "sell_rate": None, "date": None} return {"buy_rate": None, "sell_rate": None, "date": None}
return { return {
"buy_rate": rate.buy_rate, "buy_rate": float(rate.buy_rate),
"sell_rate": rate.sell_rate, "sell_rate": float(rate.sell_rate),
"date": rate.date.isoformat(), "date": rate.date.isoformat(),
} }

View File

@@ -1,7 +1,9 @@
import enum import enum
from datetime import date, datetime 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 import JSON, Column, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
@@ -12,6 +14,13 @@ from app.timeutil import utcnow
# SQLite (tests). # SQLite (tests).
JSON_OR_JSONB = JSON().with_variant(JSONB(), "postgresql") 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): class RecurringItemType(str, enum.Enum):
INCOME = "INCOME" INCOME = "INCOME"
@@ -106,9 +115,9 @@ class AccountBase(SQLModel):
bank: Bank bank: Bank
currency: Currency currency: Currency
label: str label: str
balance: float = 0.0 balance: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
account_type: AccountType = AccountType.BANK 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): class Account(AccountBase, table=True):
@@ -126,16 +135,16 @@ class AccountRead(AccountBase):
class AccountUpdate(SQLModel): class AccountUpdate(SQLModel):
balance: Optional[float] = None balance: Optional[Money] = None
label: Optional[str] = None label: Optional[str] = None
next_payment: Optional[float] = None next_payment: Optional[Money] = None
# --- Transaction --- # --- Transaction ---
class TransactionBase(SQLModel): class TransactionBase(SQLModel):
amount: float amount: Money = Field(max_digits=15, decimal_places=2)
currency: Currency = Currency.CRC currency: Currency = Currency.CRC
merchant: str merchant: str
city: Optional[str] = None city: Optional[str] = None
@@ -171,7 +180,7 @@ class TransactionRead(TransactionBase):
class TransactionUpdate(SQLModel): class TransactionUpdate(SQLModel):
amount: Optional[float] = None amount: Optional[Money] = None
currency: Optional[Currency] = None currency: Optional[Currency] = None
merchant: Optional[str] = None merchant: Optional[str] = None
city: Optional[str] = None city: Optional[str] = None
@@ -189,14 +198,14 @@ class TransactionUpdate(SQLModel):
class ExchangeRate(SQLModel, table=True): class ExchangeRate(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
date: datetime date: datetime
buy_rate: float buy_rate: Decimal = Field(max_digits=15, decimal_places=6)
sell_rate: float sell_rate: Decimal = Field(max_digits=15, decimal_places=6)
fetched_at: datetime = Field(default_factory=utcnow) fetched_at: datetime = Field(default_factory=utcnow)
class ExchangeRateRead(SQLModel): class ExchangeRateRead(SQLModel):
buy_rate: float buy_rate: Money
sell_rate: float sell_rate: Money
date: datetime date: datetime
fetched_at: datetime fetched_at: datetime
@@ -254,7 +263,7 @@ class UserSettingsUpdate(SQLModel):
class RecurringItemBase(SQLModel): class RecurringItemBase(SQLModel):
name: str name: str
amount: float amount: Money = Field(max_digits=15, decimal_places=2)
currency: Currency = Currency.CRC currency: Currency = Currency.CRC
item_type: RecurringItemType item_type: RecurringItemType
frequency: RecurringFrequency = RecurringFrequency.MONTHLY frequency: RecurringFrequency = RecurringFrequency.MONTHLY
@@ -287,7 +296,7 @@ class RecurringItemRead(RecurringItemBase):
class RecurringItemUpdate(SQLModel): class RecurringItemUpdate(SQLModel):
name: Optional[str] = None name: Optional[str] = None
amount: Optional[float] = None amount: Optional[Money] = None
currency: Optional[Currency] = None currency: Optional[Currency] = None
item_type: Optional[RecurringItemType] = None item_type: Optional[RecurringItemType] = None
frequency: Optional[RecurringFrequency] = None frequency: Optional[RecurringFrequency] = None
@@ -323,15 +332,15 @@ class PensionSnapshotBase(SQLModel):
contract_number: str contract_number: str
period_start: date period_start: date
period_end: date period_end: date
saldo_anterior: float saldo_anterior: Money = Field(max_digits=15, decimal_places=2)
aportes: float aportes: Money = Field(max_digits=15, decimal_places=2)
rendimientos: float rendimientos: Money = Field(max_digits=15, decimal_places=2)
retiros: float retiros: Money = Field(max_digits=15, decimal_places=2)
traslados: float traslados: Money = Field(max_digits=15, decimal_places=2)
comision: float comision: Money = Field(max_digits=15, decimal_places=2)
correccion: float correccion: Money = Field(max_digits=15, decimal_places=2)
bonificacion: float bonificacion: Money = Field(max_digits=15, decimal_places=2)
saldo_final: float saldo_final: Money = Field(max_digits=15, decimal_places=2)
source_filename: str source_filename: str
@@ -356,20 +365,20 @@ class BalanceOverride(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
year: int year: int
month: int month: int
override_balance: float override_balance: Money = Field(max_digits=15, decimal_places=2)
created_at: datetime = Field(default_factory=utcnow) created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow) updated_at: datetime = Field(default_factory=utcnow)
class BalanceOverrideCreate(SQLModel): class BalanceOverrideCreate(SQLModel):
override_balance: float override_balance: Money
class BalanceOverrideRead(SQLModel): class BalanceOverrideRead(SQLModel):
id: int id: int
year: int year: int
month: int month: int
override_balance: float override_balance: Money
updated_at: datetime updated_at: datetime
@@ -379,8 +388,8 @@ class BalanceOverrideRead(SQLModel):
class SavingsAccrualBase(SQLModel): class SavingsAccrualBase(SQLModel):
year: int year: int
month: int month: int
memp_amount: float = 200000.0 memp_amount: Money = Field(default=Decimal("200000"), max_digits=15, decimal_places=2)
mpat_amount: float = 200000.0 mpat_amount: Money = Field(default=Decimal("200000"), max_digits=15, decimal_places=2)
trigger_transaction_id: Optional[int] = None trigger_transaction_id: Optional[int] = None
notes: Optional[str] = None notes: Optional[str] = None
@@ -401,8 +410,8 @@ class SavingsAccrualRead(SavingsAccrualBase):
class SavingsAccrualUpdate(SQLModel): class SavingsAccrualUpdate(SQLModel):
memp_amount: Optional[float] = None memp_amount: Optional[Money] = None
mpat_amount: Optional[float] = None mpat_amount: Optional[Money] = None
notes: Optional[str] = None notes: Optional[str] = None
@@ -418,10 +427,10 @@ class MunicipalReceiptBase(SQLModel):
holder_name: str holder_name: str
holder_cedula: str holder_cedula: str
holder_address: str holder_address: str
subtotal: float subtotal: Money = Field(max_digits=15, decimal_places=2)
interests: float interests: Money = Field(max_digits=15, decimal_places=2)
iva: float iva: Money = Field(max_digits=15, decimal_places=2)
total: float total: Money = Field(max_digits=15, decimal_places=2)
raw_charges: list[dict] = Field( raw_charges: list[dict] = Field(
default_factory=list, default_factory=list,
sa_column=Column(JSON, nullable=False, server_default="[]"), sa_column=Column(JSON, nullable=False, server_default="[]"),
@@ -453,13 +462,13 @@ class MunicipalReceiptRead(MunicipalReceiptBase):
class WaterMeterReadingBase(SQLModel): class WaterMeterReadingBase(SQLModel):
meter_id: str meter_id: str
period: str # "YYYY-MM" period: str # "YYYY-MM"
reading_previous: float = 0 reading_previous: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
reading_current: float = 0 reading_current: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
consumption_m3: float consumption_m3: Money = Field(max_digits=15, decimal_places=2)
agua_potable: float = 0 agua_potable: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
serv_ambientales: float = 0 serv_ambientales: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
alcant_sanitario: float = 0 alcant_sanitario: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
iva: float = 0 iva: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
is_historical: bool = False is_historical: bool = False
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id") receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")

View File

@@ -31,7 +31,7 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float |
if freq == RecurringFrequency.MONTHLY: if freq == RecurringFrequency.MONTHLY:
if item.override_amounts and str(month) in item.override_amounts: if item.override_amounts and str(month) in item.override_amounts:
return float(item.override_amounts[str(month)]) return float(item.override_amounts[str(month)])
return item.amount return float(item.amount)
if freq == RecurringFrequency.WEEKLY: if freq == RecurringFrequency.WEEKLY:
# Count occurrences of the weekday in this month # Count occurrences of the weekday in this month
@@ -39,14 +39,14 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float |
weekday = item.day_of_month if item.day_of_month is not None else 0 weekday = item.day_of_month if item.day_of_month is not None else 0
cal = calendar.monthcalendar(year, month) cal = calendar.monthcalendar(year, month)
count = sum(1 for week in cal if week[weekday] != 0) count = sum(1 for week in cal if week[weekday] != 0)
return item.amount * count return float(item.amount) * count
if freq == RecurringFrequency.QUARTERLY: if freq == RecurringFrequency.QUARTERLY:
# Active in months 3, 6, 9, 12 by default # Active in months 3, 6, 9, 12 by default
if month % 3 == 0: if month % 3 == 0:
if item.override_amounts and str(month) in item.override_amounts: if item.override_amounts and str(month) in item.override_amounts:
return float(item.override_amounts[str(month)]) return float(item.override_amounts[str(month)])
return item.amount return float(item.amount)
return None return None
if freq == RecurringFrequency.BIANNUAL: if freq == RecurringFrequency.BIANNUAL:
@@ -54,12 +54,12 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float |
base = item.month_of_year or 1 base = item.month_of_year or 1
second = base + 6 if base <= 6 else base - 6 second = base + 6 if base <= 6 else base - 6
if month in (base, second): if month in (base, second):
return item.amount return float(item.amount)
return None return None
if freq == RecurringFrequency.YEARLY: if freq == RecurringFrequency.YEARLY:
if month == (item.month_of_year or 12): if month == (item.month_of_year or 12):
return item.amount return float(item.amount)
return None return None
return None return None
@@ -561,13 +561,13 @@ def _get_december_cumulative(session: Session, year: int) -> float:
) )
).first() ).first()
if override: if override:
return override.override_balance return float(override.override_balance)
# Compute the full year to get December's cumulative # Compute the full year to get December's cumulative
overrides = session.exec( overrides = session.exec(
select(BalanceOverride).where(BalanceOverride.year == year) select(BalanceOverride).where(BalanceOverride.year == year)
).all() ).all()
override_map = {o.month: o.override_balance for o in overrides} override_map = {o.month: float(o.override_balance) for o in overrides}
cumulative = 0.0 cumulative = 0.0
if year > FRESH_START_YEAR: if year > FRESH_START_YEAR:
@@ -591,7 +591,7 @@ def compute_yearly_projection_with_cumulative(
overrides = session.exec( overrides = session.exec(
select(BalanceOverride).where(BalanceOverride.year == year) select(BalanceOverride).where(BalanceOverride.year == year)
).all() ).all()
override_map = {o.month: o.override_balance for o in overrides} override_map = {o.month: float(o.override_balance) for o in overrides}
# Determine January carryover # Determine January carryover
if year <= FRESH_START_YEAR: if year <= FRESH_START_YEAR:

View File

@@ -255,7 +255,8 @@ def get_crc_multipliers(session: Session) -> dict[str, float]:
usd_rate = get_current_rate(session) usd_rate = get_current_rate(session)
if usd_rate: if usd_rate:
multipliers["USD"] = usd_rate.sell_rate # sell_rate is Decimal when loaded from the DB; keep multipliers float
multipliers["USD"] = float(usd_rate.sell_rate)
for code in (c.value for c in Currency): for code in (c.value for c in Currency):
if code in multipliers: if code in multipliers:

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from sqlmodel import Session, select from sqlmodel import Session, select
from app.models.models import ( from app.models.models import (
@@ -8,8 +10,8 @@ from app.models.models import (
Transaction, Transaction,
) )
MEMP_MONTHLY = 200000.0 MEMP_MONTHLY = Decimal("200000")
MPAT_MONTHLY = 200000.0 MPAT_MONTHLY = Decimal("200000")
def _get_savings_account(session: Session, bank: Bank) -> Account | None: def _get_savings_account(session: Session, bank: Bank) -> Account | None:

View File

@@ -0,0 +1,67 @@
"""Pin the Money serialization contract (Phase 2 Decimal migration, BE-02):
NUMERIC storage, but JSON emits plain numbers so the frontend's `number`
types keep working."""
import json
from datetime import datetime
from decimal import Decimal
from sqlmodel import select
from app.models.models import (
Account,
AccountRead,
Bank,
Currency,
Transaction,
TransactionRead,
)
def test_money_columns_are_numeric():
assert str(Transaction.__table__.c.amount.type) == "NUMERIC(15, 2)"
assert str(Account.__table__.c.balance.type) == "NUMERIC(15, 2)"
def test_money_serializes_as_json_number():
read = TransactionRead(
id=1,
amount=Decimal("25000.50"),
merchant="X",
date=datetime(2026, 4, 1),
created_at=datetime(2026, 4, 1),
)
dumped = read.model_dump(mode="json")
assert dumped["amount"] == 25000.5
assert isinstance(dumped["amount"], float)
# and the whole payload is json.dumps-able (no Decimal leaks)
json.dumps(dumped)
def test_money_accepts_float_input():
# n8n and the SPA POST plain JSON numbers; they must coerce to Decimal.
read = AccountRead(
id=1,
bank=Bank.BAC,
currency=Currency.CRC,
label="x",
balance=1234.56,
updated_at=datetime(2026, 4, 1),
)
assert read.balance == Decimal("1234.56")
def test_decimal_roundtrip_through_db(session):
session.add(
Transaction(
amount=Decimal("9999.99"),
merchant="ROUNDTRIP",
date=datetime(2026, 4, 1),
)
)
session.commit()
row = session.exec(
select(Transaction).where(Transaction.merchant == "ROUNDTRIP")
).one()
assert isinstance(row.amount, Decimal)
assert row.amount == Decimal("9999.99")