Skip to main content

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}