mako-gpke 0.9.0

GPKE process engine for German electricity market communication (Lieferbeginn, Lieferende, Netznutzungsabrechnung)
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
//! GPKE COMDIS 29001/29002 — Kaufmännisch-Bilanzielle Ausgleichsprozesse (KBA).
//!
//! COMDIS is the BDEW-regulated formal commercial dispute channel.
//! Without COMDIS, disputes remain in informal REMADV-only territory;
//! BNetzA may interpret NB silence on REMADV as implicit acceptance.
//!
//! ## Process flow (NB role — process owner)
//!
//! ```text
//! LF:  REMADV 33002 (Zahlungsabzug / informal payment reduction)
//! NB:  Receives REMADV 33002 via gpke-abrechnung workflow
//! NB:  Decides to formally reject → dispatches COMDIS 29001 (NB → LF, Ablehnung REMADV)
//! LF:  Receives COMDIS 29001 → formal KBA process begins
//! NB:  Receives LF counter (COMDIS 29002 NB → LF, Ablehnung IFTSTA, or negotiation)
//! Both: Resolve (settled / withdrawn / escalated to BNetzA)
//! ```
//!
//! ## Prüfidentifikatoren
//!
//! | PID   | Sender | Direction  | Description                                  |
//! |-------|--------|------------|----------------------------------------------|
//! | 29001 | NB     | NB → LF    | Ablehnung REMADV (formal rejection of REMADV dispute) |
//! | 29002 | NB     | NB → LF    | Ablehnung IFTSTA (formal rejection of IFTSTA challenge) |
//!
//! Both PIDs live in `mako-gpke` (GPKE Teil 2/3, BK6-22-024).
//!
//! ## APERAK Frist
//!
//! APERAK AHB 1.0 §2.4.1 Strom "all other" rule: **nächster Werktag 12 Uhr**.
//!
//! ## Governing ruling
//!
//! **BK6-22-024** (GPKE, Beschluss 28.10.2022).

use mako_engine::{
    error::WorkflowError,
    ids::DeadlineId,
    types::{MarktpartnerCode, MessageRef},
    workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
};

// ── PID set ───────────────────────────────────────────────────────────────────

/// COMDIS Prüfidentifikatoren in `mako-gpke`.
///
/// - 29001: LF → NB — Einleitung KBA
/// - 29002: NB → LF — Antwort auf KBA
pub const COMDIS_PIDS: &[u32] = &[29001, 29002];

/// Stable workflow name used in the `ProcessRegistry`.
pub const WORKFLOW_NAME: &str = "gpke-comdis";

/// APERAK deadline label — nächster Werktag 12 Uhr per APERAK AHB 1.0 §2.4.1.
pub const COMDIS_APERAK_WINDOW_LABEL: &str = "gpke-comdis-aperak-next-workday";

// ── Events ────────────────────────────────────────────────────────────────────

/// Events emitted by the GPKE COMDIS workflow.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum GpkeComdisEvent {
    /// PID 29001 (LF formal KBA Einleitung) received and accepted.
    Comdis29001Received {
        /// EDIFACT message reference from UNH.
        message_ref: MessageRef,
        /// Rechnungsnummer of the disputed invoice.
        rechnungsnummer: String,
        /// LF Marktpartnercode (initiating party).
        lf_code: MarktpartnerCode,
        /// NB Marktpartnercode (responding party).
        nb_code: MarktpartnerCode,
        /// Disputed amount in 1/100 EUR (integer avoids float rounding).
        disputed_amount_ct: i64,
        /// UTC timestamp of receipt.
        received_at: time::OffsetDateTime,
    },
    /// AHB profile validation passed on inbound 29001.
    ValidationPassed {
        /// Reference of the validated message.
        message_ref: MessageRef,
    },
    /// AHB profile validation failed — message rejected.
    ValidationFailed {
        /// Human-readable validation error strings.
        errors: Vec<String>,
    },
    /// NB dispatched COMDIS 29002 (counter-response).
    Comdis29002Dispatched {
        /// NB COMDIS 29002 message reference.
        counter_ref: MessageRef,
        /// `true` = NB accepts LF position, `false` = NB rejects.
        accepted: bool,
        /// Rejection reason code (EDIFACT ERC), if applicable.
        reason_code: Option<String>,
    },
    /// Dispute resolved (settled / withdrawn / escalated to BNetzA).
    ComdisResolved {
        /// Final outcome of the KBA process.
        outcome: ComdisOutcome,
    },
    /// APERAK deadline expired — operator escalation required.
    DeadlineExpired {
        /// Unique ID of the expired deadline.
        deadline_id: DeadlineId,
        /// Label of the expired deadline.
        label: Box<str>,
    },
}

