grid-billing 0.10.0

Role-neutral grid invoice calculation: NNE/KA/MMM/MSB (PIDs 31001/31002/31005/31006/31009/31011) — used by netzbilanzd (NB) and invoicd (LF selbstausstellen). §14a ToU HT/NT. Zero I/O, no float money.
Documentation

grid-billing

Role-neutral NNE/KA/MMM/MSB grid invoice calculation library for German energy market communication (BDEW MaKo).

Crates.io

What this crate does

grid-billing computes BDEW INVOIC billing positions for:

  • NNE (Netznutzungsentgelt) — flat-rate or §14a Modul 2 ToU (HT/NT split)
  • KA (Konzessionsabgabe) — §17 StromNZV, included as separate position
  • MMM (Mehr-/Mindermengensaldo) — actual vs. SLP profile deviation, credit when Mindermengen dominate
  • MSB-Rechnung — metering service fee (NB → MSB, PID 31009)

All calculations are pure functions — zero I/O, zero async, no side effects. All monetary arithmetic uses EuroAmount = i64 × 10⁻⁵ EUR — no f64 anywhere in the billing path.

Architecture: domain types, no BO4E dependency

grid-billing returns GridInvoice — a pure domain type with no dependency on rubo4e. The service layer (netzbilanzd, invoicd) owns the into_rechnung() conversion to rubo4e::current::Rechnung for EDIFACT serialization and invoic-checker validation.

grid-billing::calculate_nne_invoice(NneInput) → GridInvoice { positions, total_eur, pid, … }
                                                        │
                                                        ▼
                                          netzbilanzd::into_rechnung(&invoice) → Rechnung
                                                        │
                                                        ▼
                                          InvoicCheckEngine::check(pid, &rechnung, …)
                                                        │
                                                        ▼
                                          invoice_drafts (PostgreSQL) → AS4 dispatch

This separation makes grid-billing publishable to crates.io without pulling in the internal rubo4e crate.

Who uses this library

Consumer Role Use case
netzbilanzd NB Generate INVOIC 31001/31002/31005/31009/31011 to send to LF/MSB/LFG
invoicd LF §20 MessZV selbstausstellen PID 31006 — LF runs the same formula the NB would use

This dual-role usage is intentional: under §20 MessZV selbstausgestellt invoicing, the LF runs the identical formula independently. The calculation is symmetric.

Generated invoice types

PID Description Direction billing_type in netzbilanzd
31001 NNE Strom NB → LF nne_strom
31002 MMM Strom / Gas NB → LF · GNB → LFG mmm_strom, mmm_gas
31005 NNE Gas GNB → LFG nne_gas
31006 Selbst ausgestellte NNE LF → LF internal invoicd only
31009 MSB-Rechnung NB → MSB msb_31009
31011 AWH Sperrprozesse Gas GNB → LFG nne_gas_awh_31011 (PID override on GridInvoice.pid)

Output type: GridInvoice

pub struct GridInvoice {
    pub pid: u32,              // BDEW Prüfidentifikator (caller may override for 31005/31011)
    pub rechnungsnummer: String,
    pub invoice_date: time::Date,
    pub due_date: time::Date,
    pub period_from: time::Date,
    pub period_to: time::Date,
    pub nb_mp_id: String,      // Sender MP-ID (for invoic-checker tariff lookups)
    pub positions: Vec<InvoicePosition>,
    pub total_eur: Decimal,    // Net total, rounded to 2 decimal places
}

pub struct InvoicePosition {
    pub number: u32,
    pub text: String,
    pub quantity: Decimal,
    pub unit: QuantityUnit,    // Kwh | Kw | Monat
    pub unit_price_eur: Decimal,
    pub net_eur: Decimal,      // quantity × unit_price_eur, rounded to 5 dp
}

Design invariants

Invariant Detail
No floating-point money All amounts use EuroAmount (i64 × 10⁻⁵ EUR). No f64 in billing path.
No rubo4e dependency Returns GridInvoice domain types. Service layer owns into_rechnung().
PID mutable GridInvoice.pid is pub — caller sets 31005 for Gas, 31011 for AWH Gas.
Decimal-only tariff input All tariff rates are rust_decimal::Decimal via from_str_exact. Never Decimal::try_from(f64).
Pure functions calculate_nne_invoice, calculate_mmm_invoice, calculate_msb_invoice are synchronous with no side effects.

Quick start

[dependencies]
grid-billing = { version = "0.10" }
rust_decimal = "1"
time         = "0.3"

NNE flat-rate (SLP)

use grid_billing::{NneInput, calculate_nne_invoice};
use rust_decimal::Decimal;
use time::macros::date;

fn d(s: &str) -> Decimal { Decimal::from_str_exact(s).unwrap() }

let invoice = calculate_nne_invoice(&NneInput {
    malo_id: "51238696780".into(),
    nb_mp_id: "9900357000004".into(),
    lf_mp_id: "9900012345678".into(),
    rechnungsnummer: "NNE-2026-01-0001".into(),
    period_from: date!(2026-01-01),
    period_to:   date!(2026-01-31),
    invoice_date: date!(2026-02-15),
    due_date:    date!(2026-03-17),  // 30-day Zahlungsziel
    arbeitsmenge_kwh: d("1500"),
    arbeitspreis_ct_per_kwh: d("28.50"),
    ka_satz_ct_per_kwh: Some(d("0.11")),  // Konzessionsabgabe
    ..NneInput::default()
}).expect("valid NNE input");

