energy-billing 0.20.0

Pure multi-product retail utility billing for German markets — Strom, Gas, Wärme, Wasser and the § 14a tariffs. Zero I/O, no float money.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
//! `BillingPosition` — the atomic unit of every energy invoice.
//!
//! Every charge, credit, levy, and tax on an invoice is one `BillingPosition`.
//! The `category` and `tags` fields enable downstream routing (accounting, ERP,
//! MwSt base computation, regulatory reporting).
//!
//! ## Explainability: `PositionTrace`
//!
//! Every `BillingPosition` carries a `trace: PositionTrace` that answers
//! *"why does this amount appear on the invoice?"* — matching the audit depth of
//! `grid-billing::CalculationTrace`. Each trace records:
//! - the input quantity and unit price before rounding
//! - the formula used (human-readable)
//! - all applicable §-citations
//! - the tariff source (which product sheet supplied the rate)
//!
//! This makes every invoice amount reproducible and auditable without re-running
//! the calculation, satisfying BNetzA §20 EnWG audit requirements.

use crate::EuroAmount;
use crate::rates::RoundMoney;
use rust_decimal::Decimal;
use rust_decimal::dec;

// ── PositionTrace ─────────────────────────────────────────────────────────────

/// Full audit record for how one [`BillingPosition`] was computed.
///
/// Answers: *"Why does this charge/credit appear, and how was it calculated?"*
///
/// Every `BillingPosition` carries a `PositionTrace` so invoice auditors can
/// reconstruct any amount without re-running the billing engine.
///
/// ## Design note
///
/// Mirrors `grid_billing::CalculationTrace` — both billing engines should provide
/// the same depth of explainability.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct PositionTrace {
    /// Human-readable formula, e.g.:
    /// `"500.000 kWh × 0.30000 EUR/kWh = 150.00000 EUR"`
    pub formula: String,

    /// Input quantity before rounding (same as `BillingPosition.quantity` for most positions;
    /// may differ when pro-rata fractions are applied).
    pub input_quantity: Decimal,

    /// Input unit price in EUR before rounding (converted from ct/kWh or EUR/month).
    pub input_unit_price_eur: Decimal,

    /// Gross amount before rounding (input_quantity × input_unit_price_eur).
    pub gross_eur: Decimal,

    /// Applicable regulatory citations (§-references).
    ///
    /// Examples: `["§40 EnWG"]` (invoice content), `["§3 StromStG"]`
    /// (Stromsteuer), `["§19 Abs. 2 StromNEV"]`, `["§12 Abs. 1 UStG"]`
    /// (standard VAT rate).
    ///
    /// Two citations do **not** belong here and are easy to reach for:
    /// **§40a EnWG** is Verbrauchsermittlung (estimation), not invoice content —
    /// that is §40. **§12 Abs. 3 UStG** zero-rates the supply of a PV *system*,
    /// never electricity or feed-in; a small operator's 0 % feed-in comes from
    /// the Kleinunternehmerregelung (§19 UStG), which is an election rather than
    /// a function of plant size.
    pub regulatory_basis: Vec<String>,

    /// Tariff source reference (product sheet or contract).
    ///
    /// `None` for statutory positions (Stromsteuer, BEHG, MwSt).
    /// Set to tariff sheet ID for commodity positions from `productd`.
    pub tariff_source: Option<String>,

    /// Any pro-rata fraction applied (0.0–1.0).
    ///
    /// `None` when billing covers the full period.
    /// `Some(0.5)` means only half the billing period is charged
    /// (e.g. contract start mid-month).
    pub pro_rata_fraction: Option<Decimal>,

    /// Human-readable note on rounding, if applicable.
    pub rounding_note: Option<String>,
}