impl EventPayload for GpkeComdisEvent {
    fn event_type(&self) -> &'static str {
        match self {
            Self::Comdis29001Received { .. } => "GpkeComdis29001Received",
            Self::ValidationPassed { .. } => "GpkeComdisValidationPassed",
            Self::ValidationFailed { .. } => "GpkeComdisValidationFailed",
            Self::Comdis29002Dispatched { .. } => "GpkeComdis29002Dispatched",
            Self::ComdisResolved { .. } => "GpkeComdisResolved",
            Self::DeadlineExpired { .. } => "GpkeComdisDeadlineExpired",
        }
    }
}

// ── Outcome ───────────────────────────────────────────────────────────────────

/// Possible final outcomes of a KBA (COMDIS) dispute process.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComdisOutcome {
    /// NB accepts LF position — credit note or correction issued.
    Settled,
    /// LF withdraws dispute — original invoice stands.
    Withdrawn,
    /// Escalated to BNetzA formal arbitration.
    EscalatedBnetza,
}

// ── Domain data ───────────────────────────────────────────────────────────────

/// Business data recorded at `Comdis29001Received` time and carried throughout the lifecycle.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GpkeComdisData {
    /// EDIFACT message reference from UNH of the inbound 29001.
    pub message_ref: MessageRef,
    /// Rechnungsnummer of the disputed invoice.
    pub rechnungsnummer: String,
    /// LF Marktpartnercode (initiating party).
    pub lf_code: MarktpartnerCode,
    /// NB Marktpartnercode (responding party).
    pub nb_code: MarktpartnerCode,
    /// Disputed amount in 1/100 EUR (integer arithmetic avoids float rounding).
    pub disputed_amount_ct: i64,
    /// UTC timestamp of COMDIS 29001 receipt.
    pub received_at: time::OffsetDateTime,
}

// ── State ─────────────────────────────────────────────────────────────────────

/// Lifecycle state of a GPKE COMDIS process stream.
///
/// ```text
/// New → Initiated → ValidationPassed → Answered → Resolved
///                 ↘ ValidationFailed → Rejected
///                                    ↘ DeadlineExpired → Rejected
/// ```
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status", content = "data")]
pub enum GpkeComdisState {
    /// No events yet.
    #[default]
    New,
    /// 29001 received; awaiting AHB validation outcome.
    Initiated(GpkeComdisData),
    /// AHB validation passed; NB must respond (nächster Werktag 12 Uhr).
    ValidationPassed(GpkeComdisData),
    /// NB dispatched COMDIS 29002.
    Answered(GpkeComdisData),
    /// Process resolved.
    Resolved(GpkeComdisData),
    /// Process rejected due to validation failure or deadline expiry.
    Rejected {
        /// Human-readable rejection reason.
        reason: String,
    },
}

// ── Commands ──────────────────────────────────────────────────────────────────

