T3: Decimal for money — all monetary fields migrated from float

- All domain monetary fields now use Decimal:
  - Account.balance, Asset.value, Asset.growth_rate
  - Liability.balance, Liability.interest, Liability.payment
  - Transaction.amount, RecurringCashflow.amount
  - ExchangeRate.rate
  - ForecastScenario.income_multiplier, expense_multiplier, growth_multiplier
- CurrencyConverter: all arithmetic in Decimal, quantize to 0.01 with ROUND_HALF_UP
- ForecastService: Decimal arithmetic throughout (income, expenses, balance, growth, liability cost)
- ScenarioService: Decimal multipliers, deepcopy safe with Decimal fields
- assistant.py: _DecimalEncoder for json.dumps (Decimal -> str in JSON)

Pydantic v2 + Decimal:
- model_dump(mode='json') converts Decimal to str (JSON-safe)
- model_validate() parses str back to Decimal
- Round-trip preserves precision (100.50 stays 100.50)

Tests: 63/63 pass.
This commit is contained in:
2026-10-08 16:44:59 +03:00
parent c765f3672a
commit 363d440d53
10 changed files with 118 additions and 73 deletions
+5 -4
View File
@@ -1,3 +1,4 @@
from decimal import Decimal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@@ -6,14 +7,14 @@ from pydantic import BaseModel, Field, field_validator
class ForecastScenario(BaseModel):
id: UUID = Field(default_factory=uuid4)
name: str = "baseline"
income_multiplier: float = 1.0
expense_multiplier: float = 1.0
growth_multiplier: float = 1.0
income_multiplier: Decimal = Field(default=Decimal("1"))
expense_multiplier: Decimal = Field(default=Decimal("1"))
growth_multiplier: Decimal = Field(default=Decimal("1"))
description: str = ""
@field_validator("income_multiplier", "expense_multiplier", "growth_multiplier")
@classmethod
def _non_negative(cls, v: float) -> float:
def _non_negative(cls, v: Decimal) -> Decimal:
if v < 0:
raise ValueError("multiplier must be non-negative")
return v