// invoice.total_eur == "435.15" (1500 × 0.285 + 1500 × 0.0011)
assert_eq!(invoice.positions.len(), 2);  // Arbeit + KA

§14a Modul 2 ToU (HT/NT split)

use grid_billing::{NneInput, calculate_nne_invoice};

let invoice = calculate_nne_invoice(&NneInput {
    // … identity fields …
    arbeitsmenge_kwh: d("0"),          // ignored when ToU is active
    arbeitspreis_ct_per_kwh: d("0"),   // ignored when ToU is active
    arbeitsmenge_ht_kwh: Some(d("900")),
    arbeitspreis_ht_ct_per_kwh: Some(d("32.00")),  // HT band
    arbeitsmenge_nt_kwh: Some(d("600")),
    arbeitspreis_nt_ct_per_kwh: Some(d("18.00")),  // NT reduced rate
    ..NneInput::default()
}).unwrap();

// Two separate Arbeit positions: "Netznutzung Arbeit HT (§14a Modul 2)" + NT
assert_eq!(invoice.positions.len(), 2);

MMM settlement (Mehr-/Mindermengen)

use grid_billing::{MmmInput, calculate_mmm_invoice};

let mut invoice = calculate_mmm_invoice(&MmmInput {
    // … identity fields …
    actual_kwh: d("1600"),   // measured
    profil_kwh: d("1500"),   // SLP forecast
    mehr_preis_ct_per_kwh:   d("4.0"),
    minder_preis_ct_per_kwh: d("2.0"),
    ..MmmInput::default()
}).unwrap();

// Override PID for Gas MMM:
// invoice.pid = 31002 by default; set 31005 for Gas if needed via outer context
assert_eq!(invoice.total_eur, d("4.00"));  // 100 kWh Mehrmengen × 0.04 EUR/kWh

// Mindermengen: negative total = NB owes LF
// actual = 1400, profil = 1500 → total = -2.00 EUR (credit)

MSB-Rechnung (PID 31009)

use grid_billing::{MsbInput, calculate_msb_invoice};

let invoice = calculate_msb_invoice(&MsbInput {
    // … identity fields …
    grundgebuehr_eur_per_month: d("9.50"),
    billing_months: 12,
    messdienstleistung_eur: Some(d("24.00")),  // optional flat fee
    ..MsbInput::default()
}).unwrap();

// 12 × 9.50 + 24.00 = 138.00 EUR
assert_eq!(invoice.total_eur, d("138.00"));
assert_eq!(invoice.pid, 31009);

Service-layer conversion to BO4E Rechnung

The caller (netzbilanzd, invoicd) does the mapping — grid-billing stays BO4E-free:

// In netzbilanzd/src/billing.rs:
use grid_billing::{GridInvoice, QuantityUnit, calculate_nne_invoice};
use rubo4e::current::{Betrag, Menge, Mengeneinheit, Preis, Rechnungsposition, Rechnung, Zeitraum};

fn into_rechnung(invoice: &GridInvoice) -> Rechnung {
    let lz = Zeitraum { startdatum: Some(invoice.period_from), enddatum: Some(invoice.period_to), ..Default::default() };
    let positions = invoice.positions.iter().map(|p| {
        let einheit = match p.unit {
            QuantityUnit::Kwh => Some(Mengeneinheit::Kwh),
            QuantityUnit::Kw  => Some(Mengeneinheit::Kw),
            QuantityUnit::Monat => Some(Mengeneinheit::Monat),
        };
        Rechnungsposition {
            positionsnummer: Some(p.number as i64),
            positionstext: Some(p.text.clone()),
            lieferungszeitraum: Some(lz.clone()),
            positions_menge: Some(Menge { wert: Some(p.quantity), einheit, ..Default::default() }),
            einzelpreis: Some(Preis { wert: Some(p.unit_price_eur.round_dp(6)), ..Default::default() }),
            gesamtpreis: Some(Betrag { wert: Some(p.net_eur.round_dp(5)), ..Default::default() }),
            ..Default::default()
        }
    }).collect();
    Rechnung {
        rechnungsnummer: Some(invoice.rechnungsnummer.clone()),
        rechnungsdatum: Some(invoice.invoice_date),
        faelligkeitsdatum: Some(invoice.due_date),
        rechnungsperiode: Some(lz),
        gesamtnetto: Some(Betrag { wert: Some(invoice.total_eur), ..Default::default() }),
        rechnungspositionen: Some(positions),
        ..Default::default()
    }
}

// Then validate:
let rechnung = into_rechnung(&invoice);
let report = InvoicCheckEngine::check(invoice.pid, &invoice.nb_mp_id, &rechnung, &store, &config);

Billing position table

Position Label Unit Condition
1 Netznutzung Arbeit kWh SLP / RLM flat-rate
1 + 2 Netznutzung Arbeit HT/NT (§14a Modul 2) kWh each When arbeitsmenge_ht_kwh + arbeitspreis_ht_ct_per_kwh are both set
next Netznutzung Leistung kW When spitzenleistung_kw + leistungspreis_eur_per_kw are both set (RLM)
last Konzessionsabgabe kWh When ka_satz_ct_per_kwh is set

All amounts are rounded to 5 decimal places per position; total rounded to 2 decimal places.

See also

  • invoic-checker — validates the generated Rechnung in the service layer
  • netzbilanzd — NB billing service that calls grid-billing
  • invoicd — LF service using grid-billing for selbstausstellen