mako_invoic/lib.rs
1//! The INVOIC settle/dispute state machine, shared by every billing family.
2//!
3//! Every billing process in German market communication is the same
4//! conversation. An invoice is issued; the recipient validates it against the
5//! AHB and either settles or disputes it; where this deployment is the
6//! *issuer*, a REMADV comes back confirming or refusing payment, and a COMDIS
7//! may refuse that REMADV in turn.
8//!
9//! Nothing in it is commodity-specific: the process is keyed on the invoice
10//! reference, and the Sparte only decides which price sheet `invoic-checker`
11//! fetches — `invoicd`'s decision, not the workflow's. GPKE, WiM, GaBi Gas and
12//! GeLi Gas therefore register an [`InvoicFamily`] here rather than
13//! implementing the process.
14//!
15//! # What a family chooses
16//!
17//! [`InvoicFamily`] is the whole of the variation — the PID sets, the two role
18//! capabilities, the deadline label and the workflow name. Everything else is
19//! shared.
20//!
21//! ```text
22//! ── Recipient (payer) ────────────────────────────────────────────────
23//! New ──ReceiveInvoic──► InvoicReceived ──[valid]──► ValidationPassed
24//! ╰─[invalid]──► Rejected
25//! ValidationPassed ──SettleInvoice──► Settled
26//! ╰─DisputeInvoice──► Disputed
27//!
28//! ── Issuer ───────────────────────────────────────────────────────────
29//! New ──SendInvoic──► InvoicSent ──ReceiveRemadv 33001──► PaymentConfirmed
30//! ╰─ReceiveRemadv 33002/3/4──► PaymentDisputed
31//!
32//! ── Payer, after its REMADV was refused ──────────────────────────────
33//! any settled/sent state ──ReceiveComdis 29001──► ComdisRejected
34//!
35//! Any non-terminal state ──TimeoutExpired──► Rejected
36//! ```
37//!
38//! # Regulatory basis
39//!
40//! - **INVOIC AHB 1.0** (FV2025-10-01 onwards; AHB 2.8e before) — the invoice
41//! message and its Prüfidentifikatoren.
42//! - **REMADV AHB 1.0a § 3** — the payment advice. Settlement is „ganz oder gar
43//! nicht": there are no Teilzahlungen, so 33002/33003/33004 are all
44//! Abweisungen and only 33001 confirms.
45//! - **COMDIS AHB 1.0** — the invoicer's refusal of a payer's REMADV (29001).
46//! - **APERAK AHB 1.0 § 2.4.1** — the technical acknowledgement, 45 Minuten on a
47//! weekday. A different clock from the business answer this workflow runs.
48
49#![forbid(unsafe_code)]
50
51use rust_decimal::RoundingStrategy;
52use std::collections::HashMap;
53use std::marker::PhantomData;
54
55use mako_engine::types::Pruefidentifikator;
56use mako_engine::{
57 envelope::EventEnvelope,
58 error::WorkflowError,
59 ids::DeadlineId,
60 outbox::PendingOutbox,
61 projection::Projection,
62 types::{MarktpartnerCode, MessageRef},
63 workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
64};
65use rubo4e::current::Rechnung;
66
67// ── Shared PID sets ───────────────────────────────────────────────────────────
68
69/// The REMADV Prüfidentifikatoren a payer can answer an invoice with.
70///
71/// Received by the **invoicer** after sending an INVOIC. Per REMADV AHB 1.0a § 3
72/// settlement is „ganz oder gar nicht" — there are no Teilzahlungen, so only
73/// 33001 confirms payment and the other three are all Abweisungen.
74///
75/// | PID | Name |
76/// |-------|---------------------------------------------------------------|
77/// | 33001 | Bestätigung (Zahlungsavis — vollständige Zahlung bestätigt) |
78/// | 33002 | Abweisung (nicht positionsscharf) |
79/// | 33003 | Strom Abweisung Kopf und Summe (positionsscharf) |
80/// | 33004 | Strom Abweisung Position (positionsscharf) |
81pub const REMADV_PIDS: &[u32] = &[33001, 33002, 33003, 33004];
82
83/// The single REMADV PID that confirms payment. Everything else disputes it.
84pub const REMADV_CONFIRMATION_PID: u32 = 33001;
85
86/// The REMADV a payer **sends** to confirm — the Zahlungsavis. It carries no
87/// `AJT` at all (REMADV AHB 1.0a § 3.1.1): agreement needs no Antwortcode.
88pub const ZAHLUNGSAVIS_PID: u32 = REMADV_CONFIRMATION_PID;
89
90/// The REMADV a payer sends to refuse an invoice whose tree answers with **one**
91/// code — the plain „Abweisung" of REMADV AHB 1.0a § 3.1.1.
92///
93/// Not the default for every refusal: § 3.1.2's 33003 / 33004 pair is what
94/// carries a *set* of codes, and DE 1082 admits a different list of trees on
95/// each. [`RemadvAntwort::remadv_pid`] is what picks between them.
96pub const ABWEISUNG_PID: u32 = 33002;
97
98/// COMDIS Prüfidentifikator for an inbound Ablehnung of a REMADV (payer side).
99///
100/// Sent by the invoicer when it refuses the payer's REMADV — e.g. because the
101/// stated payment amount is wrong. Source: COMDIS AHB 1.0.
102pub const COMDIS_ABLEHNUNG_PID: Pruefidentifikator = Pruefidentifikator::const_new(29001);
103
104/// `true` when `pid` is a REMADV that confirms payment rather than disputing it.
105#[must_use]
106pub fn remadv_confirms(pid: Pruefidentifikator) -> bool {
107 pid.as_u32() == REMADV_CONFIRMATION_PID
108}
109
110// ── Family ────────────────────────────────────────────────────────────────────
111
112/// What one billing family chooses. Everything else about the process is shared.
113///
114/// A family is a zero-sized marker type; [`InvoicWorkflow`] is generic over it.
115pub trait InvoicFamily: Send + Sync + 'static {
116 /// Canonical workflow name registered in the process engine.
117 ///
118 /// Used as the `workflow_name` parameter in `spawn_or_resume` /
119 /// `dispatch_to_process` calls, and stored on every stream.
120 const WORKFLOW_NAME: &'static str;
121
122 /// Deadline label for the settlement response window.
123 ///
124 /// Register a `Deadline` with this label once the invoice validates; the
125 /// recipient must settle or dispute before it fires.
126 const DEADLINE_LABEL: &'static str;
127
128 /// INVOIC Prüfidentifikatoren this family accepts, inbound and outbound.
129 const INVOIC_PIDS: &'static [u32];
130
131 /// Whether this deployment can play the **issuer** role for the family —
132 /// recording an outbound INVOIC and correlating the payer's REMADV back to
133 /// it.
134 ///
135 /// A family that only ever receives invoices refuses `SendInvoic` and
136 /// `ReceiveRemadv` rather than opening a state it cannot reach honestly.
137 const SENDS_INVOIC: bool;
138
139 /// Whether the family exchanges COMDIS 29001 — the invoicer's refusal of a
140 /// payer's REMADV.
141 const ANSWERS_COMDIS: bool;
142
143 /// Human-readable PID list, for the rejection message when an unexpected
144 /// PID arrives. Defaults to the debug rendering of [`Self::INVOIC_PIDS`].
145 #[must_use]
146 fn pid_hint() -> String {
147 Self::INVOIC_PIDS
148 .iter()
149 .map(ToString::to_string)
150 .collect::<Vec<_>>()
151 .join("/")
152 }
153}
154
155// ── Data carried through the process ──────────────────────────────────────────
156
157/// The invoice facts a billing stream carries from receipt to settlement.
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
159pub struct InvoicData {
160 /// BDEW Prüfidentifikator of the invoice.
161 pub pruefidentifikator: Pruefidentifikator,
162 /// MP-ID of the invoice sender (the issuer).
163 pub sender: MarktpartnerCode,
164 /// MP-ID of the invoice recipient (the payer).
165 pub recipient: MarktpartnerCode,
166 /// EDIFACT document date from BGM/DTM (`YYYYMMDD`).
167 pub document_date: String,
168 /// Invoice reference from UNH/BGM — the REMADV correlation key.
169 pub invoice_ref: MessageRef,
170 /// BO4E invoice object, translated from EDIFACT by the `makod` adapter.
171 ///
172 /// `invoicd` reads this from the event store to run `invoic-checker`
173 /// without going back to the EDIFACT archive. Absent on the issuer side,
174 /// where the document was rendered here rather than parsed.
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub rechnung: Option<Box<Rechnung>>,
177 /// `SG1 RFF+ACE` — the **order this invoice answers**.
178 ///
179 /// Muss on the WiM- and MSB-Rechnung (INVOIC AHB 1.0b segment 00020). What
180 /// it names follows the Rechnungstyp: the ORDERS for `KON`/`TEC`, the
181 /// QUOTES for `MSB`.
182 ///
183 /// A process fact rather than a BO4E field, because BO4E's `Rechnung`
184 /// models the document and not the order behind it. `E_0264` Prüfschritt 40
185 /// („Basiert die Rechnung auf einer Bestellung?") is what reads it — WiM
186 /// Teil 2 UC 4.5.1: „Eine Rechnung referenziert auf die zugrundeliegende
187 /// Bestellung."
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub bestellung_ref: Option<String>,
190 /// `IMD+7081` — the Rechnungstyp, and on PID 31009 the **Use-Case**.
191 ///
192 /// `KON` „Abrechnung von Konfigurationen (Universalbestellprozess)" is the
193 /// ESA billing of WiM Teil 2 Kap. 4.5 stated on the wire; `MSB` is the
194 /// Messstellenbetrieb billed toward NB or LF, `TEC` the Änderung der
195 /// Technik. One PID, three Use-Cases, three Entscheidungsbäume.
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub rechnungstyp: Option<String>,
198}
199
200/// One `AJT` of a Nicht-Zahlungsavis — a published Antwortcode, the Ebene it
201/// came from and, on the Positionsebene, the Positionsnummer it belongs to.
202#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
203pub struct RemadvBefund {
204 /// `AJT` DE 4465 — the Code des Prüfschritts.
205 pub code: String,
206 /// `"kopf"`, `"position"` or `"summe"`. The Kopf- and Summenebene ride
207 /// REMADV 33003, the Positionsebene 33004.
208 pub ebene: String,
209 /// `SG26 LIN` Positionsnummer, on a position-level code.
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub positionsnummer: Option<u16>,
212 /// The written Erläuterung, where the code's own Hinweis requires one.
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub detail: Option<String>,
215}
216
217/// The market answer a Nicht-Zahlungsavis carries.
218///
219/// Not a bare code: `AJT` DE 1082 names the **Entscheidungsbaum**, and the same
220/// letter means different things across trees — `A70` is the Netznutzungs-
221/// Summenprüfung of `E_0406` and is undefined in the ESA tree `E_0264`, whose
222/// own total check is `A24`. The tree therefore travels with the codes, and
223/// `mako_pruefung::codes::rechnungspruefung` is what picks it from the PID
224/// and the recipient's Marktrolle.
225#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
226pub struct RemadvAntwort {
227 /// `AJT` DE 1082 — the EBD that publishes the codes (`E_0264`, `E_0406`).
228 pub ebd: String,
229 /// The refusals. Empty is not a refusal and must not reach this type.
230 pub befunde: Vec<RemadvBefund>,
231 /// The REMADV Prüfidentifikator this answer must ride — **33002** for a
232 /// tree that answers with one code, **33003** („Abweisung Kopf und Summe")
233 /// or **33004** („Abweisung Position") for one that answers with a set.
234 pub remadv_pid: u32,
235}
236
237impl RemadvAntwort {
238 /// The head code — what a single-`AJT` rendering states.
239 #[must_use]
240 pub fn erster_code(&self) -> Option<&str> {
241 self.befunde.first().map(|b| b.code.as_str())
242 }
243}
244
245// ── State ─────────────────────────────────────────────────────────────────────
246
247/// Current state of one billing process stream.
248#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
249#[serde(tag = "status", content = "data")]
250pub enum InvoicState {
251 /// No events yet.
252 #[default]
253 New,
254 /// INVOIC received; AHB validation pending.
255 InvoicReceived(InvoicData),
256 /// INVOIC passed AHB validation; awaiting settlement or dispute.
257 ValidationPassed(InvoicData),
258 /// Invoice settled.
259 Settled(InvoicData),
260 /// Invoice disputed.
261 Disputed {
262 /// Invoice facts captured at the time of the dispute.
263 data: InvoicData,
264 /// Human-readable dispute reason.
265 reason: String,
266 },
267 /// Process rejected — AHB validation failure or an expired deadline.
268 Rejected {
269 /// Human-readable rejection reason.
270 reason: String,
271 },
272 /// Outbound INVOIC recorded (issuer role); awaiting the payer's REMADV.
273 InvoicSent(InvoicData),
274 /// REMADV 33001 received — payment confirmed.
275 PaymentConfirmed(InvoicData),
276 /// REMADV 33002/33003/33004 received — payment refused.
277 PaymentDisputed {
278 /// Invoice facts.
279 data: InvoicData,
280 /// The REMADV PID that refused it.
281 remadv_pid: Pruefidentifikator,
282 },
283 /// COMDIS 29001 received — the invoicer refused our REMADV (payer role).
284 ComdisRejected(InvoicData),
285}
286
287impl InvoicState {
288 /// Stable string label for the current variant.
289 #[must_use]
290 pub fn label(&self) -> &'static str {
291 match self {
292 Self::New => "New",
293 Self::InvoicReceived(_) => "InvoicReceived",
294 Self::ValidationPassed(_) => "ValidationPassed",
295 Self::Settled(_) => "Settled",
296 Self::Disputed { .. } => "Disputed",
297 Self::Rejected { .. } => "Rejected",
298 Self::InvoicSent(_) => "InvoicSent",
299 Self::PaymentConfirmed(_) => "PaymentConfirmed",
300 Self::PaymentDisputed { .. } => "PaymentDisputed",
301 Self::ComdisRejected(_) => "ComdisRejected",
302 }
303 }
304
305 /// The invoice facts, once an invoice has been received or sent.
306 #[must_use]
307 pub fn data(&self) -> Option<&InvoicData> {
308 match self {
309 Self::InvoicReceived(d)
310 | Self::ValidationPassed(d)
311 | Self::Settled(d)
312 | Self::InvoicSent(d)
313 | Self::PaymentConfirmed(d)
314 | Self::ComdisRejected(d) => Some(d),
315 Self::Disputed { data, .. } | Self::PaymentDisputed { data, .. } => Some(data),
316 Self::New | Self::Rejected { .. } => None,
317 }
318 }
319
320 /// `true` when the process has reached an outcome and a late deadline must
321 /// no longer overwrite it.
322 #[must_use]
323 pub const fn is_terminal(&self) -> bool {
324 matches!(
325 self,
326 Self::Settled(_)
327 | Self::Disputed { .. }
328 | Self::Rejected { .. }
329 | Self::PaymentConfirmed(_)
330 | Self::PaymentDisputed { .. }
331 | Self::ComdisRejected(_)
332 )
333 }
334}
335
336// ── Events ────────────────────────────────────────────────────────────────────
337
338/// Events emitted by the billing workflow.
339#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
340#[serde(tag = "type", content = "data")]
341pub enum InvoicEvent {
342 /// An inbound INVOIC was received and its domain fields extracted.
343 InvoicReceived {
344 /// Invoice reference from UNH/BGM.
345 invoice_ref: MessageRef,
346 /// MP-ID of the issuer.
347 sender: MarktpartnerCode,
348 /// MP-ID of the payer.
349 recipient: MarktpartnerCode,
350 /// EDIFACT document date (`YYYYMMDD`).
351 document_date: String,
352 /// BDEW Prüfidentifikator.
353 pruefidentifikator: Pruefidentifikator,
354 /// BO4E invoice object for downstream plausibility checking.
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 rechnung: Option<Box<Rechnung>>,
357 /// `SG1 RFF+ACE` — the order this invoice answers.
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 bestellung_ref: Option<String>,
360 /// `IMD+7081` — the Rechnungstyp, and on 31009 the Use-Case.
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 rechnungstyp: Option<String>,
363 },
364 /// AHB validation succeeded; the settlement window opens.
365 ValidationPassed {
366 /// Invoice reference, so a consumer need not re-read the receive event.
367 invoice_ref: MessageRef,
368 },
369 /// The invoice was accepted and settled.
370 InvoiceSettled,
371 /// The invoice was disputed.
372 InvoiceDisputed {
373 /// Human-readable dispute reason.
374 reason: String,
375 },
376 /// The process was rejected — validation failure or a hard refusal.
377 Rejected {
378 /// Human-readable rejection reason.
379 reason: String,
380 },
381 /// The settlement deadline fired before an answer was given.
382 DeadlineExpired {
383 /// Unique ID of the expired deadline.
384 deadline_id: DeadlineId,
385 /// Label of the expired deadline.
386 label: Box<str>,
387 },
388 /// An outbound INVOIC was recorded (issuer role).
389 InvoicSent {
390 /// BDEW Prüfidentifikator.
391 pruefidentifikator: Pruefidentifikator,
392 /// MP-ID of the issuer.
393 sender: MarktpartnerCode,
394 /// MP-ID of the payer.
395 recipient: MarktpartnerCode,
396 /// EDIFACT document date (`YYYYMMDD`).
397 document_date: String,
398 /// Invoice reference — the REMADV correlation key.
399 invoice_ref: MessageRef,
400 },
401 /// A REMADV answered the outbound invoice.
402 RemadvReceived {
403 /// The REMADV Prüfidentifikator.
404 pid: Pruefidentifikator,
405 /// EDIFACT message reference of the REMADV.
406 remadv_ref: MessageRef,
407 /// MP-ID of the payer that sent it.
408 sender: MarktpartnerCode,
409 /// `true` only for PID 33001 — see [`REMADV_PIDS`].
410 is_confirmed: bool,
411 },
412 /// A COMDIS 29001 refused our REMADV (payer role).
413 ComdisAbLehnungReceived {
414 /// EDIFACT message reference of the COMDIS.
415 comdis_ref: MessageRef,
416 },
417}
418
419impl EventPayload for InvoicEvent {
420 fn event_type(&self) -> &'static str {
421 match self {
422 Self::InvoicReceived { .. } => "InvoicReceived",
423 Self::ValidationPassed { .. } => "InvoicValidationPassed",
424 Self::InvoiceSettled => "InvoiceSettled",
425 Self::InvoiceDisputed { .. } => "InvoiceDisputed",
426 Self::Rejected { .. } => "InvoicRejected",
427 Self::DeadlineExpired { .. } => "InvoicDeadlineExpired",
428 Self::InvoicSent { .. } => "InvoicSent",
429 Self::RemadvReceived { .. } => "RemadvReceived",
430 Self::ComdisAbLehnungReceived { .. } => "ComdisAblehnungReceived",
431 }
432 }
433}
434
435// ── Commands ──────────────────────────────────────────────────────────────────
436
437/// Commands accepted by the billing workflow.
438#[derive(Clone)]
439pub enum InvoicCommand {
440 /// An inbound INVOIC arrived from the transport layer.
441 ///
442 /// The adapter parses the EDIFACT and runs AHB validation *before*
443 /// constructing this command: pass `validation_passed: false` with
444 /// `validation_errors` populated and the workflow rejects the process.
445 ReceiveInvoic {
446 /// BDEW Prüfidentifikator — must be one of [`InvoicFamily::INVOIC_PIDS`].
447 pid: Pruefidentifikator,
448 /// MP-ID of the issuer.
449 sender: MarktpartnerCode,
450 /// MP-ID of the payer.
451 recipient: MarktpartnerCode,
452 /// Invoice reference from UNH/BGM.
453 invoice_ref: MessageRef,
454 /// EDIFACT document date (`YYYYMMDD`).
455 document_date: String,
456 /// `true` when AHB profile validation found no errors.
457 validation_passed: bool,
458 /// Validation issues, empty when `validation_passed`.
459 validation_errors: Vec<String>,
460 /// BO4E invoice object, when the adapter translated one.
461 rechnung: Option<Box<Rechnung>>,
462 /// `SG1 RFF+ACE` — the order this invoice answers (INVOIC AHB 1.0b
463 /// segment 00020, Muss on the WiM- and MSB-Rechnung).
464 bestellung_ref: Option<String>,
465 /// `IMD+7081` — the Rechnungstyp; `KON` is the ESA Use-Case.
466 rechnungstyp: Option<String>,
467 },
468 /// **Issuer role:** record an outbound INVOIC so the payer's REMADV
469 /// correlates back to it.
470 SendInvoic {
471 /// BDEW Prüfidentifikator of the outbound invoice.
472 pid: Pruefidentifikator,
473 /// MP-ID of the issuer.
474 sender: MarktpartnerCode,
475 /// MP-ID of the payer.
476 recipient: MarktpartnerCode,
477 /// EDIFACT document date (`YYYYMMDD`).
478 document_date: String,
479 /// Invoice reference — the REMADV correlation key.
480 invoice_ref: MessageRef,
481 },
482 /// **Issuer role:** an inbound REMADV answered the outbound invoice.
483 ReceiveRemadv {
484 /// The REMADV Prüfidentifikator — 33001 confirms, the rest dispute.
485 pid: Pruefidentifikator,
486 /// EDIFACT message reference of the REMADV.
487 remadv_ref: MessageRef,
488 /// MP-ID of the payer that sent it.
489 sender: MarktpartnerCode,
490 },
491 /// **Payer role:** an inbound COMDIS 29001 refused our REMADV.
492 ReceiveComdis {
493 /// EDIFACT message reference of the COMDIS.
494 comdis_ref: MessageRef,
495 },
496 /// Settle the invoice — REMADV **33001** Zahlungsavis to the issuer.
497 SettleInvoice {
498 /// Belegnummer of the outbound REMADV. The issuer correlates its
499 /// invoice by the `RFF` this message echoes, so it must equal the wire
500 /// UNH reference the renderer emits.
501 message_ref: MessageRef,
502 },
503 /// Dispute the invoice — a Nicht-Zahlungsavis to the issuer.
504 ///
505 /// Settlement is „ganz oder gar nicht" (REMADV AHB 1.0a § 3): there is no
506 /// Teilzahlung, so this refuses the whole invoice.
507 DisputeInvoice {
508 /// Belegnummer of the outbound REMADV.
509 message_ref: MessageRef,
510 /// Human-readable dispute reason — `SG7 FTX+ABO`.
511 reason: String,
512 /// The published Antwortcode(s) the refusal states, and the tree that
513 /// publishes them.
514 ///
515 /// `SG7 AJT` is **Muss** on every Nicht-Zahlungsavis (REMADV AHB 1.0a
516 /// § 3.1.1 / § 3.1.2), so a refusal without one is a message the issuer
517 /// cannot act on. `None` is accepted only from a caller that could not
518 /// resolve a tree at all, and the renderer then refuses to put an
519 /// incomplete answer on the wire.
520 #[allow(clippy::struct_field_names)]
521 antwort: Option<RemadvAntwort>,
522 },
523 /// The settlement deadline fired before an answer was given.
524 TimeoutExpired {
525 /// Unique ID of the expired deadline.
526 deadline_id: DeadlineId,
527 /// Label of the expired deadline.
528 label: Box<str>,
529 },
530}
531
532impl CommandPayload for InvoicCommand {}
533
534// ── Workflow ──────────────────────────────────────────────────────────────────
535
536/// The INVOIC settle/dispute workflow for one [`InvoicFamily`].
537pub struct InvoicWorkflow<F: InvoicFamily>(PhantomData<fn() -> F>);
538
539impl<F: InvoicFamily> Workflow for InvoicWorkflow<F> {
540 type State = InvoicState;
541 type Event = InvoicEvent;
542 type Command = InvoicCommand;
543
544 /// Deadline compensation for the settlement window.
545 ///
546 /// The window runs from receipt to answer, so it only compensates a stream
547 /// that has an invoice in hand and has not yet answered.
548 fn on_deadline(
549 deadline: &mako_engine::deadline::Deadline,
550 state: &Self::State,
551 ) -> Option<Self::Command> {
552 match (deadline.label(), state) {
553 (label, InvoicState::InvoicReceived(_) | InvoicState::ValidationPassed(_))
554 if label == F::DEADLINE_LABEL =>
555 {
556 Some(InvoicCommand::TimeoutExpired {
557 deadline_id: deadline.deadline_id(),
558 label: deadline.label().into(),
559 })
560 }
561 _ => None,
562 }
563 }
564
565 fn apply(state: Self::State, event: &Self::Event) -> Self::State {
566 match event {
567 InvoicEvent::InvoicReceived {
568 invoice_ref,
569 sender,
570 recipient,
571 document_date,
572 pruefidentifikator,
573 rechnung,
574 bestellung_ref,
575 rechnungstyp,
576 } => InvoicState::InvoicReceived(InvoicData {
577 pruefidentifikator: *pruefidentifikator,
578 sender: sender.clone(),
579 recipient: recipient.clone(),
580 document_date: document_date.clone(),
581 invoice_ref: invoice_ref.clone(),
582 rechnung: rechnung.clone(),
583 bestellung_ref: bestellung_ref.clone(),
584 rechnungstyp: rechnungstyp.clone(),
585 }),
586
587 InvoicEvent::ValidationPassed { .. } => match state {
588 InvoicState::InvoicReceived(data) => InvoicState::ValidationPassed(data),
589 other => other,
590 },
591
592 InvoicEvent::InvoiceSettled => match state {
593 // The **second round**. WiM Teil 2 Kap. 4.5.2 Nr. 4 has the
594 // payer answer again after the issuer's COMDIS, and this time
595 // conceding: the invoice stands and is paid.
596 InvoicState::ComdisRejected(data) => InvoicState::Settled(data),
597 InvoicState::ValidationPassed(data) => InvoicState::Settled(data),
598 other => other,
599 },
600
601 InvoicEvent::InvoiceDisputed { reason } => match state {
602 // The second round, refusing again — `E_0266` `A25`, „der MSB
603 // konnte nicht alle Einwände entkräften". A third round is not
604 // published: „kommt es zu einer erneuten Ablehnung durch den
605 // MSB, ist eine bilaterale Klärung notwendig".
606 InvoicState::ComdisRejected(data) => InvoicState::Disputed {
607 data,
608 reason: reason.clone(),
609 },
610 InvoicState::ValidationPassed(data) => InvoicState::Disputed {
611 data,
612 reason: reason.clone(),
613 },
614 other => other,
615 },
616
617 InvoicEvent::Rejected { reason } => InvoicState::Rejected {
618 reason: reason.clone(),
619 },
620
621 // A deadline that fires after the process already reached an
622 // outcome changes nothing — the answer was given in time.
623 InvoicEvent::DeadlineExpired { label, .. } => {
624 if state.is_terminal() {
625 state
626 } else {
627 InvoicState::Rejected {
628 reason: format!("settlement deadline expired: {label}"),
629 }
630 }
631 }
632
633 InvoicEvent::InvoicSent {
634 pruefidentifikator,
635 sender,
636 recipient,
637 document_date,
638 invoice_ref,
639 } => InvoicState::InvoicSent(InvoicData {
640 pruefidentifikator: *pruefidentifikator,
641 sender: sender.clone(),
642 recipient: recipient.clone(),
643 document_date: document_date.clone(),
644 invoice_ref: invoice_ref.clone(),
645 rechnung: None,
646 // The issuer rendered the document here; the reference it put
647 // on the wire is the sender's own and is not read back.
648 bestellung_ref: None,
649 rechnungstyp: None,
650 }),
651
652 InvoicEvent::RemadvReceived {
653 pid, is_confirmed, ..
654 } => match state {
655 InvoicState::InvoicSent(data) => {
656 if *is_confirmed {
657 InvoicState::PaymentConfirmed(data)
658 } else {
659 InvoicState::PaymentDisputed {
660 remadv_pid: *pid,
661 data,
662 }
663 }
664 }
665 other => other,
666 },
667
668 // Accepted in exactly the states `handle` admits — see the guard
669 // there for why those and no others.
670 InvoicEvent::ComdisAbLehnungReceived { .. } => match state {
671 InvoicState::ValidationPassed(data)
672 | InvoicState::Settled(data)
673 | InvoicState::Disputed { data, .. } => InvoicState::ComdisRejected(data),
674 other => other,
675 },
676 }
677 }
678
679 fn handle(
680 state: &Self::State,
681 command: Self::Command,
682 ) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
683 match command {
684 InvoicCommand::ReceiveInvoic {
685 pid,
686 sender,
687 recipient,
688 invoice_ref,
689 document_date,
690 validation_passed,
691 validation_errors,
692 rechnung,
693 bestellung_ref,
694 rechnungstyp,
695 } => {
696 if !matches!(state, InvoicState::New) {
697 return Err(WorkflowError::invalid_state("New", state.label()));
698 }
699 if !F::INVOIC_PIDS.contains(&pid.as_u32()) {
700 return Err(WorkflowError::rejected(format!(
701 "expected an INVOIC PID for {} ({}), got {pid}",
702 F::WORKFLOW_NAME,
703 F::pid_hint(),
704 )));
705 }
706 let mut events = vec![InvoicEvent::InvoicReceived {
707 invoice_ref: invoice_ref.clone(),
708 sender: sender.clone(),
709 recipient: recipient.clone(),
710 document_date,
711 pruefidentifikator: pid,
712 rechnung: rechnung.clone(),
713 bestellung_ref: bestellung_ref.clone(),
714 rechnungstyp: rechnungstyp.clone(),
715 }];
716 let mut outbox: Vec<PendingOutbox> = Vec::new();
717 if validation_passed {
718 events.push(InvoicEvent::ValidationPassed {
719 invoice_ref: invoice_ref.clone(),
720 });
721 // Tell `invoicd` a validated invoice is ready for
722 // plausibility checking. The BO4E `Rechnung` rides along so
723 // it can run `InvoicCheckEngine::check` straight off the
724 // webhook payload without re-reading the EDIFACT archive.
725 outbox.push(
726 PendingOutbox::new(
727 "ProcessInitiated",
728 recipient.as_str(),
729 serde_json::json!({
730 "pid": pid.as_u32(),
731 "invoice_ref": invoice_ref.as_str(),
732 "sender_mp_id": sender.as_str(),
733 "workflow": F::WORKFLOW_NAME,
734 "rechnung": serde_json::to_value(rechnung.as_deref())
735 .unwrap_or(serde_json::Value::Null),
736 // The two EDIFACT facts BO4E has no field for.
737 // `E_0264` Prüfschritt 40 needs the first, and
738 // the second states the Use-Case on the wire —
739 // one PID, three of them.
740 "bestellung_ref": bestellung_ref,
741 "rechnungstyp": rechnungstyp,
742 }),
743 )
744 // Caused by ValidationPassed (index 1).
745 .caused_by(1),
746 );
747 } else {
748 events.push(InvoicEvent::Rejected {
749 reason: validation_errors.join("; "),
750 });
751 }
752 Ok(WorkflowOutput::with_outbox(events, outbox))
753 }
754
755 InvoicCommand::SettleInvoice { message_ref } => {
756 if !answerable(state) {
757 return Err(WorkflowError::invalid_state(
758 "ValidationPassed|ComdisRejected",
759 state.label(),
760 ));
761 }
762 Ok(WorkflowOutput::with_outbox(
763 vec![InvoicEvent::InvoiceSettled],
764 vec![
765 remadv_outbox(state, ZAHLUNGSAVIS_PID, &message_ref, None, None),
766 completion_outbox::<F>(state, "settled", None),
767 ],
768 ))
769 }
770
771 InvoicCommand::DisputeInvoice {
772 message_ref,
773 reason,
774 antwort,
775 } => {
776 if !answerable(state) {
777 return Err(WorkflowError::invalid_state(
778 "ValidationPassed|ComdisRejected",
779 state.label(),
780 ));
781 }
782 // REMADV AHB 1.0a § 3.1.1/§ 3.1.2 make `SG7 AJT` Muss on every
783 // Nicht-Zahlungsavis, and the Prüfidentifikator follows the
784 // shape of the answer: 33002 for a tree that states one code,
785 // 33003/33004 for one that states a set. Defaulting to 33002
786 // here would put an `E_0264` code on a Prüfidentifikator whose
787 // DE 1082 does not admit that tree.
788 let pid = antwort.as_ref().map_or(ABWEISUNG_PID, |a| a.remadv_pid);
789 let outbox = vec![
790 remadv_outbox(state, pid, &message_ref, Some(&reason), antwort.as_ref()),
791 completion_outbox::<F>(state, "disputed", Some(&reason)),
792 ];
793 Ok(WorkflowOutput::with_outbox(
794 vec![InvoicEvent::InvoiceDisputed { reason }],
795 outbox,
796 ))
797 }
798
799 InvoicCommand::TimeoutExpired { deadline_id, label } => {
800 // A deadline that fires after the answer was already given is a
801 // no-op, not a rejection.
802 if state.is_terminal() {
803 return Ok(WorkflowOutput::events(vec![]));
804 }
805 Ok(vec![InvoicEvent::DeadlineExpired { deadline_id, label }].into())
806 }
807
808 InvoicCommand::SendInvoic {
809 pid,
810 sender,
811 recipient,
812 document_date,
813 invoice_ref,
814 } => {
815 if !F::SENDS_INVOIC {
816 return Err(WorkflowError::rejected(format!(
817 "{} does not play the issuer role — it receives invoices only",
818 F::WORKFLOW_NAME,
819 )));
820 }
821 if !matches!(state, InvoicState::New) {
822 return Err(WorkflowError::invalid_state("New", state.label()));
823 }
824 if !F::INVOIC_PIDS.contains(&pid.as_u32()) {
825 return Err(WorkflowError::rejected(format!(
826 "expected an INVOIC PID for {} ({}), got {pid}",
827 F::WORKFLOW_NAME,
828 F::pid_hint(),
829 )));
830 }
831 Ok(vec![InvoicEvent::InvoicSent {
832 pruefidentifikator: pid,
833 sender,
834 recipient,
835 document_date,
836 invoice_ref,
837 }]
838 .into())
839 }
840
841 InvoicCommand::ReceiveRemadv {
842 pid,
843 remadv_ref,
844 sender,
845 } => {
846 if !F::SENDS_INVOIC {
847 return Err(WorkflowError::rejected(format!(
848 "{} never issues an invoice, so no REMADV can answer one",
849 F::WORKFLOW_NAME,
850 )));
851 }
852 if !matches!(state, InvoicState::InvoicSent(_)) {
853 return Err(WorkflowError::invalid_state("InvoicSent", state.label()));
854 }
855 if !REMADV_PIDS.contains(&pid.as_u32()) {
856 return Err(WorkflowError::rejected(format!(
857 "expected a REMADV PID (33001–33004), got {pid}",
858 )));
859 }
860 // REMADV AHB 1.0a § 3 — settlement is „ganz oder gar nicht".
861 // Only 33001 confirms; 33002/33003/33004 are all Abweisungen.
862 let is_confirmed = remadv_confirms(pid);
863 Ok(vec![InvoicEvent::RemadvReceived {
864 pid,
865 remadv_ref,
866 sender,
867 is_confirmed,
868 }]
869 .into())
870 }
871
872 InvoicCommand::ReceiveComdis { comdis_ref } => {
873 if !F::ANSWERS_COMDIS {
874 return Err(WorkflowError::rejected(format!(
875 "{} does not exchange COMDIS 29001",
876 F::WORKFLOW_NAME,
877 )));
878 }
879 // A COMDIS refuses a REMADV **we** sent, and we send one as the
880 // *payer*: after validating an invoice we settle or dispute it,
881 // and that answer is the REMADV. So the only states in which one
882 // can arrive are the payer's answered states, plus
883 // `ValidationPassed` for a COMDIS that races our own answer.
884 //
885 // `InvoicSent`, `PaymentConfirmed` and `PaymentDisputed` are the
886 // *issuer's* states. There we are the one who would send a
887 // COMDIS, never receive it, so an inbound one is a routing
888 // error and saying so beats recording an event that `apply`
889 // would then ignore — which is what the four copies did, each
890 // with a slightly different set.
891 if !matches!(
892 state,
893 InvoicState::ValidationPassed(_)
894 | InvoicState::Settled(_)
895 | InvoicState::Disputed { .. }
896 ) {
897 return Err(WorkflowError::invalid_state(
898 "ValidationPassed|Settled|Disputed",
899 state.label(),
900 ));
901 }
902 Ok(vec![InvoicEvent::ComdisAbLehnungReceived { comdis_ref }].into())
903 }
904 }
905 }
906}
907
908/// The `ProcessCompleted` outbox entry for a settled or disputed invoice.
909/// The states a payer may answer an invoice from.
910///
911/// `ValidationPassed` is the first round. **`ComdisRejected` is the second**:
912/// the issuer answered the payer's Nicht-Zahlungsavis with a COMDIS 29001
913/// claiming its invoice was correct, and the payer owes another answer — WiM
914/// Teil 2 Kap. 4.5.2 Nr. 4 for an ESA, by the Zahlungsziel, and the tree is
915/// `E_0266` rather than `E_0264` (its Prüfschritt 1 asks whether the COMDIS
916/// actually rebutted the objections, which `E_0264` does not publish a code
917/// for).
918///
919/// Without this arm `ComdisRejected` is a dead end: the process records the
920/// COMDIS and can never answer it, so the round that either releases the
921/// payment or ends in bilateral clearing is unreachable.
922const fn answerable(state: &InvoicState) -> bool {
923 matches!(
924 state,
925 InvoicState::ValidationPassed(_) | InvoicState::ComdisRejected(_)
926 )
927}
928
929/// Build the **outbound REMADV** — the answer the invoice issuer is waiting on.
930///
931/// The market answer and the ERP notification are two different messages with
932/// two different audiences: `ProcessCompleted` tells this operator's own ERP
933/// what happened, and only this reaches the counterparty. Both go out, because
934/// an invoice recorded as answered in the § 147 AO trail and unanswered on the
935/// wire is the same invoice.
936///
937/// The recipient is the invoice's **issuer**: a REMADV travels back up the
938/// invoice, so sender and receiver are the mirror of the INVOIC's.
939fn remadv_outbox(
940 state: &InvoicState,
941 pid: u32,
942 message_ref: &MessageRef,
943 reason: Option<&str>,
944 antwort: Option<&RemadvAntwort>,
945) -> PendingOutbox {
946 let data = state.data();
947 let issuer = data.map(|d| d.sender.as_str()).unwrap_or_default();
948 let mut payload = serde_json::json!({
949 "pid": pid,
950 "sender": data.map(|d| d.recipient.as_str()).unwrap_or_default(),
951 "receiver": issuer,
952 "message_ref": message_ref.as_str(),
953 // BGM DE 1001: `481` Zahlungsavis, `239` Abgelehnte Forderung
954 // (Nicht-Zahlungsavis) — REMADV AHB 1.0a § 3.1.1.
955 "document_code": if pid == ZAHLUNGSAVIS_PID { "481" } else { "239" },
956 // `SG5 RFF` — the invoice this answers. The issuer correlates on it.
957 "invoice_ref": data.map(|d| d.invoice_ref.to_string()).unwrap_or_default(),
958 "document_date": data.map(|d| d.document_date.clone()).unwrap_or_default(),
959 });
960 let Some(obj) = payload.as_object_mut() else {
961 return PendingOutbox::new("REMADV", issuer, payload);
962 };
963 // `SG5` — the invoice being answered, its fälliger Betrag and its
964 // Rechnungsdatum, all **Muss** (REMADV AHB 1.0a § 3.1.1 segments
965 // 00012–00015). Read off the stored BO4E `Rechnung`: the payer side keeps
966 // it precisely so the answer need not go back to the EDIFACT archive.
967 //
968 // The **Überweisungsbetrag** is not a copy of the fällige Betrag: condition
969 // `[926]` fixes it to `0` on an Abweisung, because refusing an invoice
970 // transfers nothing, and conditions `[3]`/`[4]` negate it on a Gutschrift.
971 if let Some(r) = data.and_then(|d| d.rechnung.as_deref()) {
972 let faellig = r
973 .zu_zahlen
974 .as_ref()
975 .or(r.gesamtbrutto.as_ref())
976 .and_then(|b| b.wert)
977 .unwrap_or_default();
978 let gutschrift = r.ist_storno == Some(true);
979 let ueberweisung = if pid == ZAHLUNGSAVIS_PID {
980 if gutschrift { -faellig } else { faellig }
981 } else {
982 rust_decimal::Decimal::ZERO
983 };
984 obj.insert(
985 "rechnungsbezug".to_owned(),
986 serde_json::json!({
987 // `SG5 DOC` DE 1001. A Storno of a self-billed invoice is `Z25`,
988 // of an ordinary one `457`; otherwise `389` self-billed and
989 // `380` Handelsrechnung.
990 "dokumentenart": match (gutschrift, r.ist_original == Some(false)) {
991 (true, true) => "Z25",
992 (true, false) => "457",
993 (false, true) => "389",
994 (false, false) => "380",
995 },
996 "rechnungsnummer": r.rechnungsnummer.clone().unwrap_or_default(),
997 "faelliger_betrag": faellig.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero).to_string(),
998 "ueberweisungsbetrag": ueberweisung.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero).to_string(),
999 "rechnungsdatum": r
1000 .rechnungsdatum
1001 .map(|d| d.date().to_string())
1002 .unwrap_or_default(),
1003 }),
1004 );
1005 }
1006 if let Some(reason) = reason {
1007 obj.insert("ablehnungsgrund".to_owned(), serde_json::json!(reason));
1008 }
1009 if let Some(a) = antwort {
1010 // `SG7 AJT` DE 4465 / DE 1082. The head code renders as the single
1011 // `AJT` every REMADV carries; the full set travels alongside it for the
1012 // itemised 33003/33004 rendering and for the audit trail, which has to
1013 // show every Prüfschritt that refused.
1014 obj.insert(
1015 "antwort_code".to_owned(),
1016 serde_json::json!(a.erster_code()),
1017 );
1018 obj.insert("antwort_codeliste".to_owned(), serde_json::json!(a.ebd));
1019 obj.insert("antwort_befunde".to_owned(), serde_json::json!(a.befunde));
1020 }
1021 PendingOutbox::new("REMADV", issuer, payload)
1022}
1023
1024fn completion_outbox<F: InvoicFamily>(
1025 state: &InvoicState,
1026 outcome: &str,
1027 reason: Option<&str>,
1028) -> PendingOutbox {
1029 let data = state.data();
1030 let mut payload = serde_json::json!({
1031 "pid": data.map_or(0, |d| d.pruefidentifikator.as_u32()),
1032 "invoice_ref": data.map(|d| d.invoice_ref.to_string()).unwrap_or_default(),
1033 "workflow": F::WORKFLOW_NAME,
1034 "outcome": outcome,
1035 });
1036 if let Some(reason) = reason
1037 && let Some(obj) = payload.as_object_mut()
1038 {
1039 obj.insert("reason".to_owned(), serde_json::json!(reason));
1040 }
1041 PendingOutbox::new("ProcessCompleted", "", payload)
1042}
1043
1044// ── Read-model projection ─────────────────────────────────────────────────────
1045
1046/// Read-model record for a single billing process stream.
1047#[derive(Debug)]
1048pub struct InvoicRecord {
1049 /// Current lifecycle status label.
1050 pub status: &'static str,
1051 /// BDEW Prüfidentifikator, once an invoice has been received or sent.
1052 pub pruefidentifikator: Option<Pruefidentifikator>,
1053 /// Total events processed for this stream.
1054 pub event_count: usize,
1055}
1056
1057impl Default for InvoicRecord {
1058 fn default() -> Self {
1059 Self {
1060 status: "New",
1061 pruefidentifikator: None,
1062 event_count: 0,
1063 }
1064 }
1065}
1066
1067/// In-process read model tracking billing process streams.
1068#[derive(Debug, Default)]
1069pub struct InvoicProjection {
1070 /// All known billing process records keyed by stream ID.
1071 pub records: HashMap<String, InvoicRecord>,
1072 /// Sequence number of the last event applied.
1073 pub last_seq: u64,
1074}
1075
1076impl Projection for InvoicProjection {
1077 fn name(&self) -> &'static str {
1078 "InvoicProjection"
1079 }
1080
1081 fn handle_event(&mut self, envelope: &EventEnvelope) {
1082 self.last_seq = self.last_seq.max(envelope.sequence_number);
1083
1084 let record = self
1085 .records
1086 .entry(envelope.stream_id.as_str().to_owned())
1087 .or_default();
1088 record.event_count += 1;
1089
1090 let Ok(event) = envelope.decode::<InvoicEvent>() else {
1091 return;
1092 };
1093
1094 match event {
1095 InvoicEvent::InvoicReceived {
1096 pruefidentifikator, ..
1097 } => {
1098 record.status = "InvoicReceived";
1099 record.pruefidentifikator = Some(pruefidentifikator);
1100 }
1101 InvoicEvent::ValidationPassed { .. } => record.status = "ValidationPassed",
1102 InvoicEvent::InvoiceSettled => record.status = "Settled",
1103 InvoicEvent::InvoiceDisputed { .. } => record.status = "Disputed",
1104 InvoicEvent::Rejected { .. } | InvoicEvent::DeadlineExpired { .. } => {
1105 record.status = "Rejected";
1106 }
1107 InvoicEvent::InvoicSent {
1108 pruefidentifikator, ..
1109 } => {
1110 record.status = "InvoicSent";
1111 record.pruefidentifikator = Some(pruefidentifikator);
1112 }
1113 InvoicEvent::RemadvReceived { is_confirmed, .. } => {
1114 record.status = if is_confirmed {
1115 "PaymentConfirmed"
1116 } else {
1117 "PaymentDisputed"
1118 };
1119 }
1120 InvoicEvent::ComdisAbLehnungReceived { .. } => record.status = "ComdisRejected",
1121 }
1122 }
1123
1124 fn last_sequence(&self) -> Option<u64> {
1125 if self.last_seq == 0 {
1126 None
1127 } else {
1128 Some(self.last_seq)
1129 }
1130 }
1131}