eeg-billing 0.20.0

Pure EEG/KWKG feed-in settlement for German energy markets — EEG 2017–2024 and § 7 KWKG 2023. Zero I/O, no float money.
Documentation
//! EEG Gutschrift document — the §14 UStG self-billing invoice for feed-in.
//!
//! Under the **Gutschriftverfahren (§14 Abs. 2 Satz 2 UStG)** the Netzbetreiber
//! *issues* the settlement document to the Anlagenbetreiber (the recipient of the
//! supply issues the invoice, not the supplier). For a Regelbesteuerung operator
//! that document must show 19 % USt; for a §19 Kleinunternehmer it shows none
//! (category `E`) — but the document, with its VAT breakdown (EN 16931 BG-23), is
//! required in every case. The settlement *amount* alone was never a legal document.
//!
//! The [`settlement_to_gutschrift`] function assembles a [`billing::BillingDocument`]
//! (positions + the VAT layers for the operator's tax status) and renders it as a
//! BO4E [`rubo4e::current::Rechnung`]. The `billing` crate does the money and VAT
//! (shared with energy-/grid-billing); the BO4E rendering below is EEG-specific and
//! lives here — this is the crate's own `bo4e` bridge, the same pattern
//! `energy-billing::Invoice::to_rechnung` and `grid_billing::bo4e::into_rechnung`
//! already follow.

use crate::rounding::RoundMoney;
use billing::{BillingDocument, BillingError, DocumentMeta, LineItem, PricingModel, TaxCategory};
use rubo4e::current as bo;
use rust_decimal::Decimal;

use crate::model::SettleOutput;
use crate::tariff::EegSettleTariff;
use crate::ust::{VatStatus, ust_tax_layers};

/// Render an EEG settlement as a §14 UStG Gutschrift (`rubo4e::current::Rechnung`).
///
/// `vat` selects the tax layers (Regelbesteuerung 19 % `S` / §19 Kleinunternehmer
/// exempt `E`); `meta` carries the document facts (Gutschrift number, period, dates,
/// NB = `issuer_id`, Anlagenbetreiber = `recipient_id`). For a settlement with no
/// billable positions (NoData / PriceMissing) the Rechnung has no positions — the
/// caller decides whether to issue it.
///
/// # Errors
///
/// Propagates any [`BillingError`] from document assembly (e.g. a position amount
/// outside the representable range).
pub fn settlement_to_gutschrift(
    output: &SettleOutput,
    vat: VatStatus,
    meta: DocumentMeta,
) -> Result<bo::Rechnung, BillingError> {
    Ok(settlement_to_gutschrift_with_document(output, vat, meta)?.0)
}

/// The Gutschrift **and** the [`BillingDocument`] it came from, for callers that
/// also want the typed totals (net / tax / gross) for a ledger entry without
/// re-parsing the BO4E form.
///
/// # Errors
///
/// Propagates any [`BillingError`] from document assembly.
pub fn settlement_to_gutschrift_with_document(
    output: &SettleOutput,
    vat: VatStatus,
    meta: DocumentMeta,
) -> Result<(bo::Rechnung, BillingDocument), BillingError> {
    let positions = EegSettleTariff::new(output).positions(&())?.into_inner();
    let doc = BillingDocument::from_positions(meta, positions, ust_tax_layers(vat), vec![])?;
    let rechnung = document_to_rechnung(&doc)?;
    Ok((rechnung, doc))
}

