eeg-billing 0.12.0

Pure EEG/KWKG feed-in settlement for German energy markets. EEG 2017–2024 (Solarpaket I), §§20–50b EEG 2023 + §7 KWKG 2023. Multi-EEG-version rates, §12 Abs. 3 UStG, §51 Negativpreisregel, §24 Anlagenerweiterung. Zero I/O, zero async, 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
//! Direktvermarktung domain model — §§20–22 EEG 2023.
//!
//! Covers the **Direktvermarktung** regulatory framework:
//! - §20 **Pflichtgemäße Direktvermarktung** — mandatory for plants > 100 kW
//! - §21 **Freiwillige Direktvermarktung** — optional for smaller plants
//! - §22 **Ausschreibungspflicht** — tendering mandatory above capacity thresholds
//! - Monthly switching rules between Einspeisevergütung and Marktprämie (§21 Abs. 3)
//!
//! ## Key capacity thresholds (EEG 2023)
//!
//! | Rule | Threshold | Legal basis |
//! |---|---|---|
//! | Mandatory Direktvermarktung | > 100 kW | §20 Abs. 1 EEG 2023 |
//! | Ausschreibung — Solar PV | > 1,000 kWp | §22 Abs. 1 EEG 2023 |
//! | Ausschreibung — Wind Onshore | > 750 kW | §22 Abs. 2 EEG 2023 |
//! | Ausschreibung — Biomasse | > 150 kW | §22 Abs. 3 EEG 2023 |
//! | Ausschreibung — Wasserkraft | > 500 kW | §22 Abs. 4 EEG 2023 |
//! | Ausschreibung — Geothermie | > 150 kW | §22 Abs. 4 EEG 2023 |
//!
//! ## Switching between FeedInTariff and MarketPremium (§21 Abs. 3)
//!
//! A plant may switch from Direktvermarktung back to Einspeisevergütung:
//! - Only **once per calendar month** (not multiple times within a month)
//! - Requires written notice to the NB before the start of the billing period
//! - Not permitted for plants subject to **mandatory** Direktvermarktung (§20)
//!
//! **Important**: Plants in mandatory Direktvermarktung that are temporarily unable
//! to market (e.g. Direktvermarkter insolvency) use **Ausfallvergütung** (§21 Abs. 1 Nr. 2
//! EEG 2023, `TemporaryFeedInTariff` scheme) — this is NOT the same as switching back to
//! regular Einspeisevergütung.
//!
//! ## Managementprämie (§20 Abs. 3 EEG 2023)
//!
//! Paid monthly by NB to the plant operator (or Direktvermarkter) as a flat fee
//! for the administrative effort of participating in direct marketing.
//! Rate: 0.4 ct/kWh for plants ≤ 100 MW; 0.2 ct/kWh for plants > 100 MW.

use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use time::Date;

use crate::technology::ErzeugungsArt;
use crate::version::EegGesetz;

// ── Threshold helpers ─────────────────────────────────────────────────────────

/// Capacity threshold (kW) above which Direktvermarktung is **mandatory** per §20 EEG.
///
/// This threshold has been 100 kW since EEG 2012 and was not changed in EEG 2023.
pub const DIREKTVERMARKTUNG_PFLICHT_KW: Decimal = dec!(100);

/// Returns `true` when the plant is subject to **pflichtgemäße Direktvermarktung**
/// under §20 EEG 2023 (mandatory Direktvermarktung, > 100 kW installed).
///
/// Plants ≤ 100 kW may still participate voluntarily (§21) but are not required to.
///
/// ## EEG version sensitivity
///
/// The threshold has been 100 kW since EEG 2012. For EEG ≤2009 plants there was
/// no mandatory Direktvermarktung — they may stay on Einspeisevergütung forever
/// under §100 Übergangsregelung.
///
/// # Example
///
/// ```rust
/// use eeg_billing::direktverm::is_direktvermarktung_mandatory;
/// use eeg_billing::EegGesetz;
/// use rust_decimal_macros::dec;
///
/// // 150 kW plant under EEG 2023 — mandatory
/// assert!(is_direktvermarktung_mandatory(dec!(150), EegGesetz::Eeg2023));
///
/// // 80 kW plant — not mandatory (may still use voluntarily)
/// assert!(!is_direktvermarktung_mandatory(dec!(80), EegGesetz::Eeg2023));
///
/// // Any size under EEG 2009 — §20 did not exist yet
/// assert!(!is_direktvermarktung_mandatory(dec!(500), EegGesetz::Eeg2009));
/// ```
#[must_use]
pub fn is_direktvermarktung_mandatory(leistung_kw: Decimal, gesetz: EegGesetz) -> bool {
    match gesetz {
        EegGesetz::Eeg2012 | EegGesetz::Eeg2017 | EegGesetz::Eeg2021 | EegGesetz::Eeg2023 => {
            leistung_kw > DIREKTVERMARKTUNG_PFLICHT_KW
        }
        // EEG ≤2009 and KWKG: no mandatory Direktvermarktung
        _ => false,
    }
}