/// Commands handled by the GPKE COMDIS workflow.
#[derive(Debug, Clone)]
pub enum GpkeComdisCommand {
    /// Inbound COMDIS 29001 from the LF at the AS4 boundary.
    Receive29001 {
        /// EDIFACT message reference from UNH.
        message_ref: MessageRef,
        /// Rechnungsnummer of the disputed invoice.
        rechnungsnummer: String,
        /// LF Marktpartnercode (initiating party).
        lf_code: MarktpartnerCode,
        /// NB Marktpartnercode (responding party).
        nb_code: MarktpartnerCode,
        /// Disputed amount in 1/100 EUR.
        disputed_amount_ct: i64,
        /// UTC timestamp of receipt.
        received_at: time::OffsetDateTime,
        /// `true` if AHB profile validation passed.
        validation_passed: bool,
        /// Human-readable validation error strings (empty when `validation_passed = true`).
        validation_errors: Vec<String>,
    },
    /// NB operator dispatches COMDIS 29002 counter-response.
    Dispatch29002 {
        /// NB COMDIS 29002 message reference.
        counter_ref: MessageRef,
        /// `true` if NB accepts LF position.
        accepted: bool,
        /// Rejection reason code (EDIFACT ERC), if applicable.
        reason_code: Option<String>,
    },
    /// Mark the dispute as resolved (operator action after 29002 exchange).
    Resolve {
        /// Final outcome of the KBA process.
        outcome: ComdisOutcome,
    },
    /// APERAK deadline expired (engine deadline callback).
    TimeoutExpired {
        /// Unique ID of the expired deadline.
        deadline_id: DeadlineId,
        /// Label of the expired deadline.
        label: Box<str>,
    },
}

impl CommandPayload for GpkeComdisCommand {}

// ── Workflow ──────────────────────────────────────────────────────────────────

/// GPKE COMDIS workflow — KBA formal dispute lifecycle for PIDs 29001/29002.
pub struct GpkeComdisWorkflow;

impl Workflow for GpkeComdisWorkflow {
    type State = GpkeComdisState;
    type Event = GpkeComdisEvent;
    type Command = GpkeComdisCommand;

    fn on_deadline(
        deadline: &mako_engine::deadline::Deadline,
        state: &Self::State,
    ) -> Option<Self::Command> {
        match (deadline.label(), state) {
            (
                COMDIS_APERAK_WINDOW_LABEL,
                GpkeComdisState::Initiated(_) | GpkeComdisState::ValidationPassed(_),
            ) => Some(GpkeComdisCommand::TimeoutExpired {
                deadline_id: deadline.deadline_id(),
                label: deadline.label().into(),
            }),
            _ => None,
        }
    }

    fn apply(state: Self::State, event: &Self::Event) -> Self::State {
        match event {
            GpkeComdisEvent::Comdis29001Received {
                message_ref,
                rechnungsnummer,
                lf_code,
                nb_code,
                disputed_amount_ct,
                received_at,
            } => GpkeComdisState::Initiated(GpkeComdisData {
                message_ref: message_ref.clone(),
                rechnungsnummer: rechnungsnummer.clone(),
                lf_code: lf_code.clone(),
                nb_code: nb_code.clone(),
                disputed_amount_ct: *disputed_amount_ct,
                received_at: *received_at,
            }),

            GpkeComdisEvent::ValidationPassed { .. } => match state {
                GpkeComdisState::Initiated(data) => GpkeComdisState::ValidationPassed(data),
                other => other,
            },

            GpkeComdisEvent::ValidationFailed { errors } => GpkeComdisState::Rejected {
                reason: errors.join("; "),
            },

            GpkeComdisEvent::Comdis29002Dispatched { .. } => match state {
                GpkeComdisState::ValidationPassed(data) => GpkeComdisState::Answered(data),
                other => other,
            },

            GpkeComdisEvent::ComdisResolved { .. } => match state {
                GpkeComdisState::Answered(data) => GpkeComdisState::Resolved(data),
                other => other,
            },

            GpkeComdisEvent::DeadlineExpired { label, .. } => GpkeComdisState::Rejected {
                reason: format!("APERAK deadline expired: {label}"),
            },
        }
    }