/// Map the assembled [`BillingDocument`] to the BO4E Gutschrift.
///
/// EEG feed-in is electricity billed per kWh, so the mapping is small: no BDEW
/// Artikelnummer, one unit. The per-position `legal_basis` (`§21 EEG 2023`, …) and
/// the party MP-IDs ride as round-trip-preserved extension data BO4E has no field
/// for.
fn document_to_rechnung(doc: &BillingDocument) -> Result<bo::Rechnung, BillingError> {
    let meta = &doc.meta;

    let rechnungspositionen: Vec<bo::Rechnungsposition> = doc
        .net_positions()
        .iter()
        .enumerate()
        .map(|(i, p)| position_to_bo4e(i + 1, p))
        .collect();

    let steuerbetraege: Vec<bo::Steuerbetrag> = doc
        .tax_breakdown()
        .iter()
        .map(|e| bo::Steuerbetrag {
            // BT-116 / BT-117 are document-level amounts: EN 16931 BR-DEC-19 and
            // BR-DEC-20 cap them at two decimals, and a third of a cent is not
            // bankable in any case.
            basiswert: Some(e.taxable_base.into_decimal().round_kfm(2)),
            steuerwert: Some(e.tax_amount.into_decimal().round_kfm(2)),
            // BO4E carries the rate as a percentage (BT-119); billing stores a fraction.
            steuersatz: Some(e.rate * Decimal::ONE_HUNDRED),
            steuerart: Some(match e.category {
                TaxCategory::ReverseCharge => bo::Steuerart::Rcv,
                _ => bo::Steuerart::Ust,
            }),
            waehrungscode: Some(bo::Waehrungscode::Eur),
            ..Default::default()
        })
        .collect();

    // EN 16931 BR-DEC-14 / -13 / -15: BT-109, BT-110 and BT-112 carry at most two
    // decimals. 81.10 net at 19 % is 15.409 EUR of VAT — a figure no bank can
    // move — so the cent rounding happens here, once, and the gross is the sum of
    // the two rounded parts rather than a third independently rounded number.
    let taxable_total = doc.taxable_total()?.into_decimal().round_kfm(2);
    let vat_total = doc.vat_total()?.into_decimal().round_kfm(2);
    let non_vat_total = (doc.gross_total().into_decimal()
        - doc.taxable_total()?.into_decimal()
        - doc.vat_total()?.into_decimal())
    .round_kfm(2);

    let mut rechnung = bo::Rechnung {
        typ: Some(bo::BoTyp::Rechnung),
        sparte: Some(bo::Sparte::Strom),
        rechnungsnummer: non_empty(&meta.invoice_number),
        rechnungstitel: non_empty(&meta.period_label),
        rechnungsdatum: meta
            .issue_date
            .as_deref()
            .and_then(parse_iso_date)
            .and_then(as_bo4e_timestamp),
        faelligkeitsdatum: meta
            .due_date
            .as_deref()
            .and_then(parse_iso_date)
            .and_then(as_bo4e_timestamp),
        rechnungsperiode: meta.period.as_ref().map(|per| bo::Zeitraum {
            startdatum: parse_iso_date(&per.from),
            enddatum: parse_iso_date(&per.to),
            ..Default::default()
        }),
        // BO4E gesamtnetto/-steuer are EN 16931 BT-109 (taxable base) and BT-110
        // (VAT total). `taxable_total()`/`vat_total()` are the BT-accurate
        // accessors — unlike `net_total()` (BT-106−BT-107) and `tax_total()` (all
        // tax layers incl. non-VAT levies), they stay correct if the Gutschrift
        // ever gains a document charge or a second, non-VAT tax layer.
        gesamtnetto: Some(betrag(taxable_total)),
        gesamtsteuer: Some(betrag(vat_total)),
        gesamtbrutto: Some(betrag(taxable_total + vat_total + non_vat_total)),
        steuerbetraege: (!steuerbetraege.is_empty()).then_some(steuerbetraege),
        rechnungspositionen: Some(rechnungspositionen),
        ..Default::default()
    };

    // The NB issues the Gutschrift (Gutschriftverfahren); the Anlagenbetreiber
    // receives it. BO4E models both as a `Geschaeftspartner`, so that is where
    // they go — carrying the MP-ID as a `mako:marktpartnercode` ZusatzAttribut,
    // the same shape `energy-billing` uses on its own invoices. Only the MP-IDs
    // are known here, and a `Geschaeftspartner` carrying nothing else satisfies
    // the field.
    fn party(mp_id: &str) -> Box<bo::Geschaeftspartner> {
        Box::new(bo::Geschaeftspartner {
            zusatz_attribute: Some(vec![bo::ZusatzAttribut {
                name: Some("mako:marktpartnercode".to_owned()),
                wert: Some(mp_id.into()),
                ..Default::default()
            }]),
            ..Default::default()
        })
    }
    rechnung.rechnungsersteller = meta.issuer_id.as_ref().map(|id| party(id.value.as_str()));
    rechnung.rechnungsempfaenger = meta
        .recipient_id
        .as_ref()
        .map(|id| party(id.value.as_str()));
    Ok(rechnung)
}

