From 74886fbf98c6a48a7845bbd4f25868e3f097e036 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Wed, 10 Jun 2026 13:38:28 -0600 Subject: [PATCH] Migrate all 29 money columns from float to NUMERIC/Decimal (BE-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../7f567c8bafdb_money_columns_to_numeric.py | 263 ++++++++++++++++++ backend/app/agent/tools.py | 42 +-- backend/app/models/models.py | 89 +++--- backend/app/services/budget_projection.py | 16 +- backend/app/services/exchange_rate.py | 3 +- backend/app/services/savings_accrual.py | 6 +- backend/tests/test_models_serialization.py | 67 +++++ 7 files changed, 414 insertions(+), 72 deletions(-) create mode 100644 backend/alembic/versions/7f567c8bafdb_money_columns_to_numeric.py create mode 100644 backend/tests/test_models_serialization.py diff --git a/backend/alembic/versions/7f567c8bafdb_money_columns_to_numeric.py b/backend/alembic/versions/7f567c8bafdb_money_columns_to_numeric.py new file mode 100644 index 0000000..3443dcf --- /dev/null +++ b/backend/alembic/versions/7f567c8bafdb_money_columns_to_numeric.py @@ -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 ### diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index 8caac0e..11c535d 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -66,9 +66,9 @@ def get_accounts() -> list[dict]: "bank": a.bank.value, "label": a.label, "currency": a.currency.value, - "balance": a.balance, + "balance": float(a.balance), "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 ] @@ -79,15 +79,15 @@ def get_net_worth() -> dict: USD/EUR balances are converted at the latest exchange rate.""" accounts = _s().exec(select(Account)).all() 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 liabilities_crc = 0.0 for a in accounts: - amt = a.balance + amt = float(a.balance) if a.currency.value == "USD": - amt = a.balance * sell + amt = float(a.balance) * sell 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": liabilities_crc += amt else: @@ -139,7 +139,7 @@ def get_recent_transactions( "id": t.id, "date": t.date.isoformat(), "merchant": t.merchant, - "amount": t.amount, + "amount": float(t.amount), "currency": t.currency.value, "source": t.source.value, "transaction_type": t.transaction_type.value, @@ -243,7 +243,7 @@ def list_recurring_items() -> list[dict]: { "id": r.id, "name": r.name, - "amount": r.amount, + "amount": float(r.amount), "currency": r.currency.value, "item_type": r.item_type.value, "frequency": r.frequency.value, @@ -281,12 +281,12 @@ def get_pension_snapshots( "fund": r.fund.value, "period_start": r.period_start.isoformat(), "period_end": r.period_end.isoformat(), - "saldo_anterior": r.saldo_anterior, - "aportes": r.aportes, - "rendimientos": r.rendimientos, - "retiros": r.retiros, - "comision": r.comision, - "saldo_final": r.saldo_final, + "saldo_anterior": float(r.saldo_anterior), + "aportes": float(r.aportes), + "rendimientos": float(r.rendimientos), + "retiros": float(r.retiros), + "comision": float(r.comision), + "saldo_final": float(r.saldo_final), } for r in rows ] @@ -334,11 +334,11 @@ def get_municipal_receipts( "period": r.period, "account": r.account, "finca": r.finca, - "subtotal": r.subtotal, - "interests": r.interests, - "iva": r.iva, - "total": r.total, - "water_consumption_m3": sum(w.consumption_m3 for w in readings), + "subtotal": float(r.subtotal), + "interests": float(r.interests), + "iva": float(r.iva), + "total": float(r.total), + "water_consumption_m3": float(sum(w.consumption_m3 for w in readings)), } ) return out @@ -436,8 +436,8 @@ def get_exchange_rate() -> dict: if not rate: return {"buy_rate": None, "sell_rate": None, "date": None} return { - "buy_rate": rate.buy_rate, - "sell_rate": rate.sell_rate, + "buy_rate": float(rate.buy_rate), + "sell_rate": float(rate.sell_rate), "date": rate.date.isoformat(), } diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 4d29013..048e8bb 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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") diff --git a/backend/app/services/budget_projection.py b/backend/app/services/budget_projection.py index fcb748f..829295b 100644 --- a/backend/app/services/budget_projection.py +++ b/backend/app/services/budget_projection.py @@ -31,7 +31,7 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | if freq == RecurringFrequency.MONTHLY: if item.override_amounts and str(month) in item.override_amounts: return float(item.override_amounts[str(month)]) - return item.amount + return float(item.amount) if freq == RecurringFrequency.WEEKLY: # 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 cal = calendar.monthcalendar(year, month) count = sum(1 for week in cal if week[weekday] != 0) - return item.amount * count + return float(item.amount) * count if freq == RecurringFrequency.QUARTERLY: # Active in months 3, 6, 9, 12 by default if month % 3 == 0: if item.override_amounts and str(month) in item.override_amounts: return float(item.override_amounts[str(month)]) - return item.amount + return float(item.amount) return None 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 second = base + 6 if base <= 6 else base - 6 if month in (base, second): - return item.amount + return float(item.amount) return None if freq == RecurringFrequency.YEARLY: if month == (item.month_of_year or 12): - return item.amount + return float(item.amount) return None return None @@ -561,13 +561,13 @@ def _get_december_cumulative(session: Session, year: int) -> float: ) ).first() if override: - return override.override_balance + return float(override.override_balance) # Compute the full year to get December's cumulative overrides = session.exec( select(BalanceOverride).where(BalanceOverride.year == year) ).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 if year > FRESH_START_YEAR: @@ -591,7 +591,7 @@ def compute_yearly_projection_with_cumulative( overrides = session.exec( select(BalanceOverride).where(BalanceOverride.year == year) ).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 if year <= FRESH_START_YEAR: diff --git a/backend/app/services/exchange_rate.py b/backend/app/services/exchange_rate.py index 8b1a54f..66700fa 100644 --- a/backend/app/services/exchange_rate.py +++ b/backend/app/services/exchange_rate.py @@ -255,7 +255,8 @@ def get_crc_multipliers(session: Session) -> dict[str, float]: usd_rate = get_current_rate(session) 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): if code in multipliers: diff --git a/backend/app/services/savings_accrual.py b/backend/app/services/savings_accrual.py index b593c5d..87f90ef 100644 --- a/backend/app/services/savings_accrual.py +++ b/backend/app/services/savings_accrual.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from sqlmodel import Session, select from app.models.models import ( @@ -8,8 +10,8 @@ from app.models.models import ( Transaction, ) -MEMP_MONTHLY = 200000.0 -MPAT_MONTHLY = 200000.0 +MEMP_MONTHLY = Decimal("200000") +MPAT_MONTHLY = Decimal("200000") def _get_savings_account(session: Session, bank: Bank) -> Account | None: diff --git a/backend/tests/test_models_serialization.py b/backend/tests/test_models_serialization.py new file mode 100644 index 0000000..9bf8439 --- /dev/null +++ b/backend/tests/test_models_serialization.py @@ -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")