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
//! Monthly settlement lifecycle state machine for EEG plants.
//!
//! Every EEG plant has a **per-period settlement state** that describes whether
//! the full Vergütung, a reduced amount, or no payment at all can be disbursed.
//!
//! ## State machine
//!
//! ```text
//!                ┌─────────────────────────────────────────┐
//!                │               NORMAL FLOW               │
//!                └─────────────────────────────────────────┘
//!
//!   PlantCommissioned ──→ Active (Vergütung flows normally)
//!//!                            ├──→ Reduced (§52 sanction, §53b, technical defect)
//!                            │        └──→ Active (when violation resolved)
//!//!                            ├──→ Suspended (no payment, §52 EEG ≤2021 MaStR)
//!                            │        └──→ Active (when MaStR registered)
//!//!                            ├──→ Interrupted (temporary: negative prices, force majeure)
//!                            │        └──→ Active (next period)
//!//!                            ├──→ PostEeg (Förderdauer expired, EPEX basis)
//!//!                            └──→ Ended (plant decommissioned or Förderdauer expired + no PostEEG)
//! ```
//!
//! ## Relationship to `SettlementStatus`
//!
//! `SettlementStatus` in `SettleOutput` reflects the **calculation result** for
//! a single period. `SettlementPeriodState` is the **persistent plant-level state**
//! stored in `einsd`'s DB and used as context for the next month's settlement.
//!
//! | SettlementStatus | Typical SettlementPeriodState |
//! |---|---|
//! | `Calculated` | `Active` or `Reduced` |
//! | `NoData` | `Active` (data pending) |
//! | `PriceMissing` | `Active` (EPEX data pending) |
//! | `Sanctioned` | `Suspended` or `Reduced` |
//! | `FoerderungBeendet` | `Ended` or `PostEeg` |

use time::Date;

// ── SettlementPeriodState ─────────────────────────────────────────────────────

/// Persistent per-plant monthly settlement lifecycle state.
///
/// Stored in `einsd`'s `eeg_anlagen.settlement_state` column.
/// Determines how the next billing period is processed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum SettlementPeriodState {
    /// Normal: full Vergütung / Marktprämie flows as per the applicable scheme.
    ///
    /// `SettleInput::sanktion` should be `None` and `pflichtverstoss` empty.
    Active,

    /// Vergütung reduced to a fraction or different basis due to ongoing sanction.
    ///
    /// Examples:
    /// - §52 Abs. 3 EEG ≤2021: 20% reduction (SanktionAlt::VerguetungReduziert20Prozent)
    /// - §52 Abs. 2 EEG ≤2021: reduced to EPEX Marktwert (SanktionAlt::VerguetungAufMarktwert)
    /// - §52 EEG 2023 Pflichtzahlungen active but Vergütung still flows
    /// - §53b regional reduction in effect
    Reduced,

    /// No EEG payment disbursed.
    ///
    /// Examples:
    /// - §52 Abs. 1 EEG ≤2021: MaStR not registered (VerguetungAufNull)
    /// - §52 Abs. 1 EEG ≤2021: Direktvermarktungspflicht not met (VerguetungAufNull)
    Suspended,

    /// Temporarily no payment this period (data, price, or force-majeure related).
    ///
    /// Unlike `Suspended`, this is not a regulatory sanction — the plant is healthy
    /// and will resume normally next period. No operator action required.
    ///
    /// Examples:
    /// - Meter data not yet available (`SettlementStatus::NoData`)
    /// - EPEX monthly price not yet imported (`SettlementStatus::PriceMissing`)
    Interrupted,

    /// 20-year Förderdauer expired; plant now eligible for post-EEG remuneration.
    ///
    /// Settlement continues but at EPEX spot price (`SettlementScheme::PostEeg`).
    /// The plant's `foerderendedatum` has passed.
    PostEeg,

    /// Plant has no further EEG billing (decommissioned or no post-EEG continuation).
    ///
    /// Terminal state. No more settlement periods expected.
    Ended,
}

impl SettlementPeriodState {
    /// Returns `true` when the plant can potentially receive a payment this period.
    #[must_use]
    pub fn is_payable(self) -> bool {
        matches!(
            self,
            Self::Active | Self::Reduced | Self::PostEeg | Self::Interrupted
        )
    }

    /// Returns `true` when this state represents a regulatory sanction that requires
    /// operator action to resolve.
    #[must_use]
    pub fn requires_operator_action(self) -> bool {
        matches!(self, Self::Suspended | Self::Reduced)
    }

    /// Returns `true` when this is a terminal state (no future settlements).
    #[must_use]
    pub fn is_terminal(self) -> bool {
        self == Self::Ended
    }