impl PositionTrace {
    /// Build a simple commodity trace (quantity × price = net).
    #[must_use]
    pub fn commodity(
        quantity: Decimal,
        unit: &str,
        unit_price_eur: Decimal,
        regulatory_basis: impl Into<String>,
    ) -> Self {
        let gross = quantity * unit_price_eur;
        Self {
            formula: format!(
                "{quantity:.3} {unit} × {unit_price_eur:.5} EUR/{unit} = {:.5} EUR",
                gross.round_kfm(5)
            ),
            input_quantity: quantity,
            input_unit_price_eur: unit_price_eur,
            gross_eur: gross,
            regulatory_basis: vec![regulatory_basis.into()],
            tariff_source: None,
            pro_rata_fraction: None,
            rounding_note: None,
        }
    }

    /// Build a tax trace (rate × base = tax amount).
    #[must_use]
    pub fn tax(
        rate: Decimal,
        netto_base_eur: Decimal,
        regulatory_basis: impl Into<String>,
    ) -> Self {
        let gross = netto_base_eur * rate;
        Self {
            formula: format!(
                "{rate:.4} × {netto_base_eur:.5} EUR = {:.5} EUR",
                gross.round_kfm(5)
            ),
            input_quantity: netto_base_eur,
            input_unit_price_eur: rate,
            gross_eur: gross,
            regulatory_basis: vec![regulatory_basis.into()],
            tariff_source: None,
            pro_rata_fraction: None,
            rounding_note: None,
        }
    }

    /// Attach a tariff source reference (product sheet ID).
    #[must_use]
    pub fn with_tariff_source(mut self, source: impl Into<String>) -> Self {
        self.tariff_source = Some(source.into());
        self
    }

    /// Attach a pro-rata fraction.
    #[must_use]
    pub fn with_pro_rata(mut self, fraction: Decimal) -> Self {
        self.pro_rata_fraction = Some(fraction);
        self
    }

    /// Add an additional regulatory citation.
    #[must_use]
    pub fn with_basis(mut self, basis: impl Into<String>) -> Self {
        self.regulatory_basis.push(basis.into());
        self
    }
}

// ── BillingWarning ────────────────────────────────────────────────────────────

/// A non-fatal warning produced during billing calculation.
///
/// Warnings do not prevent invoice generation — they flag conditions that
/// the operator should review before dispatch. Examples:
/// - Estimated meter reading (§ 40a Abs. 2 EnWG) — labeled on invoice
/// - Preisgarantie expiring in ≤ 30 days — §41 Abs. 1 Nr. 4 EnWG notice
/// - §41a dynamic tariff offered but meter is not iMSys — §41a Abs. 1 EnWG risk
/// - Consumption deviates > 50% from Vorjahresverbrauch — review reading
///
/// The service layer (`billingd`) should surface `Error`-severity warnings
/// to the operator and may block dispatch for high-severity issues.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BillingWarning {
    /// Machine-readable warning code for programmatic handling.
    pub code: &'static str,
    /// Severity level.
    pub severity: WarningSeverity,
    /// Human-readable description (shown in operator dashboard).
    pub message: String,
}

/// Severity level for [`BillingWarning`].
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub enum WarningSeverity {
    /// Informational — no action required.
    Info,
    /// Potential issue — review recommended before dispatch.
    Warning,
    /// Definite issue — operator must review before dispatch.
    Error,
}

// ── PositionCategory ──────────────────────────────────────────────────────────

