mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +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:
@@ -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 ###
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
67
backend/tests/test_models_serialization.py
Normal file
67
backend/tests/test_models_serialization.py
Normal 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")
|
||||
Reference in New Issue
Block a user