M-Bus Gateway
← Tilbage til blog
· Python· Decimal· finans· beregning· varmeregnskab· SQLAlchemy· Pydantic· backend

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.

Se SQLAlchemy async patterns eller Pydantic v2 guide.