    fn handle(
        state: &Self::State,
        command: Self::Command,
    ) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
        match command {
            GpkeComdisCommand::Receive29001 {
                message_ref,
                rechnungsnummer,
                lf_code,
                nb_code,
                disputed_amount_ct,
                received_at,
                validation_passed,
                validation_errors,
            } => {
                if !matches!(state, GpkeComdisState::New) {
                    return Err(WorkflowError::CommandRejected {
                        reason: "Receive29001 requires New state".into(),
                    });
                }
                let received_event = GpkeComdisEvent::Comdis29001Received {
                    message_ref: message_ref.clone(),
                    rechnungsnummer,
                    lf_code,
                    nb_code,
                    disputed_amount_ct,
                    received_at,
                };
                let validation_event = if validation_passed {
                    GpkeComdisEvent::ValidationPassed { message_ref }
                } else {
                    GpkeComdisEvent::ValidationFailed {
                        errors: validation_errors,
                    }
                };
                Ok(WorkflowOutput::from(vec![received_event, validation_event]))
            }

            GpkeComdisCommand::Dispatch29002 {
                counter_ref,
                accepted,
                reason_code,
            } => {
                if !matches!(state, GpkeComdisState::ValidationPassed(_)) {
                    return Err(WorkflowError::CommandRejected {
                        reason: "Dispatch29002 requires ValidationPassed state".into(),
                    });
                }
                Ok(WorkflowOutput::from(vec![
                    GpkeComdisEvent::Comdis29002Dispatched {
                        counter_ref,
                        accepted,
                        reason_code,
                    },
                ]))
            }

            GpkeComdisCommand::Resolve { outcome } => {
                if !matches!(state, GpkeComdisState::Answered(_)) {
                    return Err(WorkflowError::CommandRejected {
                        reason: "Resolve requires Answered state".into(),
                    });
                }
                Ok(WorkflowOutput::from(vec![
                    GpkeComdisEvent::ComdisResolved { outcome },
                ]))
            }

            GpkeComdisCommand::TimeoutExpired { deadline_id, label } => {
                Ok(WorkflowOutput::from(vec![
                    GpkeComdisEvent::DeadlineExpired { deadline_id, label },
                ]))
            }
        }
    }
}

// ── DB schema ─────────────────────────────────────────────────────────────────

/// DDL for the `comdis_records` business table.
///
/// Deploy in `invoicd` (LF role) and `netzbilanzd` (NB role).
/// The engine workflow tracks COMDIS state; this table mirrors the outcome for
/// operator reporting and BNetzA compliance documentation.
pub const COMDIS_RECORDS_DDL: &str = r"
CREATE TABLE IF NOT EXISTS comdis_records (
    id                  UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant              TEXT        NOT NULL,
    rechnungsnummer     TEXT        NOT NULL,
    comdis_ref          TEXT        NOT NULL,
    lf_mp_id            TEXT        NOT NULL,
    nb_mp_id            TEXT        NOT NULL,
    disputed_amount_ct  BIGINT      NOT NULL,
    status              TEXT        NOT NULL DEFAULT 'open'
                        CHECK (status IN ('open','answered','resolved','escalated')),
    counter_ref         TEXT,
    outcome             TEXT
                        CHECK (outcome IN ('settled','withdrawn','escalated_bnetza') OR outcome IS NULL),
    received_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
    answered_at         TIMESTAMPTZ,
    resolved_at         TIMESTAMPTZ,
    UNIQUE (tenant, rechnungsnummer, comdis_ref)
);
CREATE INDEX IF NOT EXISTS comdis_records_status   ON comdis_records (tenant, status);
CREATE INDEX IF NOT EXISTS comdis_records_lf_mp_id ON comdis_records (tenant, lf_mp_id);
";