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};
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)
}
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))
}
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 {
basiswert: Some(e.taxable_base.into_decimal().round_kfm(2)),
steuerwert: Some(e.tax_amount.into_decimal().round_kfm(2)),
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();
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()
}),
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()
};
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), ..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()
};
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())
}
fn as_bo4e_timestamp(date: time::Date) -> Option<time::OffsetDateTime> {
(0..=9999)
.contains(&date.year())
.then(|| date.midnight().assume_utc())
}
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()), recipient_id: Some("DE00012345678".into()), ..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)));
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());
}
#[test]
fn every_emitted_gutschrift_is_valid_bo4e() {
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}"));
mako_markt::bo4e::ensure_conformant(&g)
.unwrap_or_else(|e| panic!("{vat:?}: emitted a document mako would refuse: {e}"));
}
}
}