/// Returns `true` when the plant **must** participate in a BNetzA Ausschreibungsverfahren
/// (competitive tender) under §22 EEG 2023.
///
/// Plants above these thresholds may only receive EEG support via tender-awarded
/// `anzulegender Wert` (i.e. `TariffSource::Auction`). Statutory rates do not apply.
///
/// # Example
///
/// ```rust
/// use eeg_billing::direktverm::requires_ausschreibung;
/// use eeg_billing::ErzeugungsArt;
/// use rust_decimal_macros::dec;
///
/// // 1.5 MWp solar → tender mandatory
/// assert!(requires_ausschreibung(dec!(1500), ErzeugungsArt::SolarAufdach));
///
/// // 800 kW wind onshore → tender mandatory
/// assert!(requires_ausschreibung(dec!(800), ErzeugungsArt::WindOnshore));
///
/// // 500 kWp solar → no tender required
/// assert!(!requires_ausschreibung(dec!(500), ErzeugungsArt::SolarAufdach));
/// ```
#[must_use]
pub fn requires_ausschreibung(leistung_kw: Decimal, art: ErzeugungsArt) -> bool {
    match art {
        ErzeugungsArt::Solar
        | ErzeugungsArt::SolarAufdach
        | ErzeugungsArt::SolarFreiflaeche
        | ErzeugungsArt::SolarAgriPv
        | ErzeugungsArt::SolarMieterstrom
        | ErzeugungsArt::SolarStecker => leistung_kw > dec!(1000),

        ErzeugungsArt::WindOnshore => leistung_kw > dec!(750),
        ErzeugungsArt::WindOffshore => true, // all offshore is tendered (§23 EEG 2023)

        ErzeugungsArt::Biomasse
        | ErzeugungsArt::BiomassHolz
        | ErzeugungsArt::Biogas
        | ErzeugungsArt::Biomethan => leistung_kw > dec!(150),

        ErzeugungsArt::Wasserkraft => leistung_kw > dec!(500),
        ErzeugungsArt::Geothermie => leistung_kw > dec!(150),

        // Gas variants, Gezeiten, KWKG: no Ausschreibung under EEG 2023
        ErzeugungsArt::Klaegas
        | ErzeugungsArt::Grubengas
        | ErzeugungsArt::Deponiegas
        | ErzeugungsArt::Gezeiten
        | ErzeugungsArt::Kwk => false,
    }
}

// ── DirektvermarktungsPeriode ─────────────────────────────────────────────────

/// One period during which a plant participates in Direktvermarktung.
///
/// A plant may switch between Einspeisevergütung (FeedInTariff scheme) and
/// Marktprämie (MarketPremium scheme). Each contiguous block of Direktvermarktung
/// is one `DirektvermarktungsPeriode`.
///
/// ## Storage in `einsd`
///
/// The `direktvermarktung_perioden` column (`JSONB`) in `eeg_anlagen` stores a
/// `Vec<DirektvermarktungsPeriode>` sorted by `beginn_datum`. Use
/// `current_period()` to find the active period for a billing month.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DirektvermarktungsPeriode {
    /// First day of this Direktvermarktung period (inclusive).
    ///
    /// Must be the first day of a calendar month (§21 Abs. 3 EEG 2023).
    pub beginn_datum: Date,

    /// Last day of this period (inclusive), or `None` for the ongoing current period.
    ///
    /// When `Some(end)`, `end` must be the last day of a calendar month.
    pub ende_datum: Option<Date>,

    /// MP-ID of the Direktvermarkter (energy trader executing market access).
    ///
    /// The BDEW-Codenummer of the company acting as Direktvermarkter under §11 EEG.
    /// `None` for voluntary Direktvermarktung by the plant operator themselves.
    pub direktvermarkter_mp_id: Option<String>,

    /// Whether this period is **freiwillige Direktvermarktung** (voluntary).
    ///
    /// - `false` = pflichtgemäße Direktvermarktung (plants > 100 kW, §20)
    /// - `true` = freiwillige Direktvermarktung (plants ≤ 100 kW, §21)
    pub ist_freiwillig: bool,

    /// Anzulegender Wert agreed or awarded in this period (ct/kWh).
    ///
    /// For tender-based plants (`TariffSource::Auction`): the BNetzA-awarded AW.
    /// For non-tender plants: the statutory AW from `rates::wind_onshore_lookup` etc.
    pub anzulegender_wert_ct: Decimal,
}