/// High-level category for an invoice position.
///
/// Used by accounting systems and the MwSt engine to classify positions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum PositionCategory {
    /// Energy commodity charge (Arbeitspreis, Grundpreis).
    Commodity,
    /// Grid infrastructure charge (NNE, Konzessionsabgabe).
    GridCharge,
    /// Statutory per-unit levy (Stromsteuer, Energiesteuer, BEHG).
    Levy,
    /// Tax (MwSt / Umsatzsteuer).
    Tax,
    /// Credit note position (EEG Gutschrift, PV credit, §14a reduction).
    Credit,
    /// Commercial discount or reduction (rabatt, AufAbschlag).
    Discount,
    /// Non-commodity service fee (MSB, HEMS subscription, e-mobility roaming).
    Fee,
    /// Informational position (Brennwertkorrektur, §51 suspension info, Zählerstand).
    Info,
    /// Advance payment deduction on final invoice (Jahresabrechnung §41 EnWG).
    ///
    /// Does NOT affect `netto_eur` or `mwst_eur` — deducted separately in
    /// `Invoice::zahlbetrag_eur = brutto_eur - abschlag_total_eur`.
    Abschlag,

    /// Customer bonus (Willkommensbonus, Treuebonus, Wechselprämie).
    ///
    /// Semantically distinct from `Discount` (contractual price reduction) and
    /// from `Credit` (product-level credit note). Bonuses are one-time or
    /// conditional rewards that the customer earned by:
    /// - Switching to this supplier (Wechselprämie)
    /// - Staying with the supplier for N years (Treuebonus)
    /// - Signing up for a specific product (Willkommensbonus)
    ///
    /// MwSt treatment: same as `Discount` (reduces the MwSt base).
    Bonus,

    /// §42c EnWG Energy Sharing credit position.
    ///
    /// Community energy sharing (Energiegemeinschaft) allocation credit:
    /// the tenant's share of locally produced shared electricity.
    /// Reduces the grid consumption billed under `Commodity`.
    ///
    /// ## Legal basis
    ///
    /// §42c EnWG (Energy Sharing, EnWG-Novelle BGBl. 2025 I Nr. 347; phased from
    /// 01.06.2026 within one Bilanzkreis to 01.06.2028 across adjacent Bilanzkreise
    /// of the same Regelzone): sharing communities may distribute locally generated
    /// electricity to participants. The LF bills the full consumption and credits
    /// the sharing allocation separately.
    EnergyShare,
}

// ── BillingPosition ───────────────────────────────────────────────────────────

/// One line item on an energy invoice.
///
/// All monetary amounts are in **EUR** (not ct/kWh), stored as [`Decimal`]
/// with 5 decimal places precision (matching the internal `EuroAmount` type).
///
/// ## Sign convention
///
/// - `net_eur > 0` → debit (customer owes Lieferant)
/// - `net_eur < 0` → credit (Lieferant owes customer)
///
/// Credits (EEG feed-in, §14a reduction, rabatt) use negative `net_eur`.
///
/// ## Explainability via `PositionTrace`
///
/// Every position carries a `trace: PositionTrace` that records the formula,
/// inputs, regulatory citations, and tariff source. This enables full audit
/// reconstruction without re-running the billing engine — matching the depth
/// of `grid_billing::CalculationTrace`.
///
/// ## Tags
///
/// Tags are lower-case strings used for position filtering and routing:
/// - `"commodity"` — energy commodity (Stromsteuer base)
/// - `"nne"` — grid charge umbrella
/// - `"levy"` — statutory per-unit levy
/// - `"mwst"` — Umsatzsteuer position
/// - `"eeg"`, `"§14a"`, `"solar"` — product-specific
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BillingPosition {
    /// Human-readable description for the invoice.
    pub description: String,

    /// Legal basis (§-reference), shown on invoice if set.
    ///
    /// Short form for invoice display; the full citation tree is in `trace.regulatory_basis`.
    pub legal_basis: Option<String>,

    /// Quantity (kWh, m³, Tage, kW, or "1" for lump-sum).
    pub quantity: Decimal,

    /// Unit of measure (e.g. `"kWh"`, `"m³"`, `"Tage"`, `"kW"`, `"%"`).
    pub unit: String,

    /// Price per unit in EUR.
    ///
    /// For commodity: ct/kWh ÷ 100.
    /// For Grundpreis: EUR/year ÷ 365.
    /// For tax: the tax rate (fraction, e.g. 0.19).
    pub unit_price_eur: Decimal,

    /// Net amount in EUR = quantity × unit_price_eur (rounded to 5 dp).
    ///
    /// Negative for credits, positive for charges.
    pub net_eur: Decimal,

    /// Semantic category for accounting routing.
    pub category: PositionCategory,

    /// Free-form tags for downstream filtering.
    pub tags: Vec<String>,

    /// MwSt rate applicable to this position (fraction, e.g. `0.19`, `0.07`, `0.0`).
    ///
    /// When `Some`, the `MwStProvider` uses this rate for this position instead of the
    /// engine-wide default. Enables multi-rate VAT on a single invoice:
    /// - Standard electricity/gas: `None` (uses engine default, typically `0.19`)
    /// - Trinkwasser: `Some(dec!(0.07))` (§12 Abs. 2 Nr. 1 UStG, Anlage 2)
    /// - Solar PV ≤30 kWp since 01.01.2023: `Some(dec!(0.0))` (§12 Abs. 3 UStG Solarpaket I)
    ///
    /// Positions with category `Tax`, `Abschlag`, or `Info` are excluded from MwSt computation.
    #[serde(default)]
    pub applicable_tax_rate: Option<Decimal>,

    /// Full calculation audit trail for this position.
    ///
    /// Answers: *"Why does this amount appear, and how was it computed?"*
    /// Every position should have a non-default trace with at least one
    /// `regulatory_basis` citation.
    #[serde(default)]
    pub trace: PositionTrace,
}