    /// Convert to the DB string representation.
    ///
    /// Used for `eeg_anlagen.settlement_state` column.
    #[must_use]
    pub fn to_db_str(self) -> &'static str {
        match self {
            Self::Active => "active",
            Self::Reduced => "reduced",
            Self::Suspended => "suspended",
            Self::Interrupted => "interrupted",
            Self::PostEeg => "post_eeg",
            Self::Ended => "ended",
        }
    }

    /// Parse from DB string.
    ///
    /// # Errors
    ///
    /// Returns `Err` for unknown values.
    pub fn from_db_str(s: &str) -> Result<Self, InvalidSettlementPeriodState> {
        match s {
            "active" => Ok(Self::Active),
            "reduced" => Ok(Self::Reduced),
            "suspended" => Ok(Self::Suspended),
            "interrupted" => Ok(Self::Interrupted),
            "post_eeg" => Ok(Self::PostEeg),
            "ended" => Ok(Self::Ended),
            other => Err(InvalidSettlementPeriodState(other.to_owned())),
        }
    }
}

/// Error returned when a DB string cannot be parsed as [`SettlementPeriodState`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid settlement_period_state: '{0}'")]
pub struct InvalidSettlementPeriodState(pub String);

// ── StateTransition ───────────────────────────────────────────────────────────

/// A recorded transition of a plant's settlement state.
///
/// Stored in `einsd`'s `settlement_state_transitions` audit table.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StateTransition {
    /// State before the transition.
    pub from: SettlementPeriodState,
    /// State after the transition.
    pub to: SettlementPeriodState,
    /// First billing period in the new state (year-month).
    pub effective_from: Date,
    /// Human-readable reason for the transition.
    pub reason: StateTransitionReason,
}

/// Reason for a settlement state change.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum StateTransitionReason {
    /// Plant first commissioned and registered in einsd.
    InitialCommissioning,
    /// MaStR registration confirmed → suspending sanction lifted.
    MastrRegistered,
    /// §9 EEG Fernsteuerbarkeit installed.
    FernsteuerbarkeitmInstalled,
    /// Direktvermarktung started (§20 / §21 EEG).
    DirektvermarktungStarted,
    /// Direktvermarktung ended, switched back to Einspeisevergütung.
    DirektvermarktungEnded,
    /// §52 violation detected.
    Sect52ViolationDetected,
    /// §52 violation resolved retroactively.
    Sect52ViolationResolved,
    /// Förderdauer expired.
    FoerderungExpired,
    /// Post-EEG operation started (EPEX spot basis).
    PostEegStarted,
    /// Plant decommissioned.
    Decommissioned,
    /// Repowering — new Förderdauer begins.
    Repowering,
}

// ── State derivation helpers ──────────────────────────────────────────────────