impl DirektvermarktungsPeriode {
    /// Returns `true` when this period covers the given billing date.
    ///
    /// A period is active when `beginn_datum <= billing_date` and
    /// either `ende_datum.is_none()` or `billing_date <= ende_datum`.
    #[must_use]
    pub fn is_active_on(&self, billing_date: Date) -> bool {
        if billing_date < self.beginn_datum {
            return false;
        }
        match self.ende_datum {
            Some(end) => billing_date <= end,
            None => true,
        }
    }
}

/// Find the active `DirektvermarktungsPeriode` for a given billing date.
///
/// Returns `None` when no period covers `billing_date` (plant is on Einspeisevergütung).
///
/// # Example
///
/// ```rust
/// use eeg_billing::direktverm::{DirektvermarktungsPeriode, current_period};
/// use rust_decimal_macros::dec;
/// use time::macros::date;
///
/// let periods = vec![
///     DirektvermarktungsPeriode {
///         beginn_datum: date!(2024-01-01),
///         ende_datum:   Some(date!(2024-06-30)),
///         direktvermarkter_mp_id: Some("9904234560001".into()),
///         ist_freiwillig: false,
///         anzulegender_wert_ct: dec!(6.28),
///     },
/// ];
///
/// // March 2024 — within the period
/// assert!(current_period(&periods, date!(2024-03-15)).is_some());
/// // July 2024 — after the period ended
/// assert!(current_period(&periods, date!(2024-07-01)).is_none());
/// ```
#[must_use]
pub fn current_period(
    periods: &[DirektvermarktungsPeriode],
    billing_date: Date,
) -> Option<&DirektvermarktungsPeriode> {
    periods.iter().find(|p| p.is_active_on(billing_date))
}

// ── Switching validation ──────────────────────────────────────────────────────

/// Reason why a switch from Direktvermarktung to Einspeisevergütung is blocked.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum SwitchBlockedReason {
    /// Plant > 100 kW → mandatory Direktvermarktung (§20 EEG 2023).
    ///
    /// Such plants can only temporarily revert to **Ausfallvergütung** (§21 Abs. 1 Nr. 2).
    PflichtgemasseDirektvermarktung,
    /// Switch attempted within the same calendar month as the last switch.
    ///
    /// §21 Abs. 3 EEG 2023 allows at most one switch per calendar month.
    AlreadySwitchedThisMonth {
        /// Date of the most recent switch that blocks this request.
        last_switch: Date,
    },
}