/// Tag marking a position as a §13b UStG reverse-charge supply
/// (Steuerschuldnerschaft des Leistungsempfängers). Set by
/// [`BillingPosition::with_reverse_charge`]; read by the VAT-subtotal grouping in
/// [`crate::invoice::tax_subtotals_of`], which then categorises the position as
/// `ReverseCharge` (EN 16931 `AE` / BO4E `Rcv`) rather than zero-rated — the
/// supplier's invoice shows no VAT, and the recipient owes it.
pub const REVERSE_CHARGE_TAG: &str = "reverse-charge";

/// Tag marking a position as **not subject to VAT** — a hoheitliche Leistung
/// outside the scope of the UStG, EN 16931 category `O`.
///
/// Distinct from zero-rated (`Z`) and from exempt (`E`): a public-law
/// Abwassergebühr levied under a KAG-Satzung is not a supply the UStG reaches
/// at all. Set by [`BillingPosition::with_out_of_scope`].
///
/// EN 16931 **BR-O-11 … BR-O-14** make `O` exclusive: a document containing one
/// `O` breakdown group may contain no other breakdown group and no line in any
/// other category. That is a real constraint on what may share an invoice, not
/// a formatting detail — see the guard in
/// [`WaterProvider`](crate::WaterProvider).
pub const OUT_OF_SCOPE_TAG: &str = "nicht-steuerbar";

impl BillingPosition {
    /// Does this position belong in a BO4E document's `rechnungspositionen`?
    ///
    /// A BO4E `Rechnungsposition` is a **net supply line**: `gesamtnetto` is
    /// „Die Summe der Nettobeträge der Rechnungsteile", with tax in
    /// `steuerbetraege` and advances in `vorauszahlungen`. So [`Tax`] and
    /// [`Abschlag`] — which this engine keeps in one flat vector — are not
    /// positions on the wire; emitting them states each amount twice and leaves
    /// the totals irreconcilable.
    ///
    /// [`Info`] positions do belong: `net_eur == 0`, so they change no sum, and
    /// § 40 EnWG wants the Zählerstand and Brennwert lines on the document.
    ///
    /// Shared with the `en16931_map` line filter and `billingd`'s aggregate
    /// position index, which must agree on what gets emitted.
    ///
    /// [`Tax`]: PositionCategory::Tax
    /// [`Abschlag`]: PositionCategory::Abschlag
    /// [`Info`]: PositionCategory::Info
    #[must_use]
    pub const fn is_rechnungsposition(&self) -> bool {
        !matches!(
            self.category,
            PositionCategory::Tax | PositionCategory::Abschlag
        )
    }

