use std::collections::HashMap;
use mako_engine::types::Pruefidentifikator;
use mako_engine::{
envelope::EventEnvelope,
error::WorkflowError,
ids::DeadlineId,
projection::Projection,
types::{BikoId, BillingPeriod, BkvId, MarktpartnerCode, MessageRef},
workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum BillingEvent {
SummenzeitreiheReceived {
billing_period: BillingPeriod,
bkv_id: BkvId,
biko_id: BikoId,
pruefidentifikator: Pruefidentifikator,
version: BillingVersion,
message_ref: MessageRef,
},
PruefmitteilungPositivSent {
message_ref: MessageRef,
},
PruefmitteilungNegativSent {
message_ref: MessageRef,
reason: String,
},
DatenstatusReceived {
data_status: DataStatus,
},
PruefmitteilungDeadlineExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
IftstaStatusReceived {
pid: Pruefidentifikator,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
message_ref: MessageRef,
},
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BillingVersion {
Vorlaeufig,
Endgueltig,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DataStatus {
Abrechnungsdaten,
AbgerechtneteDaten,
AbgerechtneteDatenKbka,
}
impl EventPayload for BillingEvent {
fn event_type(&self) -> &'static str {
match self {
Self::SummenzeitreiheReceived { .. } => "MabisSummenzeitreiheReceived",
Self::PruefmitteilungPositivSent { .. } => "MabisPruefmitteilungPositivSent",
Self::PruefmitteilungNegativSent { .. } => "MabisPruefmitteilungNegativSent",
Self::DatenstatusReceived { .. } => "MabisDatenstatusReceived",
Self::PruefmitteilungDeadlineExpired { .. } => "MabisPruefmitteilungDeadlineExpired",
Self::IftstaStatusReceived { .. } => "MabisIftstaStatusReceived",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BillingData {
pub billing_period: BillingPeriod,
pub bkv_id: BkvId,
pub biko_id: BikoId,
pub pruefidentifikator: Pruefidentifikator,
pub version: BillingVersion,
pub message_ref: MessageRef,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status", content = "data")]
pub enum BillingState {
New,
SummenzeitreiheReceived(BillingData),
PruefmitteilungSent(BillingData),
Settled(BillingData),
Disputed {
billing: BillingData,
reason: String,
},
DeadlineExpired(BillingData),
}
impl Default for BillingState {
fn default() -> Self {
Self::New
}
}
impl BillingState {
#[must_use]
pub fn status_str(&self) -> &'static str {
match self {
Self::New => "New",
Self::SummenzeitreiheReceived(_) => "SummenzeitreiheReceived",
Self::PruefmitteilungSent(_) => "PruefmitteilungSent",
Self::Settled(_) => "Settled",
Self::Disputed { .. } => "Disputed",
Self::DeadlineExpired(_) => "DeadlineExpired",
}
}
}
#[derive(Clone)]
pub enum BillingCommand {
ReceiveSummenzeitreihe {
pid: Pruefidentifikator,
billing_period: BillingPeriod,
bkv_id: BkvId,
biko_id: BikoId,
version: BillingVersion,
message_ref: MessageRef,
},
SendPruefmitteilungPositiv {
message_ref: MessageRef,
},
SendPruefmitteilungNegativ {
message_ref: MessageRef,
reason: String,
},
ReceiveDatastatus {
data_status: DataStatus,
},
PruefmitteilungDeadlineExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
ReceiveIftsta {
pid: Pruefidentifikator,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
message_ref: MessageRef,
validation_passed: bool,
validation_errors: Vec<String>,
data_status: Option<DataStatus>,
},
}
impl CommandPayload for BillingCommand {}
pub struct MabisBillingWorkflow;
pub const PRUEFMITTEILUNG_DEADLINE_LABEL: &str = "mabis-pruefmitteilung-1-werktag";
pub const IFTSTA_PIDS: &[u32] = &[21_000, 21_001, 21_002, 21_003, 21_004, 21_005];
pub const IFTSTA_DATENSTATUS_PID: u32 = 21_004;
impl Workflow for MabisBillingWorkflow {
type State = BillingState;
type Event = BillingEvent;
type Command = BillingCommand;
fn on_deadline(
deadline: &mako_engine::deadline::Deadline,
state: &Self::State,
) -> Option<Self::Command> {
match (deadline.label(), state) {
(PRUEFMITTEILUNG_DEADLINE_LABEL, BillingState::SummenzeitreiheReceived(_)) => {
Some(BillingCommand::PruefmitteilungDeadlineExpired {
deadline_id: deadline.deadline_id(),
label: deadline.label().into(),
})
}
_ => None,
}
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
match event {
BillingEvent::SummenzeitreiheReceived {
billing_period,
bkv_id,
biko_id,
pruefidentifikator,
version,
message_ref,
} => BillingState::SummenzeitreiheReceived(BillingData {
billing_period: billing_period.clone(),
bkv_id: bkv_id.clone(),
biko_id: biko_id.clone(),
pruefidentifikator: *pruefidentifikator,
version: version.clone(),
message_ref: message_ref.clone(),
}),
BillingEvent::PruefmitteilungPositivSent { .. } => {
if let BillingState::SummenzeitreiheReceived(d) = state {
BillingState::PruefmitteilungSent(d)
} else {
state
}
}
BillingEvent::PruefmitteilungNegativSent { reason, .. } => match state {
BillingState::SummenzeitreiheReceived(billing) => BillingState::Disputed {
billing,
reason: reason.clone(),
},
_ => state,
},
BillingEvent::DatenstatusReceived { .. } => {
if let BillingState::PruefmitteilungSent(d) = state {
BillingState::Settled(d)
} else {
state
}
}
BillingEvent::PruefmitteilungDeadlineExpired { .. } => {
if let BillingState::SummenzeitreiheReceived(d) = state {
BillingState::DeadlineExpired(d)
} else {
state
}
}
BillingEvent::IftstaStatusReceived { .. } => state,
}
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
match command {
BillingCommand::ReceiveSummenzeitreihe {
pid,
billing_period,
bkv_id,
biko_id,
version,
message_ref,
} => {
if !matches!(state, BillingState::New) {
return Err(WorkflowError::invalid_state("New", state.status_str()));
}
if pid.as_u32() != 13_003 {
return Err(WorkflowError::not_implemented(pid.as_u32()));
}
Ok(vec![BillingEvent::SummenzeitreiheReceived {
billing_period,
bkv_id,
biko_id,
pruefidentifikator: pid,
version,
message_ref,
}]
.into())
}
BillingCommand::SendPruefmitteilungPositiv { message_ref } => {
if !matches!(state, BillingState::SummenzeitreiheReceived(_)) {
return Err(WorkflowError::invalid_state(
"SummenzeitreiheReceived",
state.status_str(),
));
}
Ok(vec![BillingEvent::PruefmitteilungPositivSent { message_ref }].into())
}
BillingCommand::SendPruefmitteilungNegativ {
message_ref,
reason,
} => {
if !matches!(state, BillingState::SummenzeitreiheReceived(_)) {
return Err(WorkflowError::invalid_state(
"SummenzeitreiheReceived",
state.status_str(),
));
}
Ok(vec![BillingEvent::PruefmitteilungNegativSent {
message_ref,
reason,
}]
.into())
}
BillingCommand::ReceiveDatastatus { data_status } => {
if !matches!(state, BillingState::PruefmitteilungSent(_)) {
return Err(WorkflowError::invalid_state(
"PruefmitteilungSent",
state.status_str(),
));
}
Ok(vec![BillingEvent::DatenstatusReceived { data_status }].into())
}
BillingCommand::PruefmitteilungDeadlineExpired { deadline_id, label } => {
if !matches!(state, BillingState::SummenzeitreiheReceived(_)) {
return Ok(WorkflowOutput::events(vec![]));
}
Ok(
vec![BillingEvent::PruefmitteilungDeadlineExpired { deadline_id, label }]
.into(),
)
}
BillingCommand::ReceiveIftsta {
pid,
sender,
receiver,
message_ref,
data_status,
..
} => {
if pid.as_u32() == IFTSTA_DATENSTATUS_PID {
if !matches!(state, BillingState::PruefmitteilungSent(_)) {
return Err(WorkflowError::invalid_state(
"PruefmitteilungSent",
state.status_str(),
));
}
let ds = data_status.ok_or_else(|| {
WorkflowError::validation(
"IFTSTA PID 21004 (Datenstatus): \
STS segment DataStatus code is required",
)
})?;
Ok(vec![BillingEvent::DatenstatusReceived { data_status: ds }].into())
} else {
Ok(vec![BillingEvent::IftstaStatusReceived {
pid,
sender,
receiver,
message_ref,
}]
.into())
}
}
}
}
}
#[derive(Debug)]
pub enum BillingRecord {
New {
event_count: usize,
},
Active {
status: &'static str,
billing_period: BillingPeriod,
bkv_id: BkvId,
biko_id: BikoId,
version: BillingVersion,
event_count: usize,
},
}
impl BillingRecord {
#[must_use]
pub fn status(&self) -> &'static str {
match self {
Self::New { .. } => "New",
Self::Active { status, .. } => status,
}
}
#[must_use]
pub fn event_count(&self) -> usize {
match self {
Self::New { event_count } | Self::Active { event_count, .. } => *event_count,
}
}
#[must_use]
pub fn active_data(&self) -> Option<BillingRecordData<'_>> {
match self {
Self::New { .. } => None,
Self::Active {
billing_period,
bkv_id,
biko_id,
version,
..
} => Some(BillingRecordData {
billing_period,
bkv_id,
biko_id,
version,
}),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct BillingRecordData<'a> {
pub billing_period: &'a BillingPeriod,
pub bkv_id: &'a BkvId,
pub biko_id: &'a BikoId,
pub version: &'a BillingVersion,
}
impl Default for BillingRecord {
fn default() -> Self {
Self::New { event_count: 0 }
}
}
#[derive(Debug, Default)]
pub struct BillingProjection {
pub records: HashMap<String, BillingRecord>,
pub last_seq: u64,
}
impl Projection for BillingProjection {
fn name(&self) -> &'static str {
"BillingProjection"
}
fn handle_event(&mut self, envelope: &EventEnvelope) {
self.last_seq = self.last_seq.max(envelope.sequence_number);
let record = self
.records
.entry(envelope.stream_id.as_str().to_owned())
.or_default();
let Ok(event) = envelope.decode::<BillingEvent>() else {
return;
};
match record {
BillingRecord::New { event_count } => *event_count += 1,
BillingRecord::Active { event_count, .. } => *event_count += 1,
}
match event {
BillingEvent::SummenzeitreiheReceived {
billing_period,
bkv_id,
biko_id,
version,
..
} => {
let count = record.event_count();
*record = BillingRecord::Active {
status: "SummenzeitreiheReceived",
billing_period,
bkv_id,
biko_id,
version,
event_count: count,
};
}
BillingEvent::PruefmitteilungPositivSent { .. } => {
if let BillingRecord::Active { status, .. } = record {
*status = "PruefmitteilungSent";
}
}
BillingEvent::PruefmitteilungNegativSent { .. } => {
if let BillingRecord::Active { status, .. } = record {
*status = "Disputed";
}
}
BillingEvent::DatenstatusReceived { .. } => {
if let BillingRecord::Active { status, .. } = record {
*status = "Settled";
}
}
BillingEvent::PruefmitteilungDeadlineExpired { .. } => {
if let BillingRecord::Active { status, .. } = record {
*status = "DeadlineExpired";
}
}
BillingEvent::IftstaStatusReceived { .. } => {
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn receive_cmd(version: BillingVersion) -> BillingCommand {
BillingCommand::ReceiveSummenzeitreihe {
pid: Pruefidentifikator::new(13_003).expect("13003 is valid"),
billing_period: BillingPeriod::new("2025-09"),
bkv_id: BkvId::new("BKV-DE-001"),
biko_id: BikoId::new("BIKO-DE-001"),
version,
message_ref: MessageRef::new("MSCONS-BKA-2025-09-001"),
}
}
#[test]
fn happy_path_positive_pruefmitteilung_to_settled() {
let state = BillingState::default();
let events = MabisBillingWorkflow::handle(&state, receive_cmd(BillingVersion::Vorlaeufig))
.expect("should accept PID 13003");
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
BillingEvent::SummenzeitreiheReceived { .. }
));
let state = events.iter().fold(state, MabisBillingWorkflow::apply);
assert_eq!(state.status_str(), "SummenzeitreiheReceived");
let events = MabisBillingWorkflow::handle(
&state,
BillingCommand::SendPruefmitteilungPositiv {
message_ref: MessageRef::new("PRUEF-POS-001"),
},
)
.expect("positive Prüfmitteilung must be accepted");
let state = events.iter().fold(state, MabisBillingWorkflow::apply);
assert_eq!(state.status_str(), "PruefmitteilungSent");
let events = MabisBillingWorkflow::handle(
&state,
BillingCommand::ReceiveDatastatus {
data_status: DataStatus::AbgerechtneteDaten,
},
)
.expect("ReceiveDatastatus from PruefmitteilungSent must succeed");
let state = events.iter().fold(state, MabisBillingWorkflow::apply);
assert_eq!(state.status_str(), "Settled");
}
#[test]
fn negative_pruefmitteilung_transitions_to_disputed() {
let state = BillingState::default();
let events =
MabisBillingWorkflow::handle(&state, receive_cmd(BillingVersion::Endgueltig)).unwrap();
let state = events.iter().fold(state, MabisBillingWorkflow::apply);
let events = MabisBillingWorkflow::handle(
&state,
BillingCommand::SendPruefmitteilungNegativ {
message_ref: MessageRef::new("PRUEF-NEG-001"),
reason: "Zählpunkt DE000... fehlt in der Summenzeitreihe".to_owned(),
},
)
.expect("negative Prüfmitteilung must be accepted");
let state = events.iter().fold(state, MabisBillingWorkflow::apply);
assert_eq!(state.status_str(), "Disputed");
}
#[test]
fn wrong_pid_returns_not_implemented() {
let state = BillingState::default();
let err = MabisBillingWorkflow::handle(
&state,
BillingCommand::ReceiveSummenzeitreihe {
pid: Pruefidentifikator::new(55_001).expect("valid pid"),
billing_period: BillingPeriod::new("2025-09"),
bkv_id: BkvId::new("BKV-001"),
biko_id: BikoId::new("BIKO-001"),
version: BillingVersion::Vorlaeufig,
message_ref: MessageRef::new("REF-001"),
},
)
.expect_err("PID 55001 must be rejected");
assert!(err.is_not_implemented(), "{err}");
}
#[test]
fn pruefmitteilung_in_wrong_state_is_rejected() {
let state = BillingState::New;
let err = MabisBillingWorkflow::handle(
&state,
BillingCommand::SendPruefmitteilungPositiv {
message_ref: MessageRef::new("REF"),
},
)
.expect_err("must fail on New state");
assert!(err.to_string().contains("SummenzeitreiheReceived"), "{err}");
}
#[test]
fn deadline_expired_in_summenzeitreihe_received_state() {
let state = BillingState::default();
let events =
MabisBillingWorkflow::handle(&state, receive_cmd(BillingVersion::Vorlaeufig)).unwrap();
let state = events.iter().fold(state, MabisBillingWorkflow::apply);
let events = MabisBillingWorkflow::handle(
&state,
BillingCommand::PruefmitteilungDeadlineExpired {
deadline_id: DeadlineId::new(),
label: PRUEFMITTEILUNG_DEADLINE_LABEL.into(),
},
)
.expect("deadline in SummenzeitreiheReceived must be accepted");
let state = events.iter().fold(state, MabisBillingWorkflow::apply);
assert_eq!(state.status_str(), "DeadlineExpired");
}
#[test]
fn deadline_expired_in_terminal_state_is_noop() {
let state = BillingState::default();
let events =
MabisBillingWorkflow::handle(&state, receive_cmd(BillingVersion::Vorlaeufig)).unwrap();
let state = events.iter().fold(state, MabisBillingWorkflow::apply);
let events = MabisBillingWorkflow::handle(
&state,
BillingCommand::SendPruefmitteilungNegativ {
message_ref: MessageRef::new("REF"),
reason: "disputed".to_owned(),
},
)
.unwrap();
let state = events.iter().fold(state, MabisBillingWorkflow::apply);
assert_eq!(state.status_str(), "Disputed");
let events = MabisBillingWorkflow::handle(
&state,
BillingCommand::PruefmitteilungDeadlineExpired {
deadline_id: DeadlineId::new(),
label: PRUEFMITTEILUNG_DEADLINE_LABEL.into(),
},
)
.expect("deadline in terminal state must produce empty events");
assert!(events.is_empty(), "no events expected in terminal state");
}
}