/// Validate whether a plant may switch from Direktvermarktung to Einspeisevergütung
/// on the given effective date.
///
/// Returns `Ok(())` when the switch is permitted, or `Err(reason)` when blocked.
///
/// ## Note
///
/// This is a **compile-time validation helper** — it does not interact with any
/// persistent state. The caller must pass the most recent switch date from
/// the plant's history.
///
/// # Example
///
/// ```rust
/// use eeg_billing::direktverm::{validate_switch_to_vergütung, SwitchBlockedReason};
/// use eeg_billing::EegGesetz;
/// use rust_decimal_macros::dec;
/// use time::macros::date;
///
/// // 80 kW voluntary plant switching once per month — OK
/// let result = validate_switch_to_vergütung(
///     dec!(80),
///     EegGesetz::Eeg2023,
///     date!(2025-07-01), // effective from July 1
///     None,              // no previous switch
/// );
/// assert!(result.is_ok());
///
/// // 150 kW mandatory plant — blocked
/// let result = validate_switch_to_vergütung(
///     dec!(150),
///     EegGesetz::Eeg2023,
///     date!(2025-07-01),
///     None,
/// );
/// assert_eq!(result, Err(SwitchBlockedReason::PflichtgemasseDirektvermarktung));
/// ```
pub fn validate_switch_to_vergütung(
    leistung_kw: Decimal,
    gesetz: EegGesetz,
    effective_date: Date,
    last_switch_date: Option<Date>,
) -> Result<(), SwitchBlockedReason> {
    if is_direktvermarktung_mandatory(leistung_kw, gesetz) {
        return Err(SwitchBlockedReason::PflichtgemasseDirektvermarktung);
    }
    if let Some(last) = last_switch_date
        && last.year() == effective_date.year()
        && last.month() == effective_date.month()
    {
        return Err(SwitchBlockedReason::AlreadySwitchedThisMonth { last_switch: last });
    }
    Ok(())
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use time::macros::date;

    #[test]
    fn mandatory_above_100kw() {
        assert!(is_direktvermarktung_mandatory(
            dec!(100.1),
            EegGesetz::Eeg2023
        ));
        assert!(!is_direktvermarktung_mandatory(
            dec!(100),
            EegGesetz::Eeg2023
        ));
        assert!(!is_direktvermarktung_mandatory(
            dec!(50),
            EegGesetz::Eeg2023
        ));
    }

    #[test]
    fn no_mandatory_for_old_eeg() {
        // EEG 2009 plants may stay on Einspeisevergütung forever (§100 Übergangsregelung)
        assert!(!is_direktvermarktung_mandatory(
            dec!(500),
            EegGesetz::Eeg2009
        ));
        assert!(!is_direktvermarktung_mandatory(
            dec!(500),
            EegGesetz::Eeg2000
        ));
    }

    #[test]
    fn ausschreibung_solar_above_1mwp() {
        assert!(requires_ausschreibung(
            dec!(1001),
            ErzeugungsArt::SolarAufdach
        ));
        assert!(!requires_ausschreibung(
            dec!(999),
            ErzeugungsArt::SolarAufdach
        ));
    }

    #[test]
    fn ausschreibung_wind_onshore_above_750kw() {
        assert!(requires_ausschreibung(
            dec!(751),
            ErzeugungsArt::WindOnshore
        ));
        assert!(!requires_ausschreibung(
            dec!(750),
            ErzeugungsArt::WindOnshore
        ));
    }

    #[test]
    fn ausschreibung_wind_offshore_always() {
        assert!(requires_ausschreibung(dec!(1), ErzeugungsArt::WindOffshore));
    }

    #[test]
    fn ausschreibung_biomasse_above_150kw() {
        assert!(requires_ausschreibung(dec!(151), ErzeugungsArt::Biomasse));
        assert!(!requires_ausschreibung(dec!(150), ErzeugungsArt::Biomasse));
    }

    #[test]
    fn period_active_on() {
        let p = DirektvermarktungsPeriode {
            beginn_datum: date!(2024 - 01 - 01),
            ende_datum: Some(date!(2024 - 06 - 30)),
            direktvermarkter_mp_id: None,
            ist_freiwillig: true,
            anzulegender_wert_ct: dec!(6.28),
        };
        assert!(p.is_active_on(date!(2024 - 03 - 15)));
        assert!(p.is_active_on(date!(2024 - 01 - 01)));
        assert!(p.is_active_on(date!(2024 - 06 - 30)));
        assert!(!p.is_active_on(date!(2024 - 07 - 01)));
        assert!(!p.is_active_on(date!(2023 - 12 - 31)));
    }

    #[test]
    fn open_period_always_active() {
        let p = DirektvermarktungsPeriode {
            beginn_datum: date!(2024 - 01 - 01),
            ende_datum: None,
            direktvermarkter_mp_id: None,
            ist_freiwillig: false,
            anzulegender_wert_ct: dec!(6.28),
        };
        assert!(p.is_active_on(date!(2030 - 12 - 31)));
    }

    #[test]
    fn current_period_finds_active() {
        let periods = vec![DirektvermarktungsPeriode {
            beginn_datum: date!(2024 - 01 - 01),
            ende_datum: Some(date!(2024 - 06 - 30)),
            direktvermarkter_mp_id: Some("9904234560001".into()),
            ist_freiwillig: false,
            anzulegender_wert_ct: dec!(6.28),
        }];
        assert!(current_period(&periods, date!(2024 - 03 - 15)).is_some());
        assert!(current_period(&periods, date!(2024 - 07 - 01)).is_none());
    }

    #[test]
    fn switch_blocked_mandatory_plant() {
        let result = validate_switch_to_vergütung(
            dec!(150),
            EegGesetz::Eeg2023,
            date!(2025 - 07 - 01),
            None,
        );
        assert_eq!(
            result,
            Err(SwitchBlockedReason::PflichtgemasseDirektvermarktung)
        );
    }

    #[test]
    fn switch_blocked_same_month() {
        let result = validate_switch_to_vergütung(
            dec!(80),
            EegGesetz::Eeg2023,
            date!(2025 - 07 - 15),
            Some(date!(2025 - 07 - 01)),
        );
        assert!(matches!(
            result,
            Err(SwitchBlockedReason::AlreadySwitchedThisMonth { .. })
        ));
    }

    #[test]
    fn switch_allowed_different_month() {
        let result = validate_switch_to_vergütung(
            dec!(80),
            EegGesetz::Eeg2023,
            date!(2025 - 08 - 01),
            Some(date!(2025 - 07 - 01)),
        );
        assert!(result.is_ok());
    }

    #[test]
    fn switch_allowed_voluntary_no_history() {
        let result = validate_switch_to_vergütung(
            dec!(50),
            EegGesetz::Eeg2023,
            date!(2025 - 07 - 01),
            None,
        );
        assert!(result.is_ok());
    }
}