fn position_to_bo4e(number: usize, p: &LineItem) -> bo::Rechnungsposition {
    let positions_menge = p.quantity.as_ref().map(|q| bo::Menge {
        wert: Some(q.value),
        einheit: Some(bo::Mengeneinheit::Kwh), // EEG feed-in is always kWh
        ..Default::default()
    });
    let einzelpreis = p.unit_price.as_ref().map(|up| bo::Preis {
        wert: Some(up.value),
        einheit: Some(bo::Waehrungseinheit::Eur),
        ..Default::default()
    });
    let mut pos = bo::Rechnungsposition {
        positionsnummer: Some(number as i64),
        positionstext: non_empty(&p.description),
        positions_menge,
        einzelpreis,
        gesamtpreis: Some(betrag(p.net_amount.into_decimal())),
        ..Default::default()
    };
    // The legal basis (§21 EEG 2023, …) is the audit record of why the rate
    // applies. It rides in `zusatzAttribute` — a real BO4E field — under the
    // `mako:` namespace, not as a bare `_additional` key: an unprefixed
    // `rechtlicheGrundlage` is indistinguishable from a field BO4E might define
    // and from one the counterparty's own system writes.
    if let Some(lb) = p.get_meta("legal_basis") {
        pos.zusatz_attribute
            .get_or_insert_with(Vec::new)
            .push(bo::ZusatzAttribut {
                name: Some("mako:rechtliche_grundlage".to_owned()),
                wert: Some(lb.into()),
                ..Default::default()
            });
    }
    pos
}

fn betrag(wert: Decimal) -> bo::Betrag {
    bo::Betrag {
        wert: Some(wert),
        waehrung: Some(bo::Waehrungscode::Eur),
        ..Default::default()
    }
}

fn non_empty(s: &str) -> Option<String> {
    (!s.is_empty()).then(|| s.to_owned())
}

/// A BO4E date-only market value as the `date-time` the schema declares.
///
/// BDEW INVOIC transmits `rechnungsdatum` as `DTM+137` and `faelligkeitsdatum`
/// as `SG8 DTM+265`, both with DE 2379 = `303` — `CCYYMMDDHHMMZZZ`, a *timestamp*
/// — while BO4E types both `format: date-time`. (The value is still a calendar
/// date in meaning; the wire simply carries it at 303 precision. The only INVOIC
/// DTM admitting `102` is `DTM+203`.)
///
/// **Midnight UTC.** `Rechnung::rechnungsdatum_date()` reads the date in the
/// offset the payload carries, so `+00:00` reads back as the date that went in
/// and stays that date under any later normalisation; a `+01:00` midnight
/// becomes the previous day the moment someone converts it.
///
/// `None` for a year outside RFC 3339's `0000`–`9999`, which the field
/// serialises as. The field is optional and an invoice with no billing period
/// has no issue date, so it is omitted rather than made fatal — rejecting a
/// periodless invoice is the engine's job, not the serializer's.
fn as_bo4e_timestamp(date: time::Date) -> Option<time::OffsetDateTime> {
    (0..=9999)
        .contains(&date.year())
        .then(|| date.midnight().assume_utc())
}

