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