    /// Construct a debit position (customer owes the amount).
    ///
    /// `net_eur` is automatically computed as `quantity × unit_price_eur`.
    #[must_use]
    pub fn debit(
        description: impl Into<String>,
        quantity: Decimal,
        unit: impl Into<String>,
        unit_price_eur: Decimal,
        category: PositionCategory,
    ) -> Self {
        let net_eur = validated_eur(quantity * unit_price_eur);
        let unit_str = unit.into();
        Self {
            description: description.into(),
            legal_basis: None,
            quantity,
            unit: unit_str,
            unit_price_eur,
            net_eur,
            category,
            tags: Vec::new(),
            applicable_tax_rate: None,
            trace: PositionTrace::default(),
        }
    }

    /// Construct a credit position (Lieferant owes the customer).
    ///
    /// `net_eur` is automatically negated from the absolute rate.
    #[must_use]
    pub fn credit(
        description: impl Into<String>,
        quantity: Decimal,
        unit: impl Into<String>,
        abs_rate_eur: Decimal,
        category: PositionCategory,
    ) -> Self {
        let net_eur = -validated_eur(quantity * abs_rate_eur);
        Self {
            description: description.into(),
            legal_basis: None,
            quantity,
            unit: unit.into(),
            unit_price_eur: -abs_rate_eur,
            net_eur,
            category,
            tags: Vec::new(),
            applicable_tax_rate: None,
            trace: PositionTrace::default(),
        }
    }

    /// Attach a legal basis reference (e.g. `"§3 StromStG"`).
    #[must_use]
    pub fn with_legal_basis(mut self, basis: impl Into<String>) -> Self {
        self.legal_basis = Some(basis.into());
        self
    }

    /// Add a routing tag.
    #[must_use]
    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
        self.tags.push(tag.into());
        self
    }

    /// Set the MwSt rate for this position (§ UStG).
    ///
    /// Override the engine-wide default for this specific position.
    /// Use `dec!(0.07)` for Trinkwasser (§12 Abs. 2 Nr. 1 UStG, Anlage 2),
    /// `dec!(0.0)` for solar PV ≤30 kWp (§12 Abs. 3 UStG),
    /// or omit to use the engine default (19%).
    #[must_use]
    pub fn with_tax_rate(mut self, rate: Decimal) -> Self {
        self.applicable_tax_rate = Some(rate);
        self
    }

    /// Mark this position as a §13b UStG reverse-charge supply.
    ///
    /// Forces the applicable rate to 0 % (no VAT on the supplier's invoice) and
    /// categorises the position as `ReverseCharge` (EN 16931 `AE`, BO4E `Rcv`)
    /// rather than zero-rated, so [`tax_subtotals_of`](crate::invoice::tax_subtotals_of)
    /// emits a distinct AE subtotal. Applies to electricity/gas supplied to a
    /// Stromwiederverkäufer (§13b Abs. 2 Nr. 5 lit. b UStG) — the recipient,
    /// not the supplier, owes the Umsatzsteuer.
    #[must_use]
    pub fn with_reverse_charge(mut self) -> Self {
        if !self.has_tag(REVERSE_CHARGE_TAG) {
            self.tags.push(REVERSE_CHARGE_TAG.to_owned());
        }
        self.applicable_tax_rate = Some(Decimal::ZERO);
        self
    }

    /// `true` when this position is a §13b UStG reverse-charge supply.
    #[must_use]
    pub fn is_reverse_charge(&self) -> bool {
        self.has_tag(REVERSE_CHARGE_TAG)
    }

    /// Mark this position as **not subject to VAT** (EN 16931 `O`).
    ///
    /// For hoheitliche Leistungen — a public-law Gebühr levied under a
    /// KAG-Satzung — which the UStG does not reach. Forces the rate to 0 and
    /// categorises the position `O`, not `Z`: zero-rating asserts a taxable
    /// supply at 0 %, which a Gebührenbescheid is not, and the two carry
    /// different EN 16931 business rules.
    #[must_use]
    pub fn with_out_of_scope(mut self) -> Self {
        if !self.has_tag(OUT_OF_SCOPE_TAG) {
            self.tags.push(OUT_OF_SCOPE_TAG.to_owned());
        }
        self.applicable_tax_rate = Some(Decimal::ZERO);
        self
    }

    /// `true` when this position is outside the scope of the UStG.
    #[must_use]
    pub fn is_out_of_scope(&self) -> bool {
        self.has_tag(OUT_OF_SCOPE_TAG)
    }

    /// `true` when this position carries the given tag.
    #[must_use]
    pub fn has_tag(&self, tag: &str) -> bool {
        self.tags.iter().any(|t| t == tag)
    }

    /// Sum of `net_eur` across all positions.
    #[must_use]
    pub fn net_total(positions: &[BillingPosition]) -> Decimal {
        positions.iter().map(|p| p.net_eur).sum()
    }

    /// Sum of `net_eur` for positions carrying the given tag.
    #[must_use]
    pub fn total_by_tag(positions: &[BillingPosition], tag: &str) -> Decimal {
        positions
            .iter()
            .filter(|p| p.has_tag(tag))
            .map(|p| p.net_eur)
            .sum()
    }
}

