Skip to main content

mako_engine/
erp.rs

1//! ERP integration traits and reference implementations.
2//!
3//! ## Role
4//!
5//! `mako-engine` is a protocol processor — it handles EDIFACT parsing, BDEW
6//! process rules, AS4 delivery, and regulatory deadlines. All contract data,
7//! billing logic, and master data live in the operator's ERP.
8//!
9//! This module defines the **stable integration contract** between `mako-engine`
10//! and external ERP or backend systems.  The payload contract is **BO4E**, not
11//! raw EDIFACT.  ERP adapters never see EDIFACT segment codes or format-version
12//! identifiers — those are absorbed inside `mako-engine`.
13//!
14//! ## Outbound: mako → ERP
15//!
16//! Implement [`ErpAdapter`] and register it at startup.  Every domain event
17//! that requires ERP action is delivered as an [`ErpEvent`].  The production
18//! `WebhookErpAdapter` (in `makod`) serialises events as
19//! **[CloudEvents 1.0](https://cloudevents.io) structured-mode JSON** and POSTs
20//! them to the configured ERP endpoint.
21//!
22//! ```text
23//! POST <erp_webhook_url>
24//! Content-Type: application/cloudevents+json
25//! X-Idempotency-Key: <event.idempotency_key>
26//! X-Mako-Signature: <hmac-sha256-hex>   ← only when secret is configured
27//!
28//! {
29//!   "specversion": "1.0",
30//!   "id": "<idempotency_key>",
31//!   "source": "urn:mako:tenant:<tenant_id>",
32//!   "type": "de.mako.aperak.accepted",
33//!   "time": "2026-10-01T10:15:00+02:00",
34//!   "subject": "<process_id>",
35//!   "dataschema": "https://.../Marktlokation.json",
36//!   "datacontenttype": "application/json",
37//!   "makoconvid": "<conversation_id>",
38//!   "makocausationid": "<causation_id>",
39//!   "makopid": 55001,
40//!   "data": { "_typ": "MARKTLOKATION", ... }
41//! }
42//! ```
43//!
44//! See [`ErpEventType::cloud_event_type`] for the full type → CE type mapping.
45//! The BO4E payload is always in the `data` field; the `payload_schema` URL
46//! maps to the CloudEvents `dataschema` attribute.
47//!
48//! ## Inbound: ERP → mako (event-driven)
49//!
50//! For ERP systems with a message bus, implement [`ErpCommandSource`] to feed
51//! BO4E business objects into the engine without a synchronous REST round-trip.
52//!
53//! ```rust,ignore
54//! struct MyKafkaSource { consumer: KafkaConsumer }
55//!
56//! impl ErpCommandSource for MyKafkaSource {
57//!     async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
58//!         let msg = self.consumer.poll(Duration::from_millis(100)).await;
59//!         Ok(msg.map(|m| InboundErpCommand {
60//!             idempotency_key: m.offset().to_string(),
61//!             tenant_id: TenantId::new(),
62//!             payload_schema: "…/Marktlokation.json".into(),
63//!             payload: serde_json::from_slice(m.payload()).unwrap(),
64//!         }))
65//!     }
66//!
67//!     async fn ack(&self, id: &str) -> Result<(), ErpAdapterError> {
68//!         self.consumer.commit_offset(id.parse().unwrap()).await
69//!             .map_err(ErpAdapterError::transport)
70//!     }
71//!
72//!     async fn nack(&self, _id: &str, _reason: &str) -> Result<(), ErpAdapterError> {
73//!         Ok(()) // Kafka auto-redelivers on next poll
74//!     }
75//! }
76//! ```
77//!
78//! ## Reference implementations
79//!
80//! | Type | Feature | Use case |
81//! |------|---------|---------|
82//! | [`NoopErpAdapter`] | `testing` | Unit tests, CI |
83//! | [`LogErpAdapter`] | — | Structured log output; starting point for new integrations |
84//! | [`NoopErpCommandSource`] | `testing` | No-op inbound source for tests |
85//!
86//! For the production `WebhookErpAdapter` and `POST /api/v1/commands` endpoint,
87//! see `makod/src/erp_adapter.rs`.
88
89use std::sync::Arc;
90
91use serde::{Deserialize, Serialize};
92use time::OffsetDateTime;
93
94use crate::ids::{ConversationId, EventId, ProcessId, TenantId};
95
96// ── ErpAdapterError ───────────────────────────────────────────────────────────
97
98/// Errors produced by [`ErpAdapter`] and [`ErpCommandSource`] implementations.
99#[derive(Debug, thiserror::Error)]
100pub enum ErpAdapterError {
101    /// The ERP response payload could not be deserialised or is semantically
102    /// invalid.
103    #[error("ERP payload error: {0}")]
104    Payload(String),
105
106    /// A transient transport error (network timeout, HTTP 5xx, broker
107    /// disconnect).  The delivery worker will retry with exponential backoff.
108    #[error("ERP transport error: {0}")]
109    Transport(String),
110
111    /// A permanent, non-retryable error (e.g. invalid configuration,
112    /// authentication failure).  The delivery worker will dead-letter the
113    /// message.
114    #[error("ERP permanent error: {0}")]
115    Permanent(String),
116}
117
118impl ErpAdapterError {
119    /// Construct a [`Payload`](ErpAdapterError::Payload) variant.
120    pub fn payload(e: impl std::fmt::Display) -> Self {
121        Self::Payload(e.to_string())
122    }
123
124    /// Construct a [`Transport`](ErpAdapterError::Transport) variant.
125    pub fn transport(e: impl std::fmt::Display) -> Self {
126        Self::Transport(e.to_string())
127    }
128
129    /// Construct a [`Permanent`](ErpAdapterError::Permanent) variant.
130    pub fn permanent(e: impl std::fmt::Display) -> Self {
131        Self::Permanent(e.to_string())
132    }
133
134    /// Returns `true` for transient errors that warrant a retry.
135    #[must_use]
136    pub fn is_retryable(&self) -> bool {
137        matches!(self, Self::Transport(_))
138    }
139}
140
141// ── ErpEventType ─────────────────────────────────────────────────────────────
142
143/// Semantic classification of an outbound ERP process event.
144///
145/// The ERP uses this to decide which action to take — update an order status,
146/// trigger a billing run, open a complaint ticket, etc.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum ErpEventType {
150    /// A new MaKo process was spawned (e.g. inbound UTILMD received).
151    ProcessInitiated,
152    /// The counterparty sent an APERAK accepting our UTILMD.
153    AperakAccepted,
154    /// The counterparty sent an APERAK rejecting our UTILMD.
155    AperakRejected,
156    /// No APERAK received within the regulatory SLA window (deadline expired).
157    AperakTimeout,
158    /// A CONTRL syntax acknowledgement was received.
159    ContrlReceived,
160    /// The process reached its terminal success state
161    /// (e.g. Lieferbeginn/Lieferende confirmed).
162    ProcessCompleted,
163    /// A MaLo identification request was successfully resolved: the MaLo was
164    /// found and the positive callback was delivered to the requesting LF.
165    ///
166    /// The `payload` field of the associated [`ErpEvent`] carries a BO4E
167    /// `Marktlokation` JSON object with the resolved MaLo data.
168    MaloIdentified,
169    /// The process failed permanently (regulatory timeout, data error, …).
170    ProcessFailed {
171        /// Human-readable failure description.
172        reason: Box<str>,
173    },
174}
175
176impl ErpEventType {
177    /// Short label for structured logging and metrics.
178    #[must_use]
179    pub fn label(&self) -> &'static str {
180        match self {
181            Self::ProcessInitiated => "process_initiated",
182            Self::AperakAccepted => "aperak_accepted",
183            Self::AperakRejected => "aperak_rejected",
184            Self::AperakTimeout => "aperak_timeout",
185            Self::ContrlReceived => "contrl_received",
186            Self::ProcessCompleted => "process_completed",
187            Self::MaloIdentified => "malo_identified",
188            Self::ProcessFailed { .. } => "process_failed",
189        }
190    }
191
192    /// CloudEvents 1.0 `type` attribute for this event.
193    ///
194    /// Follows the reverse-DNS prefix convention (`de.mako.<domain>.<action>`).
195    /// Used by the `WebhookErpAdapter` to populate the `type` field of the
196    /// CloudEvents envelope.
197    #[must_use]
198    pub fn cloud_event_type(&self) -> &'static str {
199        match self {
200            Self::ProcessInitiated => "de.mako.process.initiated",
201            Self::AperakAccepted => "de.mako.aperak.accepted",
202            Self::AperakRejected => "de.mako.aperak.rejected",
203            Self::AperakTimeout => "de.mako.aperak.timeout",
204            Self::ContrlReceived => "de.mako.contrl.received",
205            Self::ProcessCompleted => "de.mako.process.completed",
206            Self::MaloIdentified => "de.mako.malo.identified",
207            Self::ProcessFailed { .. } => "de.mako.process.failed",
208        }
209    }
210}
211
212// ── ErpEvent ──────────────────────────────────────────────────────────────────
213
214/// A structured process event delivered from `mako-engine` to the ERP.
215///
216/// The payload is always a **BO4E-typed JSON object** — the ERP adapter never
217/// receives raw EDIFACT bytes or EDIFACT format-version identifiers.
218///
219/// On the wire (via `WebhookErpAdapter`) this struct is serialised as a
220/// **[CloudEvents 1.0](https://cloudevents.io) structured-mode JSON** envelope
221/// with `Content-Type: application/cloudevents+json`.  The BO4E payload lives
222/// in the CloudEvents `data` field; `payload_schema` maps to `dataschema`;
223/// `event_type` maps to the `type` attribute via [`ErpEventType::cloud_event_type`].
224///
225/// ## Idempotency
226///
227/// `idempotency_key` maps to the CloudEvents `id` attribute and is also sent
228/// as `X-Idempotency-Key` for ERP middleware that keys on headers.  The ERP
229/// **must** persist this key and return `HTTP 200 OK` for duplicate deliveries.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ErpEvent {
232    /// Stable dedup key — store in the ERP to reject duplicate deliveries.
233    ///
234    /// Derived from the outbox `message_id`; stable across retries.
235    pub idempotency_key: String,
236
237    /// Semantic classification of this event.
238    pub event_type: ErpEventType,
239
240    /// The mako process that generated this event.
241    pub process_id: ProcessId,
242
243    /// Tenant (operator GLN) that owns this process.
244    pub tenant_id: TenantId,
245
246    /// BDEW business conversation identifier.
247    pub conversation_id: ConversationId,
248
249    /// The mako domain event that directly caused this ERP notification.
250    pub causation_id: EventId,
251
252    /// Prüfidentifikator of the process.
253    pub pid: u32,
254
255    /// BO4E JSON Schema URL that validates [`payload`](ErpEvent::payload).
256    ///
257    /// Examples:
258    /// - `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202501.0.0/src/bo4e_schemas/bo/Marktlokation.json"`
259    /// - `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202501.0.0/src/bo4e_schemas/bo/Messlokation.json"`
260    ///
261    /// `None` for events where no primary BO4E object is applicable
262    /// (e.g. `ContrlReceived`).
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub payload_schema: Option<String>,
265
266    /// BO4E-typed payload.
267    ///
268    /// Deserialise using the ERP's own BO4E library.  Raw EDIFACT structures
269    /// are never exposed here.  `null` when no payload is applicable.
270    pub payload: serde_json::Value,
271
272    /// Wall-clock time when the domain event was persisted.
273    pub occurred_at: OffsetDateTime,
274}
275
276// ── ErpAdapter trait ──────────────────────────────────────────────────────────
277
278/// Outbound notification sink — `mako-engine` calls this when a process event
279/// should be reported to the ERP.
280///
281/// The payload is always a BO4E-typed JSON object; the adapter never receives
282/// raw EDIFACT bytes or format-version identifiers.
283///
284/// ## Contract
285///
286/// - Must be **idempotent** on `event.idempotency_key`.  Called twice with the
287///   same key must succeed without double-posting.
288/// - Return [`ErpAdapterError::Transport`] for transient failures — the caller
289///   will retry with exponential backoff.
290/// - Return [`ErpAdapterError::Permanent`] for non-retryable failures — the
291///   caller will dead-letter the event.
292#[allow(async_fn_in_trait)]
293pub trait ErpAdapter: Send + Sync + 'static {
294    /// Deliver `event` to the ERP.
295    async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError>;
296}
297
298/// Blanket `Arc` implementation so `ErpAdapter` can be shared across tasks.
299impl<T: ErpAdapter> ErpAdapter for Arc<T> {
300    async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError> {
301        (**self).notify(event).await
302    }
303}
304
305// ── InboundErpCommand ─────────────────────────────────────────────────────────
306
307/// A BO4E business object received from the ERP, intended to trigger a mako
308/// process.
309///
310/// `mako-engine` maps the BO4E payload to an internal `Command` via the
311/// domain crate's command mapper.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct InboundErpCommand {
314    /// Stable dedup key — forwarded to [`InboxStore::accept`].
315    ///
316    /// The ERP must supply a stable, unique identifier per command so that
317    /// retransmissions do not double-execute the workflow.
318    ///
319    /// [`InboxStore::accept`]: crate::inbox::InboxStore::accept
320    pub idempotency_key: String,
321
322    /// Tenant (operator GLN) that owns the target process.
323    pub tenant_id: TenantId,
324
325    /// BO4E JSON Schema URL — identifies the object type without inspecting
326    /// `payload`.
327    ///
328    /// Example:
329    /// `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202501.0.0/src/bo4e_schemas/bo/Vertrag.json"`
330    pub payload_schema: String,
331
332    /// BO4E-typed JSON payload.  `mako-engine` maps this to an internal
333    /// `Command` via the registered domain command mapper.
334    pub payload: serde_json::Value,
335}
336
337// ── ErpCommandSource trait ────────────────────────────────────────────────────
338
339/// Inbound command source — `mako-engine` polls this for new BO4E objects
340/// from the ERP.
341///
342/// Implement this for broker-based inbound flows (Kafka consumer, SFTP poll,
343/// database change feed, …) to make the entire integration fully event-driven
344/// — no synchronous REST round-trip required.
345///
346/// ## Contract
347///
348/// - [`next`](ErpCommandSource::next) must be **non-blocking** when idle —
349///   return `Ok(None)` immediately when no command is available.
350/// - [`ack`](ErpCommandSource::ack) must suppress re-delivery of `id` after
351///   a successful ack (idempotent).
352/// - [`nack`](ErpCommandSource::nack) should allow re-delivery of `id` after
353///   an appropriate backoff.
354#[allow(async_fn_in_trait)]
355pub trait ErpCommandSource: Send + Sync + 'static {
356    /// Return the next pending BO4E command, or `None` when the source is idle.
357    async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError>;
358
359    /// Acknowledge successful processing of `id`.
360    ///
361    /// After a successful ack the source must not re-deliver `id`.
362    async fn ack(&self, id: &str) -> Result<(), ErpAdapterError>;
363
364    /// Negative-acknowledge — allow re-delivery of `id` after backoff.
365    async fn nack(&self, id: &str, reason: &str) -> Result<(), ErpAdapterError>;
366}
367
368/// Blanket `Arc` implementation so `ErpCommandSource` can be shared across tasks.
369impl<S: ErpCommandSource> ErpCommandSource for Arc<S> {
370    async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
371        (**self).next().await
372    }
373    async fn ack(&self, id: &str) -> Result<(), ErpAdapterError> {
374        (**self).ack(id).await
375    }
376    async fn nack(&self, id: &str, reason: &str) -> Result<(), ErpAdapterError> {
377        (**self).nack(id, reason).await
378    }
379}
380
381// ── NoopErpAdapter ────────────────────────────────────────────────────────────
382
383/// An [`ErpAdapter`] that succeeds immediately without notifying anything.
384///
385/// Use in unit tests and CI where no real ERP endpoint is available.
386#[cfg(feature = "testing")]
387#[derive(Debug, Clone, Default)]
388pub struct NoopErpAdapter;
389
390#[cfg(feature = "testing")]
391impl ErpAdapter for NoopErpAdapter {
392    async fn notify(&self, _event: ErpEvent) -> Result<(), ErpAdapterError> {
393        Ok(())
394    }
395}
396
397// ── LogErpAdapter ─────────────────────────────────────────────────────────────
398
399/// An [`ErpAdapter`] that logs every event at `info` level without delivering
400/// it.
401///
402/// Useful as a development starting point — replace it with your concrete ERP
403/// adapter in production.
404#[derive(Debug, Clone, Default)]
405pub struct LogErpAdapter;
406
407impl ErpAdapter for LogErpAdapter {
408    async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError> {
409        tracing::info!(
410            idempotency_key = %event.idempotency_key,
411            event_type      = event.event_type.label(),
412            process_id      = %event.process_id,
413            tenant_id       = %event.tenant_id,
414            pid             = event.pid,
415            "ErpAdapter: event logged (no delivery configured)",
416        );
417        Ok(())
418    }
419}
420
421// ── NoopErpCommandSource ──────────────────────────────────────────────────────
422
423/// An [`ErpCommandSource`] that is always idle (returns `Ok(None)`).
424///
425/// Use in tests where no inbound ERP command flow is needed.
426#[cfg(feature = "testing")]
427#[derive(Debug, Clone, Default)]
428pub struct NoopErpCommandSource;
429
430#[cfg(feature = "testing")]
431impl ErpCommandSource for NoopErpCommandSource {
432    async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
433        Ok(None)
434    }
435    async fn ack(&self, _id: &str) -> Result<(), ErpAdapterError> {
436        Ok(())
437    }
438    async fn nack(&self, _id: &str, _reason: &str) -> Result<(), ErpAdapterError> {
439        Ok(())
440    }
441}
442
443// ── ErpAdapterTestHarness ─────────────────────────────────────────────────────
444
445/// A recording [`ErpAdapter`] for use in tests.
446///
447/// Records every [`ErpEvent`] delivered via [`notify`](ErpAdapter::notify) so
448/// tests can assert on event types, ordering, and BO4E payload shapes.
449///
450/// ```rust,ignore
451/// let harness = ErpAdapterTestHarness::new();
452/// my_workflow.run_with_adapter(harness.adapter()).await?;
453///
454/// let events = harness.events();
455/// assert_eq!(events[0].event_type, ErpEventType::ProcessInitiated);
456/// assert_eq!(events[1].event_type, ErpEventType::AperakAccepted);
457/// ```
458#[cfg(feature = "testing")]
459#[derive(Debug, Clone, Default)]
460pub struct ErpAdapterTestHarness {
461    events: Arc<tokio::sync::Mutex<Vec<ErpEvent>>>,
462}
463
464#[cfg(feature = "testing")]
465impl ErpAdapterTestHarness {
466    /// Create a new empty harness.
467    #[must_use]
468    pub fn new() -> Self {
469        Self::default()
470    }
471
472    /// Return a snapshot of all recorded events in delivery order.
473    pub async fn events(&self) -> Vec<ErpEvent> {
474        self.events.lock().await.clone()
475    }
476
477    /// Drain all recorded events, resetting the harness.
478    pub async fn drain(&self) -> Vec<ErpEvent> {
479        std::mem::take(&mut *self.events.lock().await)
480    }
481}
482
483#[cfg(feature = "testing")]
484impl ErpAdapter for ErpAdapterTestHarness {
485    async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError> {
486        self.events.lock().await.push(event);
487        Ok(())
488    }
489}
490
491// ── ErpCommandSourceTestHarness ───────────────────────────────────────────────
492
493/// A controllable [`ErpCommandSource`] for use in tests.
494///
495/// Inject canned [`InboundErpCommand`] payloads and verify that the engine
496/// processes them correctly.
497///
498/// ```text
499/// let source = ErpCommandSourceTestHarness::new();
500/// source.inject(InboundErpCommand {
501///     idempotency_key: "order-42".into(),
502///     tenant_id: TenantId::new(),
503///     payload_schema: ".../Vertrag.json".into(),
504///     payload: serde_json::json!({ "_typ": "VERTRAG", ... }),
505/// }).await;
506///
507/// // The engine picks up the command on the next poll.
508/// ```
509#[cfg(feature = "testing")]
510#[derive(Debug, Clone, Default)]
511pub struct ErpCommandSourceTestHarness {
512    queue: Arc<tokio::sync::Mutex<std::collections::VecDeque<InboundErpCommand>>>,
513    acked: Arc<tokio::sync::Mutex<Vec<String>>>,
514    nacked: Arc<tokio::sync::Mutex<Vec<(String, String)>>>,
515}
516
517#[cfg(feature = "testing")]
518impl ErpCommandSourceTestHarness {
519    /// Create a new empty harness.
520    #[must_use]
521    pub fn new() -> Self {
522        Self::default()
523    }
524
525    /// Enqueue a command to be returned by the next [`next`](ErpCommandSource::next) call.
526    pub async fn inject(&self, cmd: InboundErpCommand) {
527        self.queue.lock().await.push_back(cmd);
528    }
529
530    /// Return all acked command IDs.
531    pub async fn acked(&self) -> Vec<String> {
532        self.acked.lock().await.clone()
533    }
534
535    /// Return all nacked `(id, reason)` pairs.
536    pub async fn nacked(&self) -> Vec<(String, String)> {
537        self.nacked.lock().await.clone()
538    }
539}
540
541#[cfg(feature = "testing")]
542impl ErpCommandSource for ErpCommandSourceTestHarness {
543    async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
544        Ok(self.queue.lock().await.pop_front())
545    }
546
547    async fn ack(&self, id: &str) -> Result<(), ErpAdapterError> {
548        self.acked.lock().await.push(id.to_owned());
549        Ok(())
550    }
551
552    async fn nack(&self, id: &str, reason: &str) -> Result<(), ErpAdapterError> {
553        self.nacked
554            .lock()
555            .await
556            .push((id.to_owned(), reason.to_owned()));
557        Ok(())
558    }
559}
560
561// ── BO4E schema URL constants ─────────────────────────────────────────────────
562
563/// BO4E schema URL base for v202501.0.0.
564///
565/// Use `bo4e_schema_url!(Marktlokation)` to construct typed schema URLs at
566/// compile time.
567pub const BO4E_V202501_BASE: &str =
568    "https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202501.0.0/src/bo4e_schemas";
569
570/// Construct a BO4E v202501.0.0 JSON Schema URL for a Business Object.
571///
572/// ```rust
573/// use mako_engine::bo4e_schema_url;
574/// assert!(bo4e_schema_url!("bo", "Marktlokation").contains("Marktlokation"));
575/// ```
576#[macro_export]
577macro_rules! bo4e_schema_url {
578    ($category:literal, $name:literal) => {
579        concat!(
580            "https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202501.0.0/src/bo4e_schemas/",
581            $category,
582            "/",
583            $name,
584            ".json",
585        )
586    };
587}
588
589/// BO4E JSON Schema URL for `Marktlokation`.
590pub const BO4E_SCHEMA_MARKTLOKATION: &str = bo4e_schema_url!("bo", "Marktlokation");
591
592/// BO4E JSON Schema URL for `Messlokation`.
593pub const BO4E_SCHEMA_MESSLOKATION: &str = bo4e_schema_url!("bo", "Messlokation");
594
595/// BO4E JSON Schema URL for `Vertrag`.
596pub const BO4E_SCHEMA_VERTRAG: &str = bo4e_schema_url!("bo", "Vertrag");
597
598/// BO4E JSON Schema URL for `Energiemenge`.
599pub const BO4E_SCHEMA_ENERGIEMENGE: &str = bo4e_schema_url!("bo", "Energiemenge");
600
601/// BO4E JSON Schema URL for `Rechnung`.
602pub const BO4E_SCHEMA_RECHNUNG: &str = bo4e_schema_url!("bo", "Rechnung");
603
604/// BO4E JSON Schema URL for `Zaehler`.
605pub const BO4E_SCHEMA_ZAEHLER: &str = bo4e_schema_url!("bo", "Zaehler");
606
607/// BO4E JSON Schema URL for `Geschaeftspartner`.
608pub const BO4E_SCHEMA_GESCHAEFTSPARTNER: &str = bo4e_schema_url!("bo", "Geschaeftspartner");