/// Parse an ISO `YYYY-MM-DD` date (tolerating a trailing time); `None` otherwise.
fn parse_iso_date(s: &str) -> Option<time::Date> {
    let date_part = s.get(..10)?;
    let fmt = time::macros::format_description!("[year]-[month]-[day]");
    time::Date::parse(date_part, fmt).ok()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{SettleInput, SettlementScheme, calculate_settlement};
    use billing::{Currency, Period};
    use rust_decimal::dec;

    fn meta() -> DocumentMeta {
        DocumentMeta {
            invoice_number: "GS-EEG-2026-07-000123".into(),
            currency: Currency::EUR,
            period_label: "Juli 2026".into(),
            period: Some(Period::from_display("2026-07-01", "2026-07-31")),
            issue_date: Some("2026-08-05".into()),
            due_date: Some("2026-08-15".into()),
            issuer_id: Some("9904234560001".into()), // NB MP-ID (issues the Gutschrift)
            recipient_id: Some("DE00012345678".into()), // Anlagenbetreiber
            ..Default::default()
        }
    }

    fn feed_in_output() -> SettleOutput {
        calculate_settlement(&SettleInput {
            scheme: SettlementScheme::FeedInTariff {
                verguetungssatz_ct: dec!(8.11),
            },
            einspeisemenge_kwh: Some(dec!(1000)),
            ..SettleInput::default()
        })
    }

    #[test]
    fn regelbesteuerung_gutschrift_shows_19pct_ust() {
        let r = settlement_to_gutschrift(&feed_in_output(), VatStatus::Regelbesteuerung, meta())
            .unwrap();
        assert_eq!(r.rechnungsnummer.as_deref(), Some("GS-EEG-2026-07-000123"));
        assert_eq!(r.sparte, Some(bo::Sparte::Strom));
        assert_eq!(r.gesamtnetto.unwrap().wert, Some(dec!(81.10)));
        // EN 16931 BR-DEC-13/-14/-15: 81.10 × 19 % = 15.409 → 15.41, gross 96.51.
        assert_eq!(r.gesamtsteuer.unwrap().wert, Some(dec!(15.41)));
        assert_eq!(r.gesamtbrutto.unwrap().wert, Some(dec!(96.51)));
        let steuer = r
            .steuerbetraege
            .expect("a Gutschrift must carry the VAT breakdown");
        assert_eq!(steuer.len(), 1);
        assert_eq!(steuer[0].steuersatz, Some(dec!(19)));
        let pos = r.rechnungspositionen.unwrap();
        assert_eq!(
            pos[0].positions_menge.as_ref().unwrap().einheit,
            Some(bo::Mengeneinheit::Kwh)
        );
    }

    #[test]
    fn kleinunternehmer_gutschrift_is_exempt_not_missing() {
        let r = settlement_to_gutschrift(&feed_in_output(), VatStatus::Kleinunternehmer, meta())
            .unwrap();
        assert_eq!(r.gesamtbrutto.unwrap().wert, r.gesamtnetto.unwrap().wert);
        let steuer = r
            .steuerbetraege
            .expect("§19 exempt still carries a breakdown");
        assert!(steuer[0].steuerwert.unwrap().is_zero());
    }

    // ── Outbound BO4E conformance ────────────────────────────────────────────

    /// The §14 UStG Gutschrift this module emits must be valid BO4E.
    ///
    /// It is issued *by the NB to the Anlagenbetreiber* — a document that
    /// leaves the building — and BO4E `Rechnungstyp` has no Gutschrift value,
    /// so the shape here is the one `energy-billing` uses: leave the typed
    /// field absent and label the process in a `mako:` `ZusatzAttribut`. What
    /// this test forbids is the other resolution — forcing a near-enough
    /// `Rechnungstyp` in, or letting any enum fall through to `Unknown`.
    ///
    /// Typed only: this crate carries no `serde_json` (it stays publishable and
    /// rubo4e-free by default, `bo4e` being an opt-in feature), and the JSON
    /// form is the same document — `einsd` is where that round-trip is
    /// exercised.
    #[test]
    fn every_emitted_gutschrift_is_valid_bo4e() {
        // Both VAT statuses — they set different `Steuerart` values, which is
        // the branch most likely to reach for an enum BO4E does not define.
        for vat in [VatStatus::Regelbesteuerung, VatStatus::Kleinunternehmer] {
            let g = settlement_to_gutschrift(&feed_in_output(), vat, meta())
                .unwrap_or_else(|e| panic!("{vat:?}: settlement failed: {e}"));
            // The outbound gate: out-of-schema enums *and* the BO4E-stated
            // rules — net plus tax is gross, the Steuerbetrag breakdown sums to
            // the tax total, the positions sum to the net.
            mako_markt::bo4e::ensure_conformant(&g)
                .unwrap_or_else(|e| panic!("{vat:?}: emitted a document mako would refuse: {e}"));
        }
    }
}