Python Decimal til finansielle beregninger — varmeregnskab og SaaS
Python Decimal til præcise finansielle beregninger: varmeregnskab-fordeling, floating-point fælder, ROUND_HALF_UP strategi, SQLAlchemy Numeric-kolonner og Pydantic Decimal-validering.
Af M-Bus Gateway
Varmeregnskab og fakturering kræver præcise pengeberegninger. float er aldrig korrekt til penge — her er Decimal-mønstrene der sikrer kroneøres-præcision.
Floating-point fælden
# Floating-point fejl:
total = 185000.0 # Kr. fjernvarme
share = 0.3 # 30% fast del
fixed_pool = total * share
print(fixed_pool) # 55500.000000000007 — FORKERT!
# Decimal løser det:
from decimal import Decimal, ROUND_HALF_UP, getcontext
getcontext().prec = 28 # 28 signifikante cifre er mere end nok
total = Decimal("185000")
share = Decimal("0.30")
fixed_pool = total * share
print(fixed_pool) # 55500.00 — KORREKT
# Farlig konvertering (undgå):
bad = Decimal(0.3) # Decimal('0.2999999999999999888977697537484345957636833190917968750')
good = Decimal("0.3") # Decimal('0.3') — brug altid string-initializer
BEK 563 fordeling med Decimal
# server/src/distribution/calculator.py
from decimal import Decimal, ROUND_HALF_UP
from dataclasses import dataclass
@dataclass
class UnitAllocation:
unit_id: str
floor_area_m2: Decimal
hca_units: Decimal
aconto_paid: Decimal
def calculate_bek563_distribution(
total_heat_cost: Decimal,
units: list[UnitAllocation],
fixed_share: Decimal = Decimal("0.30"),
) -> dict[str, Decimal]:
"""
BEK 563 30/70-fordeling med krone-præcision.
Håndterer afrundingsdifferencer via remainder-tildeling.
"""
variable_share = Decimal("1") - fixed_share
fixed_pool = total_heat_cost * fixed_share
variable_pool = total_heat_cost * variable_share
total_area = sum(u.floor_area_m2 for u in units)
total_hca = sum(u.hca_units for u in units)
allocations: dict[str, Decimal] = {}
allocated_fixed = Decimal("0")
allocated_variable = Decimal("0")
# Beregn og afrund pr. enhed:
for i, unit in enumerate(units):
last = i == len(units) - 1
if last:
# Sidste enhed: Tildel remainder for at summere præcist til total:
fixed = fixed_pool - allocated_fixed
variable = variable_pool - allocated_variable
else:
fixed = (fixed_pool * unit.floor_area_m2 / total_area).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
variable = (variable_pool * unit.hca_units / total_hca).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
allocations[unit.unit_id] = fixed + variable
allocated_fixed += fixed
allocated_variable += variable
# Verifikation — ALTID tjek:
total_allocated = sum(allocations.values())
assert abs(total_allocated - total_heat_cost) < Decimal("0.01"), (
f"Fordelingsfejl: {total_allocated} != {total_heat_cost}"
)
return allocations
Afrundingsstrategier
from decimal import Decimal, ROUND_HALF_UP, ROUND_CEILING, ROUND_FLOOR
# BEK 563 krav: "Rimelig og gennemskuelig fordeling"
# Standard: ROUND_HALF_UP (matematisk afrunding)
amount = Decimal("1234.565")
# ROUND_HALF_UP: 1234.57 (standard)
amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# ROUND_CEILING: 1234.57 (rund altid op — gunstigt for udlejer)
amount.quantize(Decimal("0.01"), rounding=ROUND_CEILING)
# ROUND_FLOOR: 1234.56 (rund altid ned — gunstigt for lejer)
amount.quantize(Decimal("0.01"), rounding=ROUND_FLOOR)
# Platform-standard: ROUND_HALF_UP til 2 decimaler
# ALTID brug quantize — aldrig round()
# Farligt:
round(float(amount), 2) # Mister Decimal-præcision
# Korrekt:
amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Pro-rata beregning:
def prorate_amount(
total: Decimal,
days_occupied: int,
days_in_period: int,
) -> Decimal:
"""Præcis pro-rata afrunding."""
ratio = Decimal(days_occupied) / Decimal(days_in_period)
return (total * ratio).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
SQLAlchemy Numeric-kolonne
# server/src/db/models.py
from sqlalchemy import Numeric
from sqlmodel import Field, Column
from decimal import Decimal
class AnnualInput(SQLModel, table=True):
"""
Fjernvarme-faktura input.
Alle beløb som Numeric(12, 2) — 12 cifre, 2 decimaler.
"""
total_heat_cost: Decimal = Field(
sa_column=Column(Numeric(12, 2), nullable=False)
)
fixed_cost_component: Decimal = Field(
default=Decimal("0"),
sa_column=Column(Numeric(12, 2)),
)
variable_cost_component: Decimal = Field(
default=Decimal("0"),
sa_column=Column(Numeric(12, 2)),
)
class DistributionResult(SQLModel, table=True):
"""Beregnet fordeling pr. lejlighed."""
fixed_amount: Decimal = Field(sa_column=Column(Numeric(12, 2)))
variable_amount: Decimal = Field(sa_column=Column(Numeric(12, 2)))
total_amount: Decimal = Field(sa_column=Column(Numeric(12, 2)))
aconto_paid: Decimal = Field(sa_column=Column(Numeric(12, 2)))
balance: Decimal = Field(sa_column=Column(Numeric(12, 2)))
# Positiv balance = efterbetaling, negativ = tilbagebetaling
# PostgreSQL NUMERIC precision:
# Numeric(12, 2): Max 999.999.999,99 DKK — rigeligt til varmeregnskab
# Numeric(6, 4): For HCA-procenter (99,9999%)
Pydantic Decimal-validering
# server/src/schemas/annual_input.py
from pydantic import BaseModel, field_validator, model_validator
from decimal import Decimal
from typing import Annotated
PositiveDecimal = Annotated[
Decimal,
# Pydantic accepterer str, int, float → konverterer til Decimal
]
class AnnualInputCreate(BaseModel):
total_heat_cost: PositiveDecimal
fixed_cost_component: Decimal = Decimal("0")
variable_cost_component: Decimal = Decimal("0")
@field_validator("total_heat_cost")
@classmethod
def validate_positive(cls, v: Decimal) -> Decimal:
if v <= 0:
raise ValueError("Varmekost skal være positiv")
# Tillad max 2 decimaler:
return v.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
@model_validator(mode="after")
def validate_components_sum(self) -> "AnnualInputCreate":
components = self.fixed_cost_component + self.variable_cost_component
if components > Decimal("0") and abs(components - self.total_heat_cost) > Decimal("0.01"):
raise ValueError(
f"Komponenterne ({components}) summerer ikke til total ({self.total_heat_cost})"
)
return self
# JSON input:
# {"total_heat_cost": "185000.00"} → Decimal("185000.00") ✓
# {"total_heat_cost": 185000} → Decimal("185000") ✓
# {"total_heat_cost": "185000.123"} → Decimal("185000.12") (afrundet)
# {"total_heat_cost": -1000} → ValidationError ✓
Stripe og Decimal
# Stripe: Beløb i øre (integer), ikke kroner
# Konvertering: Decimal → øre (int) → Stripe
def to_stripe_amount(amount_dkk: Decimal) -> int:
"""Konvertér DKK til øre for Stripe."""
amount_oere = (amount_dkk * 100).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
return int(amount_oere)
def from_stripe_amount(amount_oere: int, currency: str = "DKK") -> Decimal:
"""Konvertér Stripe øre til DKK Decimal."""
return Decimal(amount_oere) / Decimal(100)
# Brug:
monthly_fee = Decimal("49.00")
stripe_amount = to_stripe_amount(monthly_fee) # 4900
# Abonnement-linje:
stripe.SubscriptionItem.create(
subscription=sub.id,
price_data={
"currency": "dkk",
"unit_amount": to_stripe_amount(Decimal("49")), # 4900 øre
"recurring": {"interval": "month"},
},
)
Konklusion
Decimal er det eneste korrekte valg til varmeregnskab og fakturering i Python. Brug altid string-initializer (Decimal("0.30") ikke Decimal(0.30)), quantize() for præcis afrunding og ROUND_HALF_UP som standard. Remainder-tildeling til den sidste enhed garanterer at fordelingen summerer præcist til totalen. PostgreSQL Numeric(12, 2) bevarer præcisionen i databasen. Stripe-beløb konverteres til øre (integer) — Decimal bruges kun i applikationslaget.