/// Derive the expected [`SettlementPeriodState`] from plant compliance facts.
///
/// This is a **deterministic helper** — it does not access the DB.
/// The actual state stored in `einsd` may lag behind by one billing period
/// (state is updated after each month's settlement run).
///
/// ## Parameters
///
/// - `mastr_registriert`: whether the plant has confirmed MaStR registration
/// - `fernsteuerbarkeit_datum`: when §9 Fernsteuerbarkeit was installed (None = not installed)
/// - `leistung_kwp`: installed capacity
/// - `foerderendedatum`: subsidy end date (None = not expired)
/// - `billing_date`: first day of the billing period to evaluate
/// - `eeg_gesetz_year`: EEG law year (0 = KWKG, 2000/2004/…/2023 = EEG version)
///
/// # Example
///
/// ```rust
/// use eeg_billing::settlement_state::{derive_settlement_state, SettlementPeriodState};
/// use rust_decimal_macros::dec;
/// use time::macros::date;
///
/// // Healthy plant — active (50 kW, Fernsteuerbarkeit installed 2024)
/// let state = derive_settlement_state(true, Some(date!(2024-01-01)), dec!(50), Some(date!(2040-12-31)), date!(2026-07-01), 2023);
/// assert_eq!(state, SettlementPeriodState::Active);
///
/// // MaStR not registered, EEG 2023 → Reduced (penalty, not suspension)
/// let state2 = derive_settlement_state(false, None, dec!(50), Some(date!(2040-12-31)), date!(2026-07-01), 2023);
/// assert_eq!(state2, SettlementPeriodState::Reduced);
///
/// // MaStR not registered, EEG 2017 → Suspended (VergütungAufNull)
/// let state3 = derive_settlement_state(false, None, dec!(50), Some(date!(2040-12-31)), date!(2026-07-01), 2017);
/// assert_eq!(state3, SettlementPeriodState::Suspended);
///
/// // Förderdauer expired → PostEeg
/// let state4 = derive_settlement_state(true, None, dec!(50), Some(date!(2020-12-31)), date!(2026-07-01), 2023);
/// assert_eq!(state4, SettlementPeriodState::PostEeg);
/// ```
#[must_use]
pub fn derive_settlement_state(
    mastr_registriert: bool,
    fernsteuerbarkeit_datum: Option<Date>,
    leistung_kwp: rust_decimal::Decimal,
    foerderendedatum: Option<Date>,
    billing_date: Date,
    eeg_gesetz_year: i16,
) -> SettlementPeriodState {
    use rust_decimal_macros::dec;

    // ── Förderdauer expired ───────────────────────────────────────────────────
    if let Some(fed) = foerderendedatum
        && billing_date > fed
    {
        return SettlementPeriodState::PostEeg;
    }

    // ── MaStR not registered ──────────────────────────────────────────────────
    if !mastr_registriert {
        return if eeg_gesetz_year >= 2023 {
            // EEG 2023: Pflichtzahlung, Vergütung still flows (§52 Abs. 1 Nr. 11)
            SettlementPeriodState::Reduced
        } else {
            // EEG ≤2021 via §100: VerguetungAufNull (§47 EEG 2021 old regime)
            SettlementPeriodState::Suspended
        };
    }

    // ── Fernsteuerbarkeit not installed (§9 EEG) ──────────────────────────────
    let fernsteuerbarkeit_required = leistung_kwp >= dec!(25);
    if fernsteuerbarkeit_required && fernsteuerbarkeit_datum.is_none() {
        return if eeg_gesetz_year >= 2023 {
            // EEG 2023: Pflichtzahlung €10/kW/month (§52 Abs. 1 Nr. 1)
            SettlementPeriodState::Reduced
        } else {
            // EEG ≤2021: VerguetungAufMarktwert (§52 Abs. 2 old regime)
            SettlementPeriodState::Reduced // reduced to EPEX Marktwert
        };
    }

    // ── All checks pass → Active ──────────────────────────────────────────────
    SettlementPeriodState::Active
}

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

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

    #[test]
    fn db_roundtrip_all_states() {
        let states = [
            SettlementPeriodState::Active,
            SettlementPeriodState::Reduced,
            SettlementPeriodState::Suspended,
            SettlementPeriodState::Interrupted,
            SettlementPeriodState::PostEeg,
            SettlementPeriodState::Ended,
        ];
        for s in states {
            let db = s.to_db_str();
            let parsed = SettlementPeriodState::from_db_str(db).unwrap();
            assert_eq!(s, parsed, "roundtrip failed for {s:?}");
        }
    }

    #[test]
    fn unknown_db_str_returns_error() {
        assert!(SettlementPeriodState::from_db_str("unknown").is_err());
    }

    #[test]
    fn is_payable_states() {
        assert!(SettlementPeriodState::Active.is_payable());
        assert!(SettlementPeriodState::Reduced.is_payable());
        assert!(SettlementPeriodState::PostEeg.is_payable());
        assert!(SettlementPeriodState::Interrupted.is_payable());
        assert!(!SettlementPeriodState::Suspended.is_payable());
        assert!(!SettlementPeriodState::Ended.is_payable());
    }

    #[test]
    fn derive_active_healthy_plant() {
        let state = derive_settlement_state(
            true,
            Some(date!(2024 - 01 - 01)),
            dec!(50),
            Some(date!(2040 - 12 - 31)),
            date!(2026 - 07 - 01),
            2023,
        );
        assert_eq!(state, SettlementPeriodState::Active);
    }

    #[test]
    fn derive_post_eeg_expired() {
        let state = derive_settlement_state(
            true,
            None,
            dec!(50),
            Some(date!(2020 - 12 - 31)),
            date!(2026 - 07 - 01),
            2023,
        );
        assert_eq!(state, SettlementPeriodState::PostEeg);
    }

    #[test]
    fn derive_reduced_eeg2023_mastr_missing() {
        let state = derive_settlement_state(
            false,
            None,
            dec!(50),
            Some(date!(2040 - 12 - 31)),
            date!(2026 - 07 - 01),
            2023,
        );
        assert_eq!(state, SettlementPeriodState::Reduced);
    }

    #[test]
    fn derive_suspended_eeg2017_mastr_missing() {
        let state = derive_settlement_state(
            false,
            None,
            dec!(50),
            Some(date!(2040 - 12 - 31)),
            date!(2026 - 07 - 01),
            2017,
        );
        assert_eq!(state, SettlementPeriodState::Suspended);
    }

    #[test]
    fn derive_reduced_fernsteuerbarkeit_missing_eeg2023() {
        // 50 kW plant (≥25 kW requires Fernsteuerbarkeit)
        let state = derive_settlement_state(
            true,
            None,
            dec!(50), // fernsteuerbarkeit_datum = None
            Some(date!(2040 - 12 - 31)),
            date!(2026 - 07 - 01),
            2023,
        );
        assert_eq!(state, SettlementPeriodState::Reduced);
    }

    #[test]
    fn derive_active_small_plant_no_fernsteuerbarkeit_needed() {
        // 5 kW plant < 25 kW → Fernsteuerbarkeit not required
        let state = derive_settlement_state(
            true,
            None,
            dec!(5),
            Some(date!(2040 - 12 - 31)),
            date!(2026 - 07 - 01),
            2023,
        );
        assert_eq!(state, SettlementPeriodState::Active);
    }
}