/// Round and range-validate a monetary EUR amount to 5 decimal places.
///
/// Uses [`EuroAmount`] internally to detect overflow. The ceiling is
/// `billing::Amount::MAX`, about 92,2 billion EUR, so the check is here for a
/// corrupt input rather than for a large customer.
///
/// Beyond the fixed-point range the amount is **kept** and rounded directly.
/// Zeroing it would erase the position's value silently, which is the one
/// failure a billing engine must not have; an out-of-range line instead fails
/// loudly downstream in the EN 16931 total reconciliation.
pub(crate) fn validated_eur(amount: Decimal) -> Decimal {
    EuroAmount::checked_from_decimal(amount)
        .map(EuroAmount::into_decimal)
        .unwrap_or_else(|_| amount.round_kfm(5))
}

// ── Convenience constructors ──────────────────────────────────────────────────

/// Build a commodity Grundpreis position (daily rate × billing period days).
pub(crate) fn grundpreis_position(
    label: impl Into<String>,
    daily_rate_eur: Decimal,
    days: i64,
    legal_basis: &'static str,
    tags: &[&'static str],
) -> BillingPosition {
    let mut p = BillingPosition::debit(
        label,
        Decimal::from(days),
        "Tage",
        daily_rate_eur,
        PositionCategory::Commodity,
    )
    .with_legal_basis(legal_basis)
    .with_tag("commodity")
    .with_tag("grundpreis");
    // The trace is built here, where the inputs are, so every position going
    // through this helper explains itself without each caller remembering to.
    p.trace = PositionTrace::commodity(Decimal::from(days), "Tage", daily_rate_eur, legal_basis);
    for tag in tags {
        p = p.with_tag(*tag);
    }
    p
}

/// Build a commodity Arbeitspreis position (kWh × rate in ct/kWh).
pub(crate) fn arbeitspreis_position(
    label: impl Into<String>,
    kwh: Decimal,
    rate_ct_kwh: Decimal,
    unit: &'static str,
    legal_basis: &'static str,
    tags: &[&'static str],
) -> BillingPosition {
    let mut p = BillingPosition::debit(
        label,
        kwh,
        unit,
        rate_ct_kwh / dec!(100),
        PositionCategory::Commodity,
    )
    .with_legal_basis(legal_basis)
    .with_tag("commodity")
    .with_tag("arbeitspreis");
    p.trace = PositionTrace::commodity(kwh, unit, rate_ct_kwh / dec!(100), legal_basis);
    for tag in tags {
        p = p.with_tag(*tag);
    }
    p
}

/// Build a per-unit levy position (quantity × rate in ct/unit).
/// The § 3 Abs. 1 CO2KostAufG disclosures that accompany a CO₂ cost position.
///
/// The statute lists six items a Brennstoff- or Wärmelieferant must show
/// „in allgemeinverständlicher Form". Nr. 2 — the Preisbestandteil — is the
/// levy position itself; this builds the informational ones that surround it:
///
/// | Nr. | What | Unit |
/// |---|---|---|
/// | 1 | Brennstoffemissionen der Lieferung | kg CO₂ |
/// | 3 | heizwertbezogener Emissionsfaktor | kg CO₂/kWh |
/// | 4 | Energiegehalt der Brennstoffmenge | kWh |
/// | 5 | Hinweis auf die Erstattungsansprüche | — |
///
/// Nr. 6 is conditional on the building being a vermietetes Wohngebäude with a
/// § 43 GEG Heizungsanlage. That is a fact about the Vermieter's building, not
/// about the supply, and no party to a Liefervertrag reports it here — so it is
/// not emitted and the Vermieter's own Abrechnung carries it.
///
/// `emissionsfaktor_kg_per_kwh` is the ordinance's Standardwert (§ 3 Abs. 2
/// permits no other), and the emissions follow from it and the billed quantity
/// rather than being supplied separately — one figure cannot then contradict
/// the other.
pub(crate) fn co2kostaufg_disclosures(
    menge_kwh: Decimal,
    unit: &'static str,
    emissionsfaktor_kg_per_kwh: Decimal,
    tag: &'static str,
) -> Vec<BillingPosition> {
    let emissionen_kg = (menge_kwh * emissionsfaktor_kg_per_kwh).round_kfm(2);
    let info = |description: String, legal: &'static str| BillingPosition {
        description,
        legal_basis: Some(legal.to_owned()),
        quantity: menge_kwh,
        unit: unit.to_owned(),
        unit_price_eur: Decimal::ZERO,
        net_eur: Decimal::ZERO,
        category: PositionCategory::Info,
        tags: vec!["co2kostaufg".to_owned(), tag.to_owned()],
        applicable_tax_rate: None,
        trace: PositionTrace::default(),
    };
    vec![
        info(
            format!("Brennstoffemissionen der Lieferung: {emissionen_kg} kg CO₂"),
            "CO2KostAufG § 3 Abs. 1 Nr. 1",
        ),
        info(
            format!("Heizwertbezogener Emissionsfaktor: {emissionsfaktor_kg_per_kwh} kg CO₂/kWh"),
            "CO2KostAufG § 3 Abs. 1 Nr. 3",
        ),
        info(
            format!("Energiegehalt der Lieferung: {menge_kwh} {unit}"),
            "CO2KostAufG § 3 Abs. 1 Nr. 4",
        ),
        info(
            "Erstattungsansprüche nach § 6 Absatz 2 und § 8 Absatz 2 CO2KostAufG \
             können gegenüber dem Vermieter bzw. Verpächter geltend gemacht werden."
                .to_owned(),
            "CO2KostAufG § 3 Abs. 1 Nr. 5",
        ),
    ]
}

pub(crate) fn levy_position(
    label: impl Into<String>,
    quantity: Decimal,
    unit: &'static str,
    rate_ct: Decimal,
    legal_basis: &'static str,
    tag: &'static str,
) -> BillingPosition {
    let mut p = BillingPosition::debit(
        label,
        quantity,
        unit,
        rate_ct / dec!(100),
        PositionCategory::Levy,
    )
    .with_legal_basis(legal_basis)
    .with_tag("levy")
    .with_tag(tag);
    p.trace = PositionTrace::commodity(quantity, unit, rate_ct / dec!(100), legal_basis);
    p
}