mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10: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,
|
"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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
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