Skip to main content

mako_fristen/
lib.rs

1//! **Every Frist in German market communication, in one leaf crate.**
2//!
3//! Deadlines are the regulatory asset this platform is built around, and this
4//! crate is the one answer to "when is this due". It depends on nothing but
5//! `time`, so every service can read it without pulling in a workflow engine —
6//! which is what keeps the calendar, the per-Prüfidentifikator answer tables and
7//! the alerting from each carrying a copy that disagrees with the others.
8//!
9//! | Question | Answer |
10//! |---|---|
11//! | *What is today's date, in the market's terms?* | [`heute`], [`berlin_date`], [`berlin_midnight`], [`berlin_now`] |
12//! | *When is the 4th Werktag after this instant?* | [`add_werktage`], [`deadline_at_werktage`], [`end_of_werktag_after`], [`next_werktag_at`] |
13//! | *Which window does PID 55001 carry?* | [`antwort`] |
14//! | *Which messages does receiving it oblige me to send?* | [`meldung`] |
15//! | *When must the CONTRL / APERAK go out?* | [`contrl_due_at`], [`aperak_strom_due_at`], [`aperak_gas_folgeprozess_due_at`], [`aperak_gas_initialprozess_due_at`] |
16//!
17//! `mako-engine` re-exports this crate as `mako_fristen`, so existing
18//! call sites are unchanged.
19//!
20//! # Three clocks, never one number
21//!
22//! The mistake this crate is shaped to prevent is treating any of these as the
23//! others. They differ by orders of magnitude and they fail for different
24//! reasons.
25//!
26//! | Clock | Window | Meaning |
27//! |---|---|---|
28//! | **CONTRL** | 6 wall-clock hours; **15 min** for a Strom UTILMD/ORDERS Syntaxfehlermeldung (6 h on a Saturday), **45 min** for a GABi-Gas ALOCAT; deferred to the end of a Formatumstellung window (CONTRL AHB 1.0 §2.3.1, §2.4.1) | the syntax check of the Übertragungsdatei |
29//! | **APERAK** | 45 min Strom weekday; Gas: next Werktag 12:00 (Folgeprozess) or 3 Werktage (Initialprozess) | the message was accepted for processing |
30//! | **Antwortfrist** | per PID — 11:00 of the 1. Werktag for a GPKE Anmeldung, 4 Werktage for a Gas Anmeldung, 3/5/7/1 WT for WiM Strom | the *business* answer is owed |
31//!
32//! **There is no 24-hour GPKE window**, under BK6-24-174 or anything else.
33//! The technical acknowledgement is 45 minutes
34//! ([`aperak_strom_due_at`]) and the business answer is a wall-clock instant on
35//! the first Werktag after the Übertragungstag (`mako-pruefung`). A flat 24 h is
36//! neither, and it is wrong in the direction that does not announce itself: it
37//! reports a lapsed Frist as still running.
38//!
39//! ## Werktage
40//!
41//! ```rust
42//! use mako_fristen::{self as fristen, HolidayCalendar};
43//! use time::{Date, Month};
44//!
45//! // 5 Werktage after Monday 2025-01-06:
46//! let start = Date::from_calendar_date(2025, Month::January, 6).unwrap();
47//! let due   = fristen::add_werktage(start, 5, HolidayCalendar::BdewMaKo);
48//! // Tue 07, Wed 08, Thu 09, Fri 10, Mon 13 → 2025-01-13
49//! // (Saturday and Sunday are not Werktage in market communication)
50//! assert_eq!(due, Date::from_calendar_date(2025, Month::January, 13).unwrap());
51//! ```
52//!
53//! ## Holiday calendar: BDEW-defined, Germany-wide
54//!
55//! [`HolidayCalendar::BdewMaKo`] is the single holiday calendar used in all
56//! BNetzA MaKo processes. BDEW EDI@Energy specifies a conservative-inclusive
57//! approach: every public holiday observed in *any* German state is treated as
58//! a non-Werktag. This guarantees no Frist is ever shorter than the AHB
59//! requires. Per-state calendars are **not** used in BDEW MaKo.
60//!
61//! ## CONTRL Übertragungsquittung
62//!
63//! ```rust
64//! use mako_fristen::{self as fristen, ContrlAnlass};
65//! use time::{Date, Month, OffsetDateTime, Time};
66//!
67//! let received = OffsetDateTime::new_utc(
68//!     Date::from_calendar_date(2026, Month::September, 9).unwrap(),
69//!     Time::from_hms(9, 0, 0).unwrap(),
70//! );
71//! let due = fristen::contrl_due_at(received, ContrlAnlass::Regelfall);
72//! assert_eq!(due - received, time::Duration::hours(6));
73//! ```
74
75#![deny(unsafe_code)]
76#![deny(missing_docs)]
77#![warn(clippy::pedantic, clippy::must_use_candidate)]
78#![allow(clippy::doc_markdown)] // German MaKo terms produce many false positives
79
80pub mod abmeldung;
81pub mod antwort;
82pub mod meldung;
83pub mod vorlauf;
84
85use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time, Weekday};
86use time_tz::{OffsetDateTimeExt, OffsetResult, PrimitiveDateTimeExt, timezones};
87
88// ── CONTRL Übertragungsquittung ───────────────────────────────────────────────
89
90/// The default wall-clock hours within which a CONTRL must be sent after
91/// receiving an EDIFACT interchange or APERAK.
92///
93/// CONTRL AHB 1.0 §2.3.1 (Gas) and §2.4.1 (Strom) state it identically: „Der
94/// Empfänger der Übertragungsdatei oder APERAK teilt dem Absender
95/// **unverzüglich, jedoch spätestens 6 Stunden** nach Erhalt … das Ergebnis
96/// seiner syntaktischen Prüfung mittels der Nachricht CONTRL mit."
97pub const CONTRL_FRIST_HOURS: i64 = 6;
98
99/// Minutes within which a Strom Syntaxfehlermeldung on a UTILMD or ORDERS must
100/// go out on a day other than Saturday (CONTRL AHB 1.0 §2.4.1).
101///
102/// „Wird eine UTILMD oder ORDERS übertragen, so ist der Empfänger … verpflichtet,
103/// dem Absender unverzüglich, jedoch **spätestens 15 Minuten** nach Eingang der
104/// Übertragungsdatei eine Syntaxfehlermeldungen per CONTRL zu senden, falls die
105/// Übertragungsdatei syntaktisch falsch ist." On a Saturday the same sentence
106/// gives 6 hours instead.
107pub const CONTRL_STROM_SCHNELL_MINUTEN: i64 = 15;
108
109/// Minutes within which the CONTRL on a GABi-Gas ALOCAT must go out
110/// (CONTRL AHB 1.0 §2.3.1).
111///
112/// „Beim Prozess der ALOCAT-Übermittlung vom NB an den MGV nach GABi Gas muss
113/// binnen **45 Minuten** nach Erhalt einer ALOCAT-Nachricht die zugehörige
114/// CONTRL versendet werden."
115pub const CONTRL_ALOCAT_MINUTEN: i64 = 45;
116
117/// Deadline label used in the `DeadlineStore` for CONTRL delivery obligations.
118///
119/// Register a `Deadline` with this label when enqueueing a CONTRL `PendingOutbox`
120/// entry. The outbox worker clears the deadline after successful CONTRL delivery.
121/// A deadline that fires before the CONTRL was delivered is a violated Frist
122/// (CONTRL AHB 1.0 §2.3.1 / §2.4.1).
123///
124/// The label does not name a window, because the window is not one number:
125/// [`contrl_due_at`] answers 6 hours, 45 minutes or 15 minutes depending on what
126/// arrived and in which Sparte, and defers to the end of a Formatumstellung
127/// window when the deadline falls inside one.
128pub const CONTRL_FRIST_LABEL: &str = "contrl-delivery-window";
129
130/// What the CONTRL is answering — the pair that decides its window.
131///
132/// CONTRL AHB 1.0 states three windows, and which applies follows the message
133/// that arrived rather than the CONTRL itself.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
135pub enum ContrlAnlass {
136    /// Any interchange or APERAK not named by a shorter window: 6 hours
137    /// (§2.3.1, §2.4.1).
138    Regelfall,
139    /// A Strom UTILMD or ORDERS: 15 minutes, or 6 hours when it arrived on a
140    /// Saturday (§2.4.1).
141    StromUtilmdOderOrders,
142    /// A GABi-Gas ALOCAT from the NB to the MGV: 45 minutes (§2.3.1).
143    GasAlocat,
144}
145
146/// Compute the CONTRL delivery deadline for `anlass`.
147///
148/// Saturday is decided on the **Berlin** weekday, because the AHB dates the
149/// exception in gesetzlicher deutscher Zeit — an interchange arriving 23:30 UTC
150/// on a Friday is already Saturday in Germany.
151///
152/// # Example
153///
154/// ```rust
155/// use mako_fristen::{self as fristen, ContrlAnlass};
156/// use time::{Date, Month, OffsetDateTime, Time};
157///
158/// // A fixed Wednesday: neither the Saturday exception nor a Formatumstellung
159/// // window applies, so both windows are the plain ones.
160/// let received = OffsetDateTime::new_utc(
161///     Date::from_calendar_date(2026, Month::September, 9).unwrap(),
162///     Time::from_hms(9, 0, 0).unwrap(),
163/// );
164/// assert_eq!(
165///     fristen::contrl_due_at(received, ContrlAnlass::Regelfall) - received,
166///     time::Duration::hours(6)
167/// );
168/// assert_eq!(
169///     fristen::contrl_due_at(received, ContrlAnlass::GasAlocat) - received,
170///     time::Duration::minutes(45)
171/// );
172/// ```
173#[must_use]
174pub fn contrl_due_at(received: OffsetDateTime, anlass: ContrlAnlass) -> OffsetDateTime {
175    let due = match anlass {
176        ContrlAnlass::Regelfall => received + Duration::hours(CONTRL_FRIST_HOURS),
177        ContrlAnlass::GasAlocat => received + Duration::minutes(CONTRL_ALOCAT_MINUTEN),
178        ContrlAnlass::StromUtilmdOderOrders => {
179            let berlin = timezones::db::europe::BERLIN;
180            if received.to_timezone(berlin).weekday() == Weekday::Saturday {
181                received + Duration::hours(CONTRL_FRIST_HOURS)
182            } else {
183                received + Duration::minutes(CONTRL_STROM_SCHNELL_MINUTEN)
184            }
185        }
186    };
187    match formatumstellung_toleranz_ende(due) {
188        Some(ende) if ende > due => ende,
189        _ => due,
190    }
191}
192
193/// The end of the Formatumstellung window `at` falls in, if any.
194///
195/// CONTRL AHB 1.0 §2.3.1 and §2.4.1 close identically: „Abweichungen von diesen
196/// Fristen sind von den Marktpartnern zu akzeptieren im Zeitraum der
197/// Formatumstellung vom **31.3. 18.00 Uhr bis 2.4. 00:00 Uhr** gesetzlicher
198/// deutscher Zeit (bei einer Formatumstellung zum 01.04.) bzw. vom **30.9.
199/// 18.00 Uhr bis 2.10. 00:00 Uhr** (bei einer Formatumstellung zum 01.10.)."
200///
201/// A CONTRL owed inside that window is owed at its end instead: a deviation
202/// happening in the window must be accepted, and one still outstanding when the
203/// window closes is a deviation outside it. Without this a Formatumstellung
204/// weekend reports every CONTRL as late — a violation the AHB says the
205/// counterparty may not raise.
206///
207/// The AHB's third case — a Formatumstellung the BNetzA dates away from 01.04.
208/// or 01.10., tolerated from six hours before that day until its end — is not
209/// modelled: it needs the date the Festlegung names, and no such date stands.
210/// The two regular windows are the ones the format calendar actually uses.
211fn formatumstellung_toleranz_ende(at: OffsetDateTime) -> Option<OffsetDateTime> {
212    let jahr = berlin_date(at).year();
213    // Both bounds are Berlin wall-clock, so the window is 30 hours in winter
214    // and in summer alike — neither edge crosses a DST transition.
215    for (von_monat, von_tag, bis_monat, bis_tag) in [
216        (Month::March, 31, Month::April, 2),
217        (Month::September, 30, Month::October, 2),
218    ] {
219        let (Ok(von_datum), Ok(bis_datum)) = (
220            Date::from_calendar_date(jahr, von_monat, von_tag),
221            Date::from_calendar_date(jahr, bis_monat, bis_tag),
222        ) else {
223            continue;
224        };
225        let von = berlin_at(von_datum, Time::from_hms(18, 0, 0).expect("18:00 is valid"));
226        let bis = berlin_midnight(bis_datum);
227        if at >= von && at < bis {
228            return Some(bis);
229        }
230    }
231    None
232}
233
234// ── APERAK Strom 45-minute / Saturday-noon sending window ────────────────────
235
236/// Minutes within which a Strom APERAK must be sent on weekdays (Mon–Fri).
237///
238/// Per APERAK AHB 1.0 §2.4.1: "UTILMD und ORDERS: an Werktagen (Montag–Freitag):
239/// 45 Minuten".
240pub const APERAK_STROM_WEEKDAY_MINUTES: i64 = 45;
241
242/// Shared prefix of every APERAK delivery-window label.
243///
244/// The outbox worker discharges a window under this prefix when the APERAK it
245/// was watching is delivered, so a window that *does* fire means the obligation
246/// really was missed. Any new APERAK window label must keep the prefix, or it
247/// will outlive its obligation and raise a false regulatory alert on every
248/// process.
249pub const APERAK_WINDOW_LABEL_PREFIX: &str = "aperak-";
250
251/// Does delivering `message_type` discharge the delivery window `label`?
252///
253/// A **delivery window** is a deadline that exists to ask one question: *did
254/// this message go out in time?* Once it has gone out the question is settled,
255/// and the window has to be retired — a window that outlives its obligation
256/// fires for every process, including every one that answered on time, and the
257/// scheduler cannot tell those apart, because a deadline it hands out is late
258/// by construction (`due_now` selects on `due_at <= now`).
259///
260/// Used by `mako_engine::builder::OutboxWorker::run` on
261/// successful delivery. Adding a delivery window means adding it here too;
262/// forgetting turns its miss counter into a count of *processes started*.
263///
264/// # Example
265///
266/// ```rust
267/// use mako_fristen::{
268///     discharges_delivery_window, APERAK_STROM_WINDOW_LABEL, CONTRL_FRIST_LABEL,
269/// };
270///
271/// assert!(discharges_delivery_window("APERAK", APERAK_STROM_WINDOW_LABEL));
272/// assert!(discharges_delivery_window("CONTRL", CONTRL_FRIST_LABEL));
273/// // A message never discharges another message's window.
274/// assert!(!discharges_delivery_window("CONTRL", APERAK_STROM_WINDOW_LABEL));
275/// // Nor does it touch a process-response deadline that shares the stream.
276/// assert!(!discharges_delivery_window("APERAK", "gpke-response-window"));
277/// ```
278#[must_use]
279pub fn discharges_delivery_window(message_type: &str, label: &str) -> bool {
280    match message_type {
281        // Strom 45 min, Gas Folgeprozess and Gas Initialprozess all share the
282        // prefix, and all are discharged by the same delivery.
283        "APERAK" => label.starts_with(APERAK_WINDOW_LABEL_PREFIX),
284        "CONTRL" => label == CONTRL_FRIST_LABEL,
285        _ => false,
286    }
287}
288
289/// Deadline label for Strom APERAK 45-minute sending obligations.
290///
291/// Register a `mako_engine::deadline::Deadline` with this label after
292/// enqueuing an outbound Strom APERAK.  If this deadline fires before the APERAK
293/// is delivered, the OutboxWorker has not completed delivery within the 45-minute
294/// window required by APERAK AHB 1.0 §2.4.1.
295pub const APERAK_STROM_WINDOW_LABEL: &str = "aperak-strom-45min-window";
296
297/// Compute the Strom APERAK sending deadline after receiving a Strom UTILMD or
298/// ORDERS message.
299///
300/// Returns the deadline by which the receiver **must dispatch its APERAK**:
301///
302/// | `received` Berlin weekday | Deadline |
303/// |---|---|
304/// | Monday – Friday | `received + 45 minutes` |
305/// | Saturday | next Sunday 12:00 Berlin local time |
306/// | Sunday | `received + 45 minutes` (de-facto — not specified in AHB) |
307///
308/// **Regulatory basis:** APERAK AHB 1.0 §2.4.1:
309/// *"UTILMD und ORDERS: samstags bis spätestens Sonntag 12:00 Uhr,
310/// an Werktagen (Montag–Freitag): 45 Minuten."*
311///
312/// # Panics
313///
314/// Panics if the date arithmetic for the Sunday computation overflows the
315/// calendar (unreachable for any date within the Gregorian calendar range).
316/// Also panics if `12:00:00` cannot be constructed as a `time::Time`
317/// (statically valid — not a reachable panic).
318///
319/// # Example
320///
321/// ```rust
322/// use mako_fristen as fristen;
323/// use time::{Date, Month, OffsetDateTime, Time, UtcOffset};
324///
325/// // Monday 2025-01-06 10:00 UTC (= 11:00 CET): deadline = 10:45 UTC
326/// let received = OffsetDateTime::new_utc(
327///     Date::from_calendar_date(2025, Month::January, 6).unwrap(),
328///     Time::from_hms(10, 0, 0).unwrap(),
329/// );
330/// let due = fristen::aperak_strom_due_at(received);
331/// assert_eq!(due - received, time::Duration::minutes(45));
332/// ```
333#[must_use]
334pub fn aperak_strom_due_at(received: OffsetDateTime) -> OffsetDateTime {
335    let berlin = timezones::db::europe::BERLIN;
336    let berlin_dt = received.to_timezone(berlin);
337
338    if berlin_dt.weekday() == Weekday::Saturday {
339        // APERAK AHB 1.0 §2.4.1: received on Saturday → by next Sunday 12:00 Berlin.
340        let sunday = berlin_dt
341            .date()
342            .next_day()
343            .expect("date overflow — unreachable for any practical date");
344        // 12:00 Berlin is never inside a DST gap (transitions happen at 02:00).
345        let noon_primitive =
346            PrimitiveDateTime::new(sunday, Time::from_hms(12, 0, 0).expect("12:00:00 is valid"));
347        match noon_primitive.assume_timezone(berlin) {
348            OffsetResult::Some(dt) => dt.to_offset(time::UtcOffset::UTC),
349            OffsetResult::Ambiguous(earlier, _) => earlier.to_offset(time::UtcOffset::UTC),
350            OffsetResult::None => {
351                // 12:00 is never inside a DST gap for Europe/Berlin.  Should never happen.
352                received + Duration::hours(26)
353            }
354        }
355    } else {
356        // Weekday (Mon–Fri) or Sunday: 45 wall-clock minutes.
357        received + Duration::minutes(APERAK_STROM_WEEKDAY_MINUTES)
358    }
359}
360
361// ── APERAK Gas sending windows ────────────────────────────────────────────────
362
363/// Deadline label for Gas APERAK sending obligations on Folgeprozesse.
364///
365/// Per APERAK AHB 1.0 §2.3.1 (Gas rules):
366/// *"Auf eingehende Übertragungsdateien, die Folgeprozesse … darstellen:
367/// nächster Werktag 12:00 Uhr."*
368///
369/// Folgeprozesse are messages that follow up on an existing process
370/// (confirmations, rejections, status messages).
371pub const APERAK_GAS_FOLGEPROZESS_LABEL: &str = "aperak-gas-folgeprozess-naechster-wt-1200";
372
373/// Deadline label for Gas APERAK sending obligations on Initialprozesse.
374///
375/// Per APERAK AHB 1.0 §2.3.1 (Gas rules):
376/// *"Auf eingehende Übertragungsdateien, die Initialprozesse … darstellen:
377/// 3 Werktage."*
378///
379/// Initialprozesse are messages that open a new business process
380/// (Lieferbeginn-Anfragen, MSB-Anmeldungen, etc.).
381pub const APERAK_GAS_INITIALPROZESS_LABEL: &str = "aperak-gas-initialprozess-3-werktage";
382
383/// Compute the Gas APERAK sending deadline for **Folgeprozesse**.
384///
385/// Per APERAK AHB 1.0 §2.3.1: the receiver must dispatch the APERAK by the
386/// **next Werktag at 12:00 Uhr Berliner Lokalzeit** after receiving the message.
387///
388/// | `received` Berlin local date | Deadline |
389/// |---|---|
390/// | Monday – Friday | next Werktag after the received date, 12:00 Berlin |
391/// | Saturday | Monday 12:00 Berlin (or next Werktag if Monday is a holiday) |
392/// | Sunday | Monday 12:00 Berlin (or next Werktag if Monday is a holiday) |
393///
394/// **Regulatory basis:** APERAK AHB 1.0 §2.3.1 — Gas Folgeprozesse.
395///
396/// # Panics
397///
398/// Panics if `12:00:00` cannot be constructed as a `time::Time` (statically
399/// valid — not a reachable panic), or if date arithmetic overflows the calendar
400/// (unreachable for any practical date), or if the timezone database cannot
401/// resolve 12:00 Europe/Berlin (12:00 is never inside a DST gap).
402///
403/// # Example
404///
405/// ```rust
406/// use mako_fristen as fristen;
407/// use time::{Date, Month, OffsetDateTime, Time};
408///
409/// // Monday 2025-01-06 10:00 UTC (= 11:00 CET): next Werktag is Tuesday.
410/// // Deadline: Tuesday 2025-01-07 12:00 CET = 11:00 UTC.
411/// let received = OffsetDateTime::new_utc(
412///     Date::from_calendar_date(2025, Month::January, 6).unwrap(),
413///     Time::from_hms(10, 0, 0).unwrap(),
414/// );
415/// let due = fristen::aperak_gas_folgeprozess_due_at(received);
416/// // 12:00 CET (UTC+1) = 11:00 UTC on 2025-01-07.
417/// assert_eq!(due.to_offset(time::UtcOffset::UTC).hour(), 11);
418/// ```
419#[must_use]
420pub fn aperak_gas_folgeprozess_due_at(received: OffsetDateTime) -> OffsetDateTime {
421    let berlin = timezones::db::europe::BERLIN;
422    let received_date = received.to_timezone(berlin).date();
423    // "nächster Werktag" means the Werktag AFTER the received date.
424    // First find the next calendar day, then advance to the next Werktag.
425    let next_day = received_date
426        .next_day()
427        .expect("date overflow — unreachable for any practical date");
428    let due_date = next_werktag(next_day, HolidayCalendar::BdewMaKo);
429    noon_berlin(due_date)
430}
431
432/// Compute the Gas APERAK sending deadline for **Initialprozesse**.
433///
434/// Per APERAK AHB 1.0 §2.3.1: the receiver must dispatch the APERAK within
435/// **3 Werktage at 12:00 Uhr Berliner Lokalzeit** after receiving the message.
436///
437/// **Regulatory basis:** APERAK AHB 1.0 §2.3.1 — Gas Initialprozesse.
438///
439/// # Panics
440///
441/// Same conditions as [`aperak_gas_folgeprozess_due_at`].
442///
443/// # Example
444///
445/// ```rust
446/// use mako_fristen::{self as fristen, HolidayCalendar};
447/// use time::{Date, Month, OffsetDateTime, Time};
448///
449/// // Monday 2025-01-06 10:00 UTC (= 11:00 CET):
450/// // 3 Werktage = Tue 07, Wed 08, Thu 09 → deadline Thu 2025-01-09 12:00 CET.
451/// let received = OffsetDateTime::new_utc(
452///     Date::from_calendar_date(2025, Month::January, 6).unwrap(),
453///     Time::from_hms(10, 0, 0).unwrap(),
454/// );
455/// let due = fristen::aperak_gas_initialprozess_due_at(received);
456/// assert_eq!(
457///     due.to_offset(time::UtcOffset::UTC).date(),
458///     Date::from_calendar_date(2025, Month::January, 9).unwrap()
459/// );
460/// ```
461#[must_use]
462pub fn aperak_gas_initialprozess_due_at(received: OffsetDateTime) -> OffsetDateTime {
463    let berlin = timezones::db::europe::BERLIN;
464    let start_date = received.to_timezone(berlin).date();
465    let due_date = add_werktage(start_date, 3, HolidayCalendar::BdewMaKo);
466    noon_berlin(due_date)
467}
468
469/// The Gas Prüfidentifikatoren that open a business process — the
470/// **Initialprozessschritte**, which carry the 3-Werktage APERAK window instead
471/// of the next-Werktag-noon one.
472///
473/// The BDEW makes this decidable rather than a matter of judgement. APERAK AHB
474/// 1.1 §2.1.3.6: the Initialprozessschritte of GeLi Gas, GPKE und WiM „sind im
475/// EDI@Energy-Dokument ‚Anwendungsübersicht der Prüfidentifikatoren' daran zu
476/// erkennen, dass in der Spalte ‚Zuordnung zu einem Objekt' die
477/// Tupel-Kennzeichnung den (Teil-)String ‚ZO-F' enthält". Everything else is a
478/// Folgeprozess and identifies purely by Markt-/Messlokations-ID.
479///
480/// The four Gas rows carrying `ZO-F` in *Anwendungsübersicht der
481/// Prüfidentifikatoren* 4.0:
482///
483/// | PID | Anwendungsfall | Festlegung |
484/// |---|---|---|
485/// | 44001 | Anmeldung NN (Lieferbeginn) | GeLi Gas 2.0 |
486/// | 44016 | Kündigung beim alten Lieferanten | GeLi Gas 2.0 |
487/// | 44039 | Kündigung MSB | AWH WiM Gas 2.0 |
488/// | 44042 | Anmeldung MSB (Beginn Messstellenbetrieb) | AWH WiM Gas 2.0 |
489///
490/// Note what is *not* here: the Ende MSB 44051 and the Verpflichtungsanfrage
491/// 44168 are `ZO-T1`, so they are Folgeprozesse despite opening a Use-Case —
492/// they identify an object that already exists.
493pub const GAS_INITIALPROZESS_PIDS: &[u32] = &[44_001, 44_016, 44_039, 44_042];
494
495/// The Gas APERAK sending deadline for an inbound Geschäftsvorfall, chosen by
496/// its Prüfidentifikator.
497///
498/// Returns the instant **and** the deadline label to register it under, so the
499/// two cannot drift apart. See [`GAS_INITIALPROZESS_PIDS`] for the rule.
500#[must_use]
501pub fn aperak_gas_due_at(pid: u32, received: OffsetDateTime) -> (&'static str, OffsetDateTime) {
502    if GAS_INITIALPROZESS_PIDS.contains(&pid) {
503        (
504            APERAK_GAS_INITIALPROZESS_LABEL,
505            aperak_gas_initialprozess_due_at(received),
506        )
507    } else {
508        (
509            APERAK_GAS_FOLGEPROZESS_LABEL,
510            aperak_gas_folgeprozess_due_at(received),
511        )
512    }
513}
514
515/// Whether the Sparte's APERAK regime has a **positive** acknowledgement at all.
516///
517/// * **Strom** (APERAK AHB 1.1 §2.4): both polarities — `BGM+312`
518///   Anerkennungsmeldung when the Geschäftsvorfall is processable, `BGM+313`
519///   Verarbeitbarkeitsfehlermeldung when it is not.
520/// * **Gas** (APERAK AHB 1.1 §2.3): the APERAK reports „ausschließlich" errors.
521///   There is no Anerkennungsmeldung — silence past the Frist *is* the
522///   acknowledgement — and every APERAK is answered with a CONTRL.
523#[must_use]
524pub const fn aperak_hat_anerkennungsmeldung(sparte_ist_gas: bool) -> bool {
525    !sparte_ist_gas
526}
527
528/// The Europe/Berlin calendar date of `instant`.
529///
530/// A German market obligation is stated in calendar days — a Lieferbeginn, a
531/// Rechnungsdatum, the day a Frist starts counting from. `OffsetDateTime::date`
532/// answers the **UTC** date, which is the previous day for every instant
533/// between 23:00 Berlin and midnight (22:00 in summer). One hour a night, every
534/// such date is off by one, and nothing about the value says so.
535///
536/// # Example
537///
538/// ```rust
539/// use mako_fristen::berlin_date;
540/// use time::macros::{date, datetime};
541///
542/// // 00:30 Berlin on 1 February is 23:30 UTC on 31 January.
543/// let instant = datetime!(2026-01-31 23:30 UTC);
544/// assert_eq!(instant.date(), date!(2026-01-31));
545/// assert_eq!(berlin_date(instant), date!(2026-02-01));
546/// ```
547///
548/// **Only for instants that are instants.** A BO4E `date-time` field carrying a
549/// pure date — `rechnungsdatum`, `wunschtermin`, a `Zeitraum` bound — is written
550/// as UTC midnight and read back with `OffsetDateTime::date`; converting one of
551/// those here answers the day before.
552#[must_use]
553pub fn berlin_date(instant: OffsetDateTime) -> Date {
554    instant.to_timezone(timezones::db::europe::BERLIN).date()
555}
556
557/// Now, expressed in the Europe/Berlin offset.
558///
559/// The same instant `OffsetDateTime::now_utc()` returns, carrying the German
560/// wall clock rather than UTC. For a record a regulator reads — an audit row, a
561/// dead-letter entry — that is the clock the obligation was stated in, and
562/// converting at display time puts the conversion in as many places as there
563/// are readers.
564#[must_use]
565pub fn berlin_now() -> OffsetDateTime {
566    OffsetDateTime::now_utc().to_timezone(timezones::db::europe::BERLIN)
567}
568
569/// Today, as the German market states it — the Europe/Berlin calendar date.
570///
571/// The one answer to „welcher Tag ist heute" for every business decision:
572/// which price slice is in force, which profile version applies, what date an
573/// invoice carries, which day a Frist starts counting from. See
574/// [`berlin_date`] for why the UTC date is not that answer.
575#[must_use]
576pub fn heute() -> Date {
577    berlin_date(OffsetDateTime::now_utc())
578}
579
580/// The instant at which the Europe/Berlin **calendar** day `date` begins,
581/// expressed in UTC.
582///
583/// Consecutive days tile the timeline without gap or overlap across both DST
584/// transitions, so `[berlin_midnight(from), berlin_midnight(to.next_day()))`
585/// is exactly the German calendar window `from..=to` — the window a Strom
586/// billing period, a Bilanzierungsmonat and a § 41a Lastgang query all mean. A
587/// window built from UTC midnights loses the first hour of the first day and
588/// gains the last hour of the day after the last.
589///
590/// **Not the Gastag.** Gas balances on a day running 06:00 → 06:00 Berlin
591/// (Art. 3 Nr. 6 VO (EU) 312/2014, KoV), which is a different boundary and a
592/// different date for the six hours before it opens. Use
593/// `mako_gabi_gas::GasDay` — or `metering::calendar::gas_day_start_utc` — for
594/// anything settled on a gas day; this function is the electricity and
595/// calendar-date answer only.
596///
597/// # Panics
598///
599/// Panics if the timezone database cannot resolve local midnight, which the
600/// Europe/Berlin transitions (02:00 → 03:00) never touch.
601#[must_use]
602pub fn berlin_midnight(date: Date) -> OffsetDateTime {
603    berlin_at(date, Time::MIDNIGHT).to_offset(time::UtcOffset::UTC)
604}
605
606/// Construct an [`OffsetDateTime`] at `at` Europe/Berlin on `date`, in UTC.
607///
608/// Every Frist in the BDEW MaKo rulebook is stated in German local time, so a
609/// deadline is only correct if the wall-clock hour is resolved against
610/// Europe/Berlin and *then* converted. Doing the arithmetic in UTC produces a
611/// deadline that is one hour wrong for half the year — a reportable BNetzA
612/// violation with no visible signal.
613///
614/// # Panics
615///
616/// Panics if the timezone database cannot resolve `at` on `date`. `at` must not
617/// fall inside the 02:00–03:00 Europe/Berlin DST gap; every Frist clock time in
618/// the rulebook (05:00, 06:00, 09:00, 11:00, 12:00, 17:00, end-of-day) is outside
619/// it.
620#[must_use]
621pub fn berlin_at(date: Date, at: Time) -> OffsetDateTime {
622    let berlin = timezones::db::europe::BERLIN;
623    let local = PrimitiveDateTime::new(date, at);
624    match local.assume_timezone(berlin) {
625        OffsetResult::Some(dt) => dt,
626        // A folded (repeated) local time resolves to the earlier instant: the
627        // earlier one is the shorter Frist, and a Frist must never be widened
628        // by an accident of the calendar.
629        OffsetResult::Ambiguous(earlier, _later) => earlier,
630        OffsetResult::None => {
631            // Unreachable for every clock time the rulebook uses. If we land
632            // here the timezone database is corrupt or absent. Panic rather
633            // than silently computing a wrong regulatory deadline.
634            panic!(
635                "CRITICAL: timezone database failure — could not resolve \
636                 {at} Europe/Berlin for date {date}. \
637                 Cannot compute a correct regulatory Frist. \
638                 Ensure tzdata is installed and up to date."
639            );
640        }
641    }
642}
643
644/// Construct an [`OffsetDateTime`] at 12:00 Europe/Berlin on `date`, in UTC.
645#[must_use]
646fn noon_berlin(date: Date) -> OffsetDateTime {
647    berlin_at(date, Time::from_hms(12, 0, 0).expect("12:00:00 is valid"))
648        .to_offset(time::UtcOffset::UTC)
649}
650
651/// The **last moment of `date`** in Europe/Berlin.
652///
653/// The rulebook's „bis zum Ablauf des … Werktags" is a day-granular Frist: it
654/// runs to the end of that calendar day in German local time, not to an
655/// end-of-business hour. Sizing such a Frist at 17:00 expires it seven hours
656/// early and reports a met obligation as a missed one.
657///
658/// „Ablauf des Tages" is the last instant of it, so the Frist carries to the
659/// final nanosecond rather than to 23:59:59.0. The difference is the last
660/// second of every such day, and it falls the way that reports a met obligation
661/// as a breach — the same direction, and the same argument, as the 17:00 case.
662///
663/// # Panics
664///
665/// Panics under the same conditions as [`berlin_at`].
666#[must_use]
667pub fn end_of_day_berlin(date: Date) -> OffsetDateTime {
668    berlin_at(
669        date,
670        Time::from_hms_nano(23, 59, 59, 999_999_999).expect("the last instant of a day is valid"),
671    )
672}
673
674/// „… Uhr des 1. Werktags nach dem ÜT" — the dominant answer-Frist shape in
675/// GPKE Teil 2.
676///
677/// The BNetzA sequence diagrams state every business answer window as a
678/// wall-clock instant on the **first Werktag strictly after the day the message
679/// arrived** (the ÜT), in German local time:
680///
681/// | Process | Trigger | Answerer | `at` |
682/// |---|---|---|---|
683/// | Lieferbeginn | 55001 / 55077 | NB | 11:00 |
684/// | Lieferende von LF an NB | 55004 | NB | 06:00 |
685/// | Lieferende von NB an LF | 55007 | LF | 05:00 |
686/// | Anfrage zur Beendigung der Zuordnung | 55010 | LFA | 09:00 |
687///
688/// This is **not** interchangeable with a flat 24-hour window, and the error
689/// runs in both directions: a message that arrives on a Friday afternoon has
690/// until Monday, while one that arrives on a Tuesday evening has less than
691/// sixteen hours. A 24-hour approximation both raises false "missed deadline"
692/// alarms and, worse, reports a genuinely lapsed Frist as still running.
693///
694/// # Panics
695///
696/// Panics under the same conditions as [`berlin_at`].
697///
698/// # Example
699///
700/// ```rust
701/// use mako_fristen::{self as fristen, HolidayCalendar};
702/// use time::{Date, Month, OffsetDateTime, Time, UtcOffset};
703///
704/// // Friday 2025-01-10 14:00 CET: the next Werktag is Monday the 13th.
705/// let received = OffsetDateTime::new_utc(
706///     Date::from_calendar_date(2025, Month::January, 10).unwrap(),
707///     Time::from_hms(13, 0, 0).unwrap(),
708/// );
709/// let due = fristen::next_werktag_at(
710///     received,
711///     Time::from_hms(11, 0, 0).unwrap(),
712///     HolidayCalendar::BdewMaKo,
713/// );
714/// assert_eq!(due.date(), Date::from_calendar_date(2025, Month::January, 13).unwrap());
715/// // 11:00 CET (UTC+1 in January) = 10:00 UTC.
716/// assert_eq!(due.to_offset(UtcOffset::UTC).hour(), 10);
717/// ```
718#[must_use]
719pub fn next_werktag_at(received: OffsetDateTime, at: Time, cal: HolidayCalendar) -> OffsetDateTime {
720    nth_werktag_at(received, 1, at, cal)
721}
722
723/// „Unverzüglich, jedoch spätester ÜZ ist `at` Uhr des `n`. WT nach dem ÜT."
724///
725/// The generalisation of [`next_werktag_at`]: the deadline falls at `at` on the
726/// `n`-th Werktag **strictly after** the arrival day, in German local time.
727/// `n = 1` is the ordinary GPKE Teil 2 shape; the Neuanlage answer window is
728/// `00:00 Uhr des 61. WT nach dem ÜT` — the same shape with a larger `n`, not a
729/// duration.
730///
731/// `n = 0` is meaningless here (the ÜT itself is not „nach dem ÜT") and is
732/// treated as `1`.
733///
734/// # Panics
735///
736/// Never for any practical date; the internal `next_day` cannot overflow inside
737/// the representable calendar range.
738#[must_use]
739pub fn nth_werktag_at(
740    received: OffsetDateTime,
741    n: u32,
742    at: Time,
743    cal: HolidayCalendar,
744) -> OffsetDateTime {
745    let berlin = timezones::db::europe::BERLIN;
746    let received_date = received.to_timezone(berlin).date();
747    // „nach dem ÜT" — strictly after the arrival day, then forward to the first
748    // Werktag.
749    let day_after = received_date
750        .next_day()
751        .expect("date overflow — unreachable for any practical date");
752    let first = next_werktag(day_after, cal);
753    let target = if n <= 1 {
754        first
755    } else {
756        add_werktage(first, n - 1, cal)
757    };
758    berlin_at(target, at)
759}
760
761/// „unverzüglich, spätestens jedoch bis zum Ablauf des `werktage`. Werktags
762/// nach Eingang" — the GeLi Gas answer-Frist shape.
763///
764/// GeLi Gas 3.0 Kap. 2.6 fixes the counting rule: „Die Frist beginnt gemäß
765/// § 187 Abs. 1 BGB mit Beginn des auf den Meldungseingang folgenden Werktags."
766/// The arrival day is therefore not counted, and the Frist ends at the **end**
767/// of the `werktage`-th Werktag after it.
768///
769/// | Process | Trigger | Answerer | `werktage` |
770/// |---|---|---|---|
771/// | Anmeldung (Lieferbeginn) | 44001 | NB | 4 |
772/// | Abmeldung (Lieferende) | 44004 | NB | 3 |
773///
774/// Distinct from [`deadline_at_werktage`], which places the deadline at 17:00 —
775/// an end-of-business convention that fits the WiM Antwortfristen but expires a
776/// GeLi Gas Frist seven hours before the statute does.
777///
778/// # Panics
779///
780/// Panics under the same conditions as [`berlin_at`].
781#[must_use]
782pub fn end_of_werktag_after(
783    received: OffsetDateTime,
784    werktage: u32,
785    cal: HolidayCalendar,
786) -> OffsetDateTime {
787    let berlin = timezones::db::europe::BERLIN;
788    let received_date = received.to_timezone(berlin).date();
789    end_of_day_berlin(add_werktage(received_date, werktage, cal))
790}
791
792/// Selects which set of public holidays to observe when counting Werktage.
793///
794/// BDEW MaKo processes use a single Germany-wide holiday calendar defined by
795/// BDEW EDI@Energy. This calendar is conservative-inclusive: it treats every
796/// public holiday observed in *any* German state as a non-Werktag, ensuring
797/// no deadline is ever shorter than the AHB requires for any counterparty.
798#[non_exhaustive]
799#[derive(Debug, Clone, Copy, PartialEq, Eq)]
800pub enum HolidayCalendar {
801    /// BDEW-defined Germany-wide holiday calendar for MaKo Werktag calculations.
802    ///
803    /// This is the single calendar used by all BNetzA MaKo processes (GPKE,
804    /// WiM, GeLi Gas, MABIS). BDEW EDI@Energy specifies a conservative-inclusive
805    /// approach: every holiday observed in *any* German state is treated as a
806    /// non-Werktag. This guarantees that no APERAK Frist is ever computed shorter
807    /// than the AHB requires for any market participant in Germany.
808    ///
809    /// Includes the 9 nationwide (*bundesweite*) public holidays **plus** all
810    /// *Landesfeiertage* that are observed in at least one German state:
811    ///
812    /// | Date | Holiday | States |
813    /// |------|---------|--------|
814    /// | 1 Jan | Neujahr | all |
815    /// | 6 Jan | Heilige Drei Könige | BY, BW, ST |
816    /// | 8 Mar | Internationaler Frauentag | BE, MV |
817    /// | 1 May | Tag der Arbeit | all |
818    /// | 20 Sep | Weltkindertag | TH |
819    /// | 3 Oct | Tag der Deutschen Einheit | all |
820    /// | 31 Oct | Reformationstag | BB, HB, HH, MV, NI, SN, ST, SH, TH |
821    /// | 1 Nov | Allerheiligen | BW, BY, NW, RP, SL |
822    /// | Wed before 23 Nov | Buß- und Bettag | SN |
823    /// | 25 Dec | 1. Weihnachtstag | all |
824    /// | 26 Dec | 2. Weihnachtstag | all |
825    /// | Easter−2 | Karfreitag | all |
826    /// | Easter+1 | Ostermontag | all |
827    /// | Easter+39 | Christi Himmelfahrt | all |
828    /// | Easter+49 | Pfingstsonntag | all |
829    /// | Easter+50 | Pfingstmontag | all |
830    /// | Easter+60 | Fronleichnam | BW, BY, HE, NW, RP, SL, SN (parts), TH (parts) |
831    /// | 15 Aug | Mariä Himmelfahrt | BY, SL |
832    ///
833    /// Augsburger Friedensfest (8 Aug) is deliberately absent: it is observed by
834    /// the city of Augsburg, not by a Bundesland, so the BDEW rule does not
835    /// extend it nationwide — and the published Feiertagskalender omits it.
836    ///
837    /// **Rationale**: A counterparty in any of these states is legally entitled
838    /// not to process messages on their regional holiday. Using a maximally
839    /// inclusive calendar ensures no deadline is shorter than the AHB requires
840    /// for any market participant in Germany, at the cost of occasionally
841    /// granting one extra day to counterparties in states where that day is a
842    /// regular Werktag.
843    BdewMaKo,
844}
845
846// ── Wall-clock helpers ────────────────────────────────────────────────────────
847
848/// Add `hours` wall-clock hours to `from`.
849///
850/// Use this for windows the Festlegungen state as wall-clock durations — the
851/// CONTRL 6 h Übertragungsquittung and the 45-minute Strom APERAK. Weekends and
852/// public holidays do **not** extend such a window.
853///
854/// There is **no** 24-hour GPKE Lieferantenwechsel window; see
855/// [`antwort::GPKE_IS_NOT_TWENTY_FOUR_HOURS`].
856///
857/// # Example
858///
859/// ```rust
860/// use mako_fristen as fristen;
861/// use time::OffsetDateTime;
862///
863/// let received = OffsetDateTime::now_utc();
864/// let due = fristen::add_hours(received, 6);   // the CONTRL window
865/// assert_eq!(due - received, time::Duration::hours(6));
866/// ```
867#[must_use]
868pub fn add_hours(from: OffsetDateTime, hours: u32) -> OffsetDateTime {
869    from + Duration::hours(i64::from(hours))
870}
871
872// ── Werktage helpers ──────────────────────────────────────────────────────────
873/// Shift a date `n` calendar months forward, clamping to the month's last day.
874///
875/// Month arithmetic is not day arithmetic and `Date::replace_month` is not
876/// month arithmetic: it keeps the year, so December + 1 lands in January of the
877/// *same* year — earlier than where it started — and it fails outright on a day
878/// the target month does not have. Both show up as silently wrong schedules
879/// rather than errors, so calendar-month steps go through here.
880///
881/// The clamp is § 188 Abs. 3 BGB: where the target month has no such day, the
882/// Frist ends on its last one.
883///
884/// ```
885/// use time::{Date, Month};
886/// use mako_fristen as fristen;
887///
888/// let jan31 = Date::from_calendar_date(2026, Month::January, 31).unwrap();
889/// // February has no 31st — § 188 Abs. 3 BGB clamps to the 28th.
890/// assert_eq!(fristen::add_months(jan31, 1), Date::from_calendar_date(2026, Month::February, 28).unwrap());
891/// // The year carries.
892/// let dec15 = Date::from_calendar_date(2026, Month::December, 15).unwrap();
893/// assert_eq!(fristen::add_months(dec15, 2), Date::from_calendar_date(2027, Month::February, 15).unwrap());
894/// ```
895#[must_use]
896pub fn add_months(date: Date, n: u32) -> Date {
897    shift_months(date, i32::try_from(n).unwrap_or(i32::MAX))
898}
899
900/// Shift a date `n` calendar months back, clamping to the month's last day.
901///
902/// The mirror of [`add_months`]; see it for why `Date::replace_month` is not a
903/// substitute.
904#[must_use]
905pub fn subtract_months(date: Date, n: u32) -> Date {
906    shift_months(date, -i32::try_from(n).unwrap_or(i32::MAX))
907}
908
909/// The shared body of [`add_months`] and [`subtract_months`].
910fn shift_months(date: Date, delta: i32) -> Date {
911    let total = i32::from(u8::from(date.month())) - 1 + delta;
912    let year = date.year() + total.div_euclid(12);
913    let month = time::Month::try_from(u8::try_from(total.rem_euclid(12) + 1).unwrap_or(1))
914        .unwrap_or(time::Month::January);
915    // § 188 Abs. 3 BGB: a day the target month does not have becomes its last.
916    let last = time::util::days_in_month(month, year);
917    Date::from_calendar_date(year, month, date.day().min(last)).unwrap_or(date)
918}
919
920/// Add `n` Werktage (working days) to `from`.
921///
922/// GPKE (BK6-24-174) Teil 1: a Werktag is any day that is not a Saturday, a
923/// Sunday or a public holiday. A holiday observed in any single Bundesland
924/// counts nationwide, and 24.12. and 31.12. count as holidays.
925///
926/// Use this for **WiM / GeLi Gas / MABIS** deadlines.
927///
928/// # Semantics of `n = 0`
929///
930/// Returns `from` unchanged regardless of whether `from` is itself a Werktag.
931/// To find the first Werktag on or after a given date, use
932/// [`next_werktag`] instead.
933///
934/// # Example
935///
936/// ```rust
937/// use mako_fristen::{self as fristen, HolidayCalendar};
938/// use time::{Date, Month};
939///
940/// // Monday + 5 Werktage. Saturday and Sunday are not Werktage:
941/// let start = Date::from_calendar_date(2025, Month::January, 6).unwrap();
942/// let due   = fristen::add_werktage(start, 5, HolidayCalendar::BdewMaKo);
943/// // Tue 07, Wed 08, Thu 09, Fri 10, Mon 13 → 2025-01-13
944/// assert_eq!(due, Date::from_calendar_date(2025, Month::January, 13).unwrap());
945/// ```
946///
947/// # Panics
948///
949/// Panics if date arithmetic overflows the calendar (unreachable for any
950/// realistic date within the Gregorian calendar range).
951#[must_use]
952pub fn add_werktage(from: Date, n: u32, cal: HolidayCalendar) -> Date {
953    let mut current = from;
954    let mut remaining = n;
955    while remaining > 0 {
956        current = current.next_day().expect("date overflow");
957        if is_werktag(current, cal) {
958            remaining -= 1;
959        }
960    }
961    current
962}
963
964/// Subtract `n` Werktage from `from` — the mirror of [`add_werktage`].
965///
966/// This is the arithmetic behind a **Vorlauffrist**: „spätester ÜT ist der
967/// *n*. WT **vor** dem gewünschten Termin" (WiM Strom Teil 1 Kap. 2.3.2 Nr. 1,
968/// 2.4.2 Nr. 1, 3.3.1.2 Nr. 1). A Vorlauffrist is anchored on a date carried
969/// *in the message* rather than on the arrival instant, so it cannot be
970/// expressed by [`add_werktage`] with a negative count — the two run in
971/// opposite directions from different anchors and conflating them silently
972/// accepts an order that is weeks too late.
973///
974/// # Semantics of `n = 0`
975///
976/// Returns `from` unchanged, symmetric with [`add_werktage`].
977///
978/// # Example
979///
980/// ```rust
981/// use mako_fristen::{self as fristen, HolidayCalendar};
982/// use time::{Date, Month};
983///
984/// // Monday − 5 Werktage. Saturday and Sunday are not Werktage, and
985/// // 2025-01-06 (Heilige Drei Könige) is in the BdewMaKo calendar:
986/// let target = Date::from_calendar_date(2025, Month::January, 13).unwrap();
987/// let latest = fristen::sub_werktage(target, 5, HolidayCalendar::BdewMaKo);
988/// // Fri 10, Thu 09, Wed 08, Tue 07, (Mon 06 skipped), Fri 03 → 2025-01-03
989/// assert_eq!(latest, Date::from_calendar_date(2025, Month::January, 3).unwrap());
990/// ```
991///
992/// # Panics
993///
994/// Panics if date arithmetic underflows the calendar (unreachable for any
995/// realistic date within the Gregorian calendar range).
996#[must_use]
997pub fn sub_werktage(from: Date, n: u32, cal: HolidayCalendar) -> Date {
998    let mut current = from;
999    let mut remaining = n;
1000    while remaining > 0 {
1001        current = current.previous_day().expect("date underflow");
1002        if is_werktag(current, cal) {
1003            remaining -= 1;
1004        }
1005    }
1006    current
1007}
1008
1009/// How many Werktage separate `from` and `to` — the inverse of [`add_werktage`].
1010///
1011/// The range is half-open the other way round from the usual convention: `from`
1012/// is not counted, `to` is. That is what makes
1013/// `werktage_between(d, add_werktage(d, n, cal), cal) == n` hold for every `d`
1014/// and `n`, which is the property every caller relies on.
1015///
1016/// Returns `0` when `to <= from`; the caller decides whether a target in the
1017/// past is a violation, because for a Vorlauffrist it always is and for a
1018/// Realisierungskorridor it need not be.
1019///
1020/// # Example
1021///
1022/// ```rust
1023/// use mako_fristen::{self as fristen, HolidayCalendar};
1024/// use time::{Date, Month};
1025///
1026/// let mon = Date::from_calendar_date(2025, Month::January, 6).unwrap();
1027/// let next_mon = Date::from_calendar_date(2025, Month::January, 13).unwrap();
1028/// assert_eq!(fristen::werktage_between(mon, next_mon, HolidayCalendar::BdewMaKo), 5);
1029/// ```
1030///
1031/// # Panics
1032///
1033/// Panics if date arithmetic overflows the calendar (unreachable for any
1034/// realistic date within the Gregorian calendar range).
1035#[must_use]
1036pub fn werktage_between(from: Date, to: Date, cal: HolidayCalendar) -> u32 {
1037    let mut current = from;
1038    let mut count = 0_u32;
1039    while current < to {
1040        current = current.next_day().expect("date overflow");
1041        if is_werktag(current, cal) {
1042            count += 1;
1043        }
1044    }
1045    count
1046}
1047
1048/// Return the first Werktag that is on or after `from`.
1049///
1050/// Unlike `add_werktage(from, 0, cal)` (which always returns `from`
1051/// unchanged), `next_werktag` advances past Sundays and public holidays.
1052///
1053/// # Example
1054///
1055/// ```rust
1056/// use mako_fristen::{self as fristen, HolidayCalendar};
1057/// use time::{Date, Month};
1058///
1059/// // Sunday 2025-01-12 → next Werktag is Monday 2025-01-13 (no holiday).
1060/// // Note: 2025-01-06 (Heilige Drei Könige) is in the fristen federal
1061/// // calendar and must not be used as the expected "next Monday" here.
1062/// let sunday = Date::from_calendar_date(2025, Month::January, 12).unwrap();
1063/// assert_eq!(
1064///     fristen::next_werktag(sunday, HolidayCalendar::BdewMaKo),
1065///     Date::from_calendar_date(2025, Month::January, 13).unwrap(),
1066/// );
1067///
1068/// // Monday 2025-01-13 is already a Werktag → returned unchanged.
1069/// let monday = Date::from_calendar_date(2025, Month::January, 13).unwrap();
1070/// assert_eq!(fristen::next_werktag(monday, HolidayCalendar::BdewMaKo), monday);
1071/// ```
1072///
1073/// # Panics
1074///
1075/// Panics if date arithmetic overflows the calendar (unreachable for any
1076/// realistic date within the Gregorian calendar range).
1077#[must_use]
1078pub fn next_werktag(from: Date, cal: HolidayCalendar) -> Date {
1079    let mut current = from;
1080    while !is_werktag(current, cal) {
1081        current = current.next_day().expect("date overflow");
1082    }
1083    current
1084}
1085
1086/// Compute a deadline `werktage` Werktage after `from`, expressed as an
1087/// [`OffsetDateTime`] at **17:00 Europe/Berlin** on the deadline date.
1088///
1089/// The deadline is computed in German local time (CET in winter, CEST in
1090/// summer). 17:00 CET = 16:00 UTC; 17:00 CEST = 15:00 UTC. Using UTC
1091/// directly would give a systematic 1–2 hour error on every regulatory
1092/// deadline.
1093///
1094/// # The count starts on the day of receipt
1095///
1096/// `from` is the Übertragungstag, and the count runs from its Berlin calendar
1097/// date whatever weekday that is — a message received on a Saturday starts its
1098/// Frist on the Saturday. **GPKE Teil 1 (BK6-24-174 Lesefassung) Kap. 7** defines
1099/// the ÜT as „der Tag des Empfangs der Übertragungsdatei … aus der
1100/// AS4-Zustellquittung" and attaches no rule deeming a non-Werktag arrival
1101/// received on the next Werktag; nor does Allgemeine Festlegungen 6.1d, whose
1102/// Werktag definition covers only which days count. Only the Werktage *counted*
1103/// skip weekends and holidays.
1104///
1105/// The same Kapitel adds the one condition that does apply: the ÜT counts „nur
1106/// …, sofern es sich um eine positive Zustellquittung bzw. Response-Nachricht
1107/// handelt". A negative acknowledgement starts no Frist, which is the caller's
1108/// to enforce — `mako_wim::wertebestellung::Zustellquittung` is the model for
1109/// carrying it.
1110///
1111/// 17:00 is never in a DST transition window for Europe/Berlin (transitions
1112/// happen at 02:00), so the conversion is unambiguous on all dates.
1113///
1114/// # Example
1115///
1116/// ```rust
1117/// use mako_fristen::{self as fristen, HolidayCalendar};
1118/// use time::{Date, Month, OffsetDateTime, Time, UtcOffset};
1119///
1120/// let received = OffsetDateTime::new_utc(
1121///     Date::from_calendar_date(2025, Month::January, 6).unwrap(),
1122///     Time::MIDNIGHT,
1123/// );
1124/// let due = fristen::deadline_at_werktage(received, 5, HolidayCalendar::BdewMaKo);
1125/// assert_eq!(due.date(), Date::from_calendar_date(2025, Month::January, 13).unwrap());
1126/// // January is CET (UTC+1): the deadline is 17:00 local time.
1127/// // Local hour is 17; the UTC equivalent is 16:00.
1128/// assert_eq!(due.hour(), 17);  // local time (CET)
1129/// assert_eq!(due.to_offset(UtcOffset::UTC).hour(), 16); // UTC equivalent
1130/// ```
1131///
1132/// # Panics
1133///
1134/// Panics if date arithmetic overflows the calendar (unreachable for any
1135/// realistic date within the Gregorian calendar range).
1136#[must_use]
1137pub fn deadline_at_werktage(
1138    from: OffsetDateTime,
1139    werktage: u32,
1140    cal: HolidayCalendar,
1141) -> OffsetDateTime {
1142    let berlin = timezones::db::europe::BERLIN;
1143    // Convert to Berlin local time before extracting the calendar date.
1144    // `from.date()` returns the UTC date which is wrong for messages arriving
1145    // between 23:00–00:00 UTC (= 00:00–01:00 CET next day in winter, or
1146    // 00:00–02:00 CEST in summer).  Using the UTC date would count Werktage
1147    // starting from yesterday's calendar date, yielding a deadline that is one
1148    // calendar day — and potentially one Werktag — too early.
1149    let start_date = from.to_timezone(berlin).date();
1150    let due_date = add_werktage(start_date, werktage, cal);
1151    // 17:00 is the end-of-business convention the WiM Antwortfristen are
1152    // administered on. Fristen worded „bis zum Ablauf des n. Werktags" run to
1153    // the end of the day instead — use [`end_of_werktag_after`] for those.
1154    berlin_at(
1155        due_date,
1156        Time::from_hms(17, 0, 0).expect("17:00:00 is valid"),
1157    )
1158}
1159
1160// ── Holiday tables ────────────────────────────────────────────────────────────
1161
1162/// Return `true` when `date` is a non-Werktag public holiday under the
1163/// [`HolidayCalendar::BdewMaKo`] calendar.
1164///
1165/// Covers all 9 *bundesweite* public holidays plus the *Landesfeiertage*
1166/// observed in at least one German state. See [`HolidayCalendar::BdewMaKo`]
1167/// for the complete list and rationale.
1168///
1169/// Easter is computed algorithmically using the Anonymous Gregorian algorithm —
1170/// no pre-computed table, no year ceiling.
1171#[must_use]
1172fn is_bdew_mako_holiday(date: Date) -> bool {
1173    let (y, m, d) = (date.year(), date.month() as u8, date.day());
1174
1175    // Fixed-date holidays. GPKE Teil 1: "Wenn in einem Bundesland ein Tag als
1176    // Feiertag ausgewiesen wird, gilt dieser Tag bundesweit als Feiertag", so
1177    // Landesfeiertage are included. The same passage adds: "Der 24.12. und der
1178    // 31.12. eines jeden Jahres gelten als Feiertage."
1179    if matches!(
1180        (m, d),
1181        (1 | 5 | 11, 1)      // Neujahr, Tag der Arbeit, Allerheiligen
1182            | (1, 6)          // Heilige Drei Könige
1183            | (3, 8)          // Internationaler Frauentag (BE, MV)
1184            | (8, 15)         // Mariä Himmelfahrt
1185            | (9, 20)         // Weltkindertag (TH)
1186            | (10, 3 | 31)    // Tag der Deutschen Einheit, Reformationstag
1187            | (12, 24 | 25 | 26 | 31) // Heiligabend, Weihnachten, Silvester
1188    ) {
1189        return true;
1190    }
1191
1192    // Buß- und Bettag (SN): the last Wednesday before 23 November.
1193    if m == 11 && date == buss_und_bettag(y) {
1194        return true;
1195    }
1196
1197    // Moveable Easter-based holidays — computed algorithmically.
1198    let e_date = easter_sunday(y);
1199
1200    let offsets: &[i64] = &[
1201        -2, // Karfreitag
1202        1,  // Ostermontag
1203        39, // Christi Himmelfahrt
1204        49, // Pfingstsonntag
1205        50, // Pfingstmontag
1206        60, // Fronleichnam (BW, BY, HE, NW, RP, SL, SN/TH parts)
1207    ];
1208
1209    for &offset in offsets {
1210        let holiday = e_date + Duration::days(offset);
1211        if holiday == date {
1212            return true;
1213        }
1214    }
1215
1216    false
1217}
1218
1219/// Compute Buß- und Bettag — the last Wednesday before 23 November.
1220///
1221/// A statutory holiday in Sachsen, and therefore a non-Werktag nationwide under
1222/// the BDEW rule. It always falls on a weekday, so it always moves a Frist.
1223fn buss_und_bettag(year: i32) -> Date {
1224    let mut day = Date::from_calendar_date(year, Month::November, 22)
1225        .expect("22 November is a valid date in every year");
1226    while day.weekday() != Weekday::Wednesday {
1227        day -= Duration::days(1);
1228    }
1229    day
1230}
1231
1232/// Compute Easter Sunday for `year` using the Anonymous Gregorian algorithm.
1233///
1234/// Valid for all years in the proleptic Gregorian calendar. No table, no
1235/// year ceiling.
1236///
1237/// # Example
1238///
1239/// ```rust,ignore
1240/// // Easter 2025: 20 April
1241/// let e = easter_sunday(2025);
1242/// assert_eq!((e.year(), e.month() as u8, e.day()), (2025, 4, 20));
1243/// ```
1244#[expect(clippy::many_single_char_names)]
1245fn easter_sunday(year: i32) -> Date {
1246    let a = year % 19;
1247    let b = year / 100;
1248    let c = year % 100;
1249    let d = b / 4;
1250    let e = b % 4;
1251    let f = (b + 8) / 25;
1252    let g = (b - f + 1) / 3;
1253    let h = (19 * a + b - d - g + 15) % 30;
1254    let i = c / 4;
1255    let k = c % 4;
1256    let l = (32 + 2 * e + 2 * i - h - k) % 7;
1257    let m = (a + 11 * h + 22 * l) / 451;
1258    let month = (h + l - 7 * m + 114) / 31;
1259    let day = (h + l - 7 * m + 114) % 31 + 1;
1260    // The algorithm guarantees month in 3..=4 and day in 1..=31; both casts are safe.
1261    let month_u8 = u8::try_from(month).expect("algorithm yields valid month index");
1262    let day_u8 = u8::try_from(day).expect("algorithm yields valid day");
1263    Date::from_calendar_date(
1264        year,
1265        time::Month::try_from(month_u8).expect("algorithm yields valid month"),
1266        day_u8,
1267    )
1268    .expect("algorithm yields valid date")
1269}
1270
1271/// Return `true` when `date` is a Werktag under `cal`.
1272///
1273/// GPKE (BK6-24-174) Teil 1 defines the term for Fristberechnung:
1274///
1275/// > **Werktag (WT)**: darunter sind alle Tage zu verstehen, die kein Samstag,
1276/// > Sonntag oder gesetzlicher Feiertag sind.
1277///
1278/// **Saturday is not a Werktag** in market communication, unlike the everyday
1279/// German sense of the word and unlike §193 BGB.
1280#[must_use]
1281pub fn is_werktag(date: Date, cal: HolidayCalendar) -> bool {
1282    if matches!(date.weekday(), Weekday::Saturday | Weekday::Sunday) {
1283        return false;
1284    }
1285    match cal {
1286        HolidayCalendar::BdewMaKo => !is_bdew_mako_holiday(date),
1287    }
1288}
1289
1290// ── Tests ─────────────────────────────────────────────────────────────────────
1291
1292#[cfg(test)]
1293mod tests {
1294    use super::*;
1295    use time::{Date, Month, OffsetDateTime, Time};
1296
1297    #[test]
1298    fn berlin_date_is_not_the_utc_date_at_the_turn_of_the_day() {
1299        // 23:30 UTC in winter is already the next day in Berlin.
1300        let winter = time::macros::datetime!(2026-01-31 23:30 UTC);
1301        assert_eq!(winter.date(), time::macros::date!(2026 - 01 - 31));
1302        assert_eq!(berlin_date(winter), time::macros::date!(2026 - 02 - 01));
1303        // In summer the window opens an hour earlier.
1304        let summer = time::macros::datetime!(2026-06-30 22:30 UTC);
1305        assert_eq!(summer.date(), time::macros::date!(2026 - 06 - 30));
1306        assert_eq!(berlin_date(summer), time::macros::date!(2026 - 07 - 01));
1307        // Midday is the same date either way.
1308        let midday = time::macros::datetime!(2026-06-30 10:00 UTC);
1309        assert_eq!(berlin_date(midday), midday.date());
1310    }
1311
1312    #[test]
1313    fn berlin_days_tile_the_timeline_across_both_dst_transitions() {
1314        // Spring forward: 29.03.2026 is 23 hours long.
1315        let spring = time::macros::date!(2026 - 03 - 29);
1316        let start = berlin_midnight(spring);
1317        let end = berlin_midnight(spring.next_day().unwrap());
1318        assert_eq!(end - start, Duration::hours(23));
1319        // Autumn back: 25.10.2026 is 25 hours long.
1320        let autumn = time::macros::date!(2026 - 10 - 25);
1321        let start = berlin_midnight(autumn);
1322        let end = berlin_midnight(autumn.next_day().unwrap());
1323        assert_eq!(end - start, Duration::hours(25));
1324        // A UTC-midnight window would have been 24 hours in both cases, and
1325        // would have started an hour into the German day.
1326        assert_ne!(berlin_midnight(spring), spring.midnight().assume_utc());
1327    }
1328
1329    #[test]
1330    fn heute_agrees_with_berlin_date_of_now() {
1331        assert_eq!(heute(), berlin_date(OffsetDateTime::now_utc()));
1332    }
1333
1334    fn date(y: i32, m: u8, d: u8) -> Date {
1335        Date::from_calendar_date(y, Month::try_from(m).unwrap(), d).unwrap()
1336    }
1337
1338    // ── add_hours ─────────────────────────────────────────────────────────────
1339
1340    #[test]
1341    fn add_hours_advances_exactly() {
1342        let t = OffsetDateTime::now_utc();
1343        assert_eq!(add_hours(t, 24) - t, Duration::hours(24));
1344    }
1345
1346    #[test]
1347    fn add_hours_crosses_midnight() {
1348        let t = OffsetDateTime::now_utc();
1349        let due = add_hours(t, 24);
1350        // 24h later is exactly one day forward (ignoring leap-seconds):
1351        assert_eq!(due.date(), t.date() + Duration::days(1));
1352    }
1353
1354    // ── contrl_due_at ─────────────────────────────────────────────────────────
1355
1356    #[test]
1357    fn contrl_frist_label_is_stable() {
1358        // Changing this label would silently orphan all existing Deadline records.
1359        assert_eq!(CONTRL_FRIST_LABEL, "contrl-delivery-window");
1360    }
1361
1362    #[test]
1363    fn the_contrl_windows_match_the_ahb() {
1364        // A Wednesday, so the Saturday exception does not apply.
1365        let mittwoch = OffsetDateTime::new_utc(
1366            date(2026, 9, 9),
1367            Time::from_hms(9, 0, 0).expect("09:00 is valid"),
1368        );
1369        assert_eq!(
1370            contrl_due_at(mittwoch, ContrlAnlass::Regelfall) - mittwoch,
1371            Duration::hours(CONTRL_FRIST_HOURS),
1372            "§2.3.1 / §2.4.1: 6 Stunden"
1373        );
1374        assert_eq!(
1375            contrl_due_at(mittwoch, ContrlAnlass::GasAlocat) - mittwoch,
1376            Duration::minutes(CONTRL_ALOCAT_MINUTEN),
1377            "§2.3.1: 45 Minuten für die ALOCAT"
1378        );
1379        assert_eq!(
1380            contrl_due_at(mittwoch, ContrlAnlass::StromUtilmdOderOrders) - mittwoch,
1381            Duration::minutes(CONTRL_STROM_SCHNELL_MINUTEN),
1382            "§2.4.1: 15 Minuten für UTILMD/ORDERS"
1383        );
1384    }
1385
1386    /// §2.4.1 states the Saturday exception for UTILMD/ORDERS separately, and it
1387    /// is a **German** Saturday: 23:30 UTC on a Friday is already Saturday in
1388    /// Berlin, so the window is 6 hours rather than 15 minutes.
1389    #[test]
1390    fn the_strom_saturday_exception_is_a_berlin_saturday() {
1391        let samstag = OffsetDateTime::new_utc(
1392            date(2026, 9, 12),
1393            Time::from_hms(9, 0, 0).expect("09:00 is valid"),
1394        );
1395        assert_eq!(samstag.weekday(), Weekday::Saturday);
1396        assert_eq!(
1397            contrl_due_at(samstag, ContrlAnlass::StromUtilmdOderOrders) - samstag,
1398            Duration::hours(CONTRL_FRIST_HOURS)
1399        );
1400
1401        // Friday 23:30 UTC is Saturday 01:30 CEST.
1402        let freitag_spaet = OffsetDateTime::new_utc(
1403            date(2026, 9, 11),
1404            Time::from_hms(23, 30, 0).expect("23:30 is valid"),
1405        );
1406        assert_eq!(
1407            freitag_spaet.weekday(),
1408            Weekday::Friday,
1409            "still Friday in UTC"
1410        );
1411        assert_eq!(
1412            contrl_due_at(freitag_spaet, ContrlAnlass::StromUtilmdOderOrders) - freitag_spaet,
1413            Duration::hours(CONTRL_FRIST_HOURS),
1414            "the AHB dates the exception in gesetzlicher deutscher Zeit"
1415        );
1416    }
1417
1418    /// §2.3.1 / §2.4.1 close by making deviations inside the Formatumstellung
1419    /// window something the counterparty must accept. A CONTRL owed inside it is
1420    /// therefore owed at its end, not fifteen minutes after the interchange —
1421    /// otherwise every Formatumstellung weekend reports Fristverletzungen that
1422    /// cannot be raised.
1423    #[test]
1424    fn the_formatumstellung_window_defers_the_contrl_deadline() {
1425        // 31.03.2026 19:00 Berlin = 17:00 UTC (CEST), inside the window.
1426        let im_fenster = OffsetDateTime::new_utc(
1427            date(2026, 3, 31),
1428            Time::from_hms(17, 0, 0).expect("17:00 is valid"),
1429        );
1430        assert_eq!(
1431            contrl_due_at(im_fenster, ContrlAnlass::StromUtilmdOderOrders),
1432            berlin_midnight(date(2026, 4, 2)),
1433            "the deadline is the end of the window, not 19:15"
1434        );
1435
1436        // An interchange arriving before 18:00 whose 15 minutes run out inside
1437        // the window is the same deviation, so it gets the same end.
1438        let kurz_davor = OffsetDateTime::new_utc(
1439            date(2026, 3, 31),
1440            Time::from_hms(15, 55, 0).expect("15:55 is valid"),
1441        );
1442        assert_eq!(
1443            contrl_due_at(kurz_davor, ContrlAnlass::StromUtilmdOderOrders),
1444            berlin_midnight(date(2026, 4, 2)),
1445            "17:55 Berlin + 15 min falls inside the window"
1446        );
1447
1448        // 30.09 is the other one, and it is a window in both Sparten.
1449        let herbst = OffsetDateTime::new_utc(
1450            date(2026, 9, 30),
1451            Time::from_hms(20, 0, 0).expect("20:00 is valid"),
1452        );
1453        assert_eq!(
1454            contrl_due_at(herbst, ContrlAnlass::GasAlocat),
1455            berlin_midnight(date(2026, 10, 2))
1456        );
1457    }
1458
1459    /// The window is two dates a year and nothing else: an ordinary day keeps
1460    /// the window the AHB gives it.
1461    #[test]
1462    fn the_formatumstellung_window_does_not_leak_into_ordinary_days() {
1463        let gewoehnlich = OffsetDateTime::new_utc(
1464            date(2026, 3, 30),
1465            Time::from_hms(17, 0, 0).expect("17:00 is valid"),
1466        );
1467        assert_eq!(
1468            contrl_due_at(gewoehnlich, ContrlAnlass::StromUtilmdOderOrders) - gewoehnlich,
1469            Duration::minutes(CONTRL_STROM_SCHNELL_MINUTEN)
1470        );
1471        // 02.04. 00:00 Berlin is the exclusive end — the window is over.
1472        let danach = berlin_midnight(date(2026, 4, 2));
1473        assert_eq!(
1474            contrl_due_at(danach, ContrlAnlass::Regelfall) - danach,
1475            Duration::hours(CONTRL_FRIST_HOURS)
1476        );
1477    }
1478
1479    // ── is_bdew_mako_holiday ────────────────────────────────────────────────────
1480
1481    #[test]
1482    fn fixed_holidays_are_detected() {
1483        assert!(is_bdew_mako_holiday(date(2025, 1, 1)), "Neujahr");
1484        assert!(
1485            is_bdew_mako_holiday(date(2025, 1, 6)),
1486            "Heilige Drei Könige"
1487        );
1488        assert!(
1489            is_bdew_mako_holiday(date(2025, 3, 8)),
1490            "Internationaler Frauentag (BE, MV)"
1491        );
1492        assert!(is_bdew_mako_holiday(date(2025, 5, 1)), "Tag der Arbeit");
1493        assert!(is_bdew_mako_holiday(date(2025, 8, 15)), "Mariä Himmelfahrt");
1494        assert!(
1495            is_bdew_mako_holiday(date(2025, 9, 20)),
1496            "Weltkindertag (TH)"
1497        );
1498        assert!(
1499            is_bdew_mako_holiday(date(2025, 10, 3)),
1500            "Tag der Deutschen Einheit"
1501        );
1502        assert!(is_bdew_mako_holiday(date(2025, 10, 31)), "Reformationstag");
1503        assert!(is_bdew_mako_holiday(date(2025, 11, 1)), "Allerheiligen");
1504        assert!(is_bdew_mako_holiday(date(2025, 12, 25)), "1. Weihnachtstag");
1505        assert!(is_bdew_mako_holiday(date(2025, 12, 26)), "2. Weihnachtstag");
1506    }
1507
1508    /// Buß- und Bettag is the last Wednesday before 23 November, so it always
1509    /// falls on a weekday and always moves a Frist. Dates cross-checked against
1510    /// the published BDEW Feiertagskalender GPKE/GeLi Gas.
1511    #[test]
1512    fn buss_und_bettag_is_the_wednesday_before_23_november() {
1513        assert_eq!(buss_und_bettag(2025), date(2025, 11, 19));
1514        assert_eq!(buss_und_bettag(2026), date(2026, 11, 18));
1515        assert_eq!(buss_und_bettag(2027), date(2027, 11, 17));
1516        // 2029: 23 Nov is a Friday, so the Wednesday before is the 21st.
1517        assert_eq!(buss_und_bettag(2029), date(2029, 11, 21));
1518
1519        assert!(is_bdew_mako_holiday(date(2025, 11, 19)));
1520        assert!(!is_bdew_mako_holiday(date(2025, 11, 20)));
1521        assert!(
1522            !is_bdew_mako_holiday(date(2025, 8, 8)),
1523            "Augsburger Friedensfest is city-level, not a Landesfeiertag"
1524        );
1525    }
1526
1527    #[test]
1528    fn easter_2025_moveable_holidays() {
1529        // Easter Sunday 2025-04-20
1530        assert!(is_bdew_mako_holiday(date(2025, 4, 18)), "Karfreitag");
1531        assert!(is_bdew_mako_holiday(date(2025, 4, 21)), "Ostermontag");
1532        assert!(
1533            is_bdew_mako_holiday(date(2025, 5, 29)),
1534            "Christi Himmelfahrt"
1535        );
1536        assert!(is_bdew_mako_holiday(date(2025, 6, 8)), "Pfingstsonntag");
1537        assert!(is_bdew_mako_holiday(date(2025, 6, 9)), "Pfingstmontag");
1538        assert!(is_bdew_mako_holiday(date(2025, 6, 19)), "Fronleichnam");
1539    }
1540
1541    /// Verify the Anonymous Gregorian algorithm is correct beyond the old 2035
1542    /// table ceiling.
1543    #[test]
1544    fn easter_beyond_2035_table_ceiling() {
1545        // 2036: Easter Sunday = 13 April (verified against multiple Easter calculators)
1546        assert_eq!(easter_sunday(2036), date(2036, 4, 13));
1547        assert!(is_bdew_mako_holiday(date(2036, 4, 11)), "Karfreitag 2036"); // -2
1548        assert!(is_bdew_mako_holiday(date(2036, 4, 14)), "Ostermontag 2036"); // +1
1549        assert!(
1550            is_bdew_mako_holiday(date(2036, 5, 22)),
1551            "Christi Himmelfahrt 2036"
1552        ); // +39
1553        assert!(
1554            is_bdew_mako_holiday(date(2036, 6, 1)),
1555            "Pfingstsonntag 2036"
1556        ); // +49
1557        assert!(is_bdew_mako_holiday(date(2036, 6, 2)), "Pfingstmontag 2036"); // +50
1558        assert!(is_bdew_mako_holiday(date(2036, 6, 12)), "Fronleichnam 2036"); // +60
1559
1560        // 2050: Easter Sunday = 10 April
1561        assert_eq!(easter_sunday(2050), date(2050, 4, 10));
1562    }
1563
1564    #[test]
1565    fn saturday_is_not_a_holiday() {
1566        // 2025-01-04 is a Saturday — not a holiday
1567        assert!(!is_bdew_mako_holiday(date(2025, 1, 4)));
1568    }
1569
1570    // ── is_werktag ────────────────────────────────────────────────────────────
1571
1572    #[test]
1573    fn sunday_is_not_werktag() {
1574        assert!(!is_werktag(date(2025, 1, 5), HolidayCalendar::BdewMaKo));
1575    }
1576
1577    #[test]
1578    fn saturday_is_not_a_werktag() {
1579        // GPKE Teil 1: "alle Tage ..., die kein Samstag, Sonntag oder
1580        // gesetzlicher Feiertag sind". 2025-01-04 is a Saturday.
1581        assert!(!is_werktag(date(2025, 1, 4), HolidayCalendar::BdewMaKo));
1582    }
1583
1584    #[test]
1585    fn heiligabend_and_silvester_are_holidays() {
1586        // GPKE Teil 1: "Der 24.12. und der 31.12. eines jeden Jahres gelten als
1587        // Feiertage." 2025-12-24 is a Wednesday, 2025-12-31 a Wednesday.
1588        assert!(!is_werktag(date(2025, 12, 24), HolidayCalendar::BdewMaKo));
1589        assert!(!is_werktag(date(2025, 12, 31), HolidayCalendar::BdewMaKo));
1590    }
1591
1592    #[test]
1593    fn holiday_is_not_werktag() {
1594        assert!(!is_werktag(date(2025, 1, 1), HolidayCalendar::BdewMaKo));
1595    }
1596
1597    #[test]
1598    fn landesfeiertage_are_not_werktage() {
1599        // Heilige Drei Könige
1600        assert!(!is_werktag(date(2025, 1, 6), HolidayCalendar::BdewMaKo));
1601        // Mariä Himmelfahrt
1602        assert!(!is_werktag(date(2025, 8, 15), HolidayCalendar::BdewMaKo));
1603        // Reformationstag 2025 falls on a Friday
1604        assert!(!is_werktag(date(2025, 10, 31), HolidayCalendar::BdewMaKo));
1605        // Allerheiligen
1606        assert!(!is_werktag(date(2025, 11, 1), HolidayCalendar::BdewMaKo));
1607    }
1608
1609    // ── add_werktage ──────────────────────────────────────────────────────────
1610
1611    #[test]
1612    fn five_werktage_plain_week() {
1613        // Monday 2025-01-06 is Heilige Drei Könige, but the count starts from the
1614        // day after: Tue 07 (+1), Wed 08 (+2), Thu 09 (+3), Fri 10 (+4),
1615        // Sat 11 and Sun 12 skipped, Mon 13 (+5).
1616        let start = date(2025, 1, 6);
1617        let due = add_werktage(start, 5, HolidayCalendar::BdewMaKo);
1618        assert_eq!(due, date(2025, 1, 13));
1619    }
1620
1621    #[test]
1622    fn skips_reformationstag_and_allerheiligen() {
1623        // 2025-10-29 is a Wednesday.
1624        // +5 Werktage:
1625        //   Thu 30 (+1), Fri 31 = Reformationstag (skip), Sat 01 Nov = Allerheiligen (skip),
1626        //   Sun 02 (skip), Mon 03 (+2), Tue 04 (+3), Wed 05 (+4), Thu 06 (+5) → 2025-11-06
1627        let start = date(2025, 10, 29);
1628        let due = add_werktage(start, 5, HolidayCalendar::BdewMaKo);
1629        assert_eq!(due, date(2025, 11, 6));
1630    }
1631
1632    #[test]
1633    fn skips_heilige_drei_koenige() {
1634        // Counting starts the day after 2025-01-04 (a Saturday).
1635        // +1 Werktag: Sun 05 (skip), Mon 06 = Heilige Drei Könige (skip),
1636        //              Tue 07 → 2025-01-07
1637        let start = date(2025, 1, 4);
1638        let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
1639        assert_eq!(due, date(2025, 1, 7));
1640    }
1641
1642    #[test]
1643    fn skips_sunday_correctly() {
1644        // Saturday 2025-01-11:  +1 Werktag → skip Sun 12 → Mon 13
1645        // (Using a date that avoids Heilige Drei Könige on 06-Jan)
1646        let start = date(2025, 1, 11);
1647        let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
1648        assert_eq!(due, date(2025, 1, 13));
1649    }
1650
1651    #[test]
1652    fn skips_holiday_and_sunday() {
1653        // 2025-04-17 is Thursday before Easter.
1654        // +1 Werktag: Fri 18 = Karfreitag (skip), Sat 19 / Sun 20 (not Werktage),
1655        // Mon 21 = Ostermontag (skip), Tue 22 (+1).
1656        let start = date(2025, 4, 17);
1657        let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
1658        assert_eq!(due, date(2025, 4, 22));
1659    }
1660
1661    #[test]
1662    fn zero_werktage_returns_start() {
1663        let start = date(2025, 1, 6);
1664        assert_eq!(add_werktage(start, 0, HolidayCalendar::BdewMaKo), start);
1665    }
1666
1667    // ── next_werktag ──────────────────────────────────────────────────────────
1668
1669    #[test]
1670    fn next_werktag_from_sunday_advances_to_monday() {
1671        // Use Jan 12 (Sunday) → Jan 13 (Monday, no holiday).
1672        // Jan 6 is Heilige Drei Könige (included in the fristen federal calendar),
1673        // so that date cannot be used as the expected "next regular Monday".
1674        let sunday = date(2025, 1, 12);
1675        assert_eq!(
1676            next_werktag(sunday, HolidayCalendar::BdewMaKo),
1677            date(2025, 1, 13), // Monday
1678        );
1679    }
1680
1681    #[test]
1682    fn next_werktag_from_werktag_returns_same() {
1683        let monday = date(2025, 1, 13);
1684        assert_eq!(next_werktag(monday, HolidayCalendar::BdewMaKo), monday);
1685    }
1686
1687    #[test]
1688    fn next_werktag_from_holiday_advances_to_next_werktag() {
1689        // Neujahr 2025-01-01 is Wednesday; next Werktag is Thursday 2025-01-02.
1690        assert_eq!(
1691            next_werktag(date(2025, 1, 1), HolidayCalendar::BdewMaKo),
1692            date(2025, 1, 2),
1693        );
1694    }
1695
1696    // ── deadline_at_werktage ──────────────────────────────────────────────────
1697
1698    ///  deadline must be 17:00 CET (16:00 UTC) in winter, not 17:00 UTC.
1699    #[test]
1700    fn deadline_at_werktage_winter_cet() {
1701        // January is CET (UTC+1).  17:00 CET = 16:00 UTC.
1702        let received = OffsetDateTime::new_utc(date(2025, 1, 6), Time::MIDNIGHT);
1703        let due = deadline_at_werktage(received, 5, HolidayCalendar::BdewMaKo);
1704        assert_eq!(due.date(), date(2025, 1, 13));
1705        assert_eq!(
1706            due.to_offset(time::UtcOffset::UTC).hour(),
1707            16,
1708            "winter: 17:00 CET = 16:00 UTC"
1709        );
1710        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
1711    }
1712
1713    ///  deadline must be 17:00 CEST (15:00 UTC) in summer, not 17:00 UTC.
1714    #[test]
1715    fn deadline_at_werktage_summer_cest() {
1716        // July is CEST (UTC+2).  17:00 CEST = 15:00 UTC.
1717        let received = OffsetDateTime::new_utc(date(2025, 7, 1), Time::MIDNIGHT);
1718        let due = deadline_at_werktage(received, 1, HolidayCalendar::BdewMaKo);
1719        assert_eq!(
1720            due.to_offset(time::UtcOffset::UTC).hour(),
1721            15,
1722            "summer: 17:00 CEST = 15:00 UTC"
1723        );
1724        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
1725    }
1726
1727    /// Deadline that lands on the day *after* the spring-forward transition
1728    /// must use CEST (UTC+2), not CET (UTC+1).
1729    ///
1730    /// 2025-03-30 02:00 CET → 03:00 CEST (spring-forward).
1731    /// received = Wednesday 2025-03-26; +4 Werktage:
1732    ///   Thu 27 (+1), Fri 28 (+2), Sat 29 (+3), Sun 30 (skip), Mon 31 (+4).
1733    /// Deadline falls on Monday 2025-03-31 which is CEST: 17:00 CEST = 15:00 UTC.
1734    #[test]
1735    fn deadline_on_day_after_spring_forward_is_cest() {
1736        let received = OffsetDateTime::new_utc(date(2025, 3, 26), Time::MIDNIGHT);
1737        let due = deadline_at_werktage(received, 4, HolidayCalendar::BdewMaKo);
1738        assert_eq!(
1739            due.date(),
1740            date(2025, 4, 1),
1741            "should land on Tuesday 2025-04-01"
1742        );
1743        assert_eq!(
1744            due.to_offset(time::UtcOffset::UTC).hour(),
1745            15,
1746            "CEST: 17:00 local = 15:00 UTC (spring-forward already happened)"
1747        );
1748        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
1749    }
1750
1751    /// Deadline that lands on the day *after* the fall-back transition must
1752    /// use CET (UTC+1), not CEST (UTC+2).
1753    ///
1754    /// 2025-10-26 03:00 CEST → 02:00 CET (fall-back).
1755    /// received = Wednesday 2025-10-22; +4 Werktage:
1756    ///   Thu 23 (+1), Fri 24 (+2), Sat 25 (+3), Sun 26 (skip), Mon 27 (+4).
1757    /// Deadline falls on Monday 2025-10-27 which is CET: 17:00 CET = 16:00 UTC.
1758    #[test]
1759    fn deadline_on_day_after_fall_back_is_cet() {
1760        let received = OffsetDateTime::new_utc(date(2025, 10, 22), Time::MIDNIGHT);
1761        let due = deadline_at_werktage(received, 4, HolidayCalendar::BdewMaKo);
1762        assert_eq!(
1763            due.date(),
1764            date(2025, 10, 28),
1765            "should land on Tuesday 2025-10-28"
1766        );
1767        assert_eq!(
1768            due.to_offset(time::UtcOffset::UTC).hour(),
1769            16,
1770            "CET: 17:00 local = 16:00 UTC (fall-back already happened)"
1771        );
1772        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
1773    }
1774
1775    /// Regression test for the UTC-date edge case (F-005).
1776    ///
1777    /// A message arriving at 23:30 UTC on 2025-01-06 (Monday) is already
1778    /// 00:30 CET on 2025-01-07 (Tuesday) in Berlin local time.  The deadline
1779    /// must be counted from 2025-01-07 (Tuesday), not 2025-01-06 (Monday).
1780    ///
1781    /// Counting from Monday: Tue 07, Wed 08, Thu 09, Fri 10, Sat 11 → 2025-01-11
1782    /// Counting from Tuesday: Wed 08, Thu 09, Fri 10, Sat 11, Mon 13 → 2025-01-13
1783    ///   (2025-01-12 is Sunday; 2025-01-13 is Monday)
1784    #[test]
1785    fn deadline_at_werktage_uses_berlin_date_not_utc_date() {
1786        use time::Time;
1787        // 23:30 UTC on 2025-01-06 (Monday) = 00:30 CET on 2025-01-07 (Tuesday)
1788        let received =
1789            OffsetDateTime::new_utc(date(2025, 1, 6), Time::from_hms(23, 30, 0).unwrap());
1790        let due = deadline_at_werktage(received, 5, HolidayCalendar::BdewMaKo);
1791        // Should start from 2025-01-07 (Tuesday Berlin date), not 2025-01-06
1792        assert_eq!(
1793            due.date(),
1794            date(2025, 1, 14),
1795            "5 WT from Tuesday 2025-01-07: Wed 08 (+1), Thu 09 (+2), Fri 10 (+3), \
1796             Mon 13 (+4), Tue 14 (+5) — Sat 11 and Sun 12 are not Werktage"
1797        );
1798    }
1799
1800    /// Edge case: a message at 23:59 UTC on Friday 2025-01-10 is already
1801    /// Saturday 00:59 CET in Berlin, so the count starts from the Saturday.
1802    /// Saturday and Sunday are both skipped, putting 1 WT on the Monday.
1803    #[test]
1804    fn deadline_at_werktage_friday_night_utc_is_saturday_berlin() {
1805        use time::Time;
1806        // 23:59 UTC on Friday 2025-01-10 = 00:59 CET on Saturday 2025-01-11
1807        let received =
1808            OffsetDateTime::new_utc(date(2025, 1, 10), Time::from_hms(23, 59, 0).unwrap());
1809        let due = deadline_at_werktage(received, 1, HolidayCalendar::BdewMaKo);
1810        // Starting from Saturday 2025-01-11: 1 WT = Monday 2025-01-13 (Sunday skipped)
1811        assert_eq!(
1812            due.date(),
1813            date(2025, 1, 13),
1814            "1 WT from Saturday 2025-01-11 is Monday 2025-01-13 (Sunday not a Werktag)"
1815        );
1816    }
1817
1818    // ── aperak_strom_due_at ───────────────────────────────────────────────────
1819
1820    /// Weekday (Monday): deadline is exactly 45 minutes after receipt.
1821    /// APERAK AHB 1.0 §2.4.1: "an Werktagen (Montag–Freitag): 45 Minuten".
1822    #[test]
1823    fn aperak_strom_weekday_is_45_minutes() {
1824        // Monday 2025-01-06 10:00 UTC (= 11:00 CET)
1825        let received = OffsetDateTime::new_utc(date(2025, 1, 6), Time::from_hms(10, 0, 0).unwrap());
1826        let due = aperak_strom_due_at(received);
1827        assert_eq!(
1828            due - received,
1829            time::Duration::minutes(45),
1830            "weekday: due at received + 45 min"
1831        );
1832    }
1833
1834    /// Friday: deadline is exactly 45 minutes (last workday before weekend).
1835    #[test]
1836    fn aperak_strom_friday_is_45_minutes() {
1837        // Friday 2025-01-10 14:00 UTC (= 15:00 CET)
1838        let received =
1839            OffsetDateTime::new_utc(date(2025, 1, 10), Time::from_hms(14, 0, 0).unwrap());
1840        let due = aperak_strom_due_at(received);
1841        assert_eq!(due - received, time::Duration::minutes(45));
1842    }
1843
1844    /// Saturday: deadline is next Sunday 12:00 Berlin.
1845    /// APERAK AHB 1.0 §2.4.1: "samstags bis spätestens Sonntag 12:00 Uhr".
1846    ///
1847    /// 2025-01-11 (Saturday) in CET → 2025-01-12 12:00 CET = 11:00 UTC.
1848    #[test]
1849    fn aperak_strom_saturday_is_sunday_noon_berlin() {
1850        // Saturday 2025-01-11 08:00 UTC (= 09:00 CET)
1851        let received = OffsetDateTime::new_utc(date(2025, 1, 11), Time::from_hms(8, 0, 0).unwrap());
1852        let due = aperak_strom_due_at(received);
1853        // Sunday 2025-01-12 12:00 CET = 11:00 UTC.
1854        assert_eq!(due.date(), date(2025, 1, 12), "due date must be Sunday");
1855        assert_eq!(
1856            due.to_offset(time::UtcOffset::UTC).hour(),
1857            11,
1858            "12:00 CET (UTC+1 in January) = 11:00 UTC"
1859        );
1860        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
1861    }
1862
1863    /// Saturday in summer (CEST): deadline is Sunday 12:00 CEST = 10:00 UTC.
1864    #[test]
1865    fn aperak_strom_saturday_summer_is_sunday_noon_cest() {
1866        // Saturday 2025-07-05 08:00 UTC (= 10:00 CEST)
1867        let received = OffsetDateTime::new_utc(date(2025, 7, 5), Time::from_hms(8, 0, 0).unwrap());
1868        let due = aperak_strom_due_at(received);
1869        // Sunday 2025-07-06 12:00 CEST = 10:00 UTC.
1870        assert_eq!(due.date(), date(2025, 7, 6), "due date must be Sunday");
1871        assert_eq!(
1872            due.to_offset(time::UtcOffset::UTC).hour(),
1873            10,
1874            "12:00 CEST (UTC+2 in July) = 10:00 UTC"
1875        );
1876        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
1877    }
1878
1879    /// Saturday at 23:50 Berlin time (late): deadline is still Sunday 12:00.
1880    #[test]
1881    fn aperak_strom_saturday_late_night_is_still_sunday_noon() {
1882        // Saturday 2025-01-11 22:50 UTC (= 23:50 CET) — one of the last moments on Saturday
1883        let received =
1884            OffsetDateTime::new_utc(date(2025, 1, 11), Time::from_hms(22, 50, 0).unwrap());
1885        let due = aperak_strom_due_at(received);
1886        // Still Sunday 12:00 CET = 11:00 UTC — not 45 minutes from receipt.
1887        assert_eq!(
1888            due.date(),
1889            date(2025, 1, 12),
1890            "late Saturday → Sunday deadline"
1891        );
1892        assert_eq!(due.to_offset(time::UtcOffset::UTC).hour(), 11);
1893    }
1894
1895    // ── aperak_gas_folgeprozess_due_at ────────────────────────────────────────
1896
1897    #[test]
1898    fn aperak_gas_folgeprozess_weekday_is_next_day_noon_cet() {
1899        // Monday 2025-01-13 10:00 UTC (= 11:00 CET): next Werktag = Tuesday 14.
1900        // Deadline: Tuesday 2025-01-14 12:00 CET = 11:00 UTC.
1901        let received =
1902            OffsetDateTime::new_utc(date(2025, 1, 13), Time::from_hms(10, 0, 0).unwrap());
1903        let due = aperak_gas_folgeprozess_due_at(received);
1904        assert_eq!(
1905            due.to_offset(time::UtcOffset::UTC).date(),
1906            date(2025, 1, 14)
1907        );
1908        assert_eq!(
1909            due.to_offset(time::UtcOffset::UTC).hour(),
1910            11,
1911            "12:00 CET = 11:00 UTC"
1912        );
1913        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
1914    }
1915
1916    #[test]
1917    fn aperak_gas_folgeprozess_friday_skips_weekend_to_monday_noon() {
1918        // Friday 2025-01-17 10:00 UTC. Saturday and Sunday are not Werktage, so
1919        // the next Werktag is Monday 2025-01-20.
1920        let received =
1921            OffsetDateTime::new_utc(date(2025, 1, 17), Time::from_hms(10, 0, 0).unwrap());
1922        let due = aperak_gas_folgeprozess_due_at(received);
1923        assert_eq!(
1924            due.to_offset(time::UtcOffset::UTC).date(),
1925            date(2025, 1, 20),
1926            "next Werktag after Friday is Monday — Saturday is not a Werktag"
1927        );
1928        assert_eq!(
1929            due.to_offset(time::UtcOffset::UTC).hour(),
1930            11,
1931            "12:00 CET = 11:00 UTC"
1932        );
1933    }
1934
1935    #[test]
1936    fn aperak_gas_folgeprozess_saturday_skips_sunday_to_monday() {
1937        // Saturday 2025-01-11: next Werktag after Saturday = next day = Sunday (skip) → Monday 13.
1938        let received =
1939            OffsetDateTime::new_utc(date(2025, 1, 11), Time::from_hms(10, 0, 0).unwrap());
1940        let due = aperak_gas_folgeprozess_due_at(received);
1941        assert_eq!(
1942            due.to_offset(time::UtcOffset::UTC).date(),
1943            date(2025, 1, 13),
1944            "Saturday → Monday 12:00"
1945        );
1946        assert_eq!(
1947            due.to_offset(time::UtcOffset::UTC).hour(),
1948            11,
1949            "12:00 CET = 11:00 UTC"
1950        );
1951    }
1952
1953    #[test]
1954    fn aperak_gas_folgeprozess_summer_cest_noon() {
1955        // Tuesday 2025-07-08 10:00 UTC (= 12:00 CEST): next Werktag = Wednesday 09.
1956        // Deadline: Wednesday 2025-07-09 12:00 CEST = 10:00 UTC.
1957        let received = OffsetDateTime::new_utc(date(2025, 7, 8), Time::from_hms(10, 0, 0).unwrap());
1958        let due = aperak_gas_folgeprozess_due_at(received);
1959        assert_eq!(due.to_offset(time::UtcOffset::UTC).date(), date(2025, 7, 9));
1960        assert_eq!(
1961            due.to_offset(time::UtcOffset::UTC).hour(),
1962            10,
1963            "12:00 CEST = 10:00 UTC"
1964        );
1965        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
1966    }
1967
1968    // Spring-forward: message arrives on Friday 2025-03-28, next Werktag is
1969    // Saturday 2025-03-29 — but 2025-03-30 is spring-forward Sunday (skip).
1970    // The clocks go forward at 02:00 CET on Sunday 30.
1971    // Saturday 2025-03-29 12:00 CET = 11:00 UTC (CET still in effect Saturday).
1972    #[test]
1973    fn aperak_gas_folgeprozess_day_before_spring_forward() {
1974        let received =
1975            OffsetDateTime::new_utc(date(2025, 3, 28), Time::from_hms(10, 0, 0).unwrap());
1976        let due = aperak_gas_folgeprozess_due_at(received);
1977        assert_eq!(
1978            due.to_offset(time::UtcOffset::UTC).date(),
1979            date(2025, 3, 31),
1980            "next Werktag is the Monday after the spring-forward weekend"
1981        );
1982        assert_eq!(
1983            due.to_offset(time::UtcOffset::UTC).hour(),
1984            10,
1985            "the deadline lands after the 2025-03-30 spring-forward, so 12:00 CEST = 10:00 UTC"
1986        );
1987    }
1988
1989    // ── aperak_gas_initialprozess_due_at ──────────────────────────────────────
1990
1991    #[test]
1992    fn aperak_gas_initialprozess_3_werktage_winter_cet() {
1993        // Monday 2025-01-13 10:00 UTC (= 11:00 CET).
1994        // 3 Werktage: Tue 14 (+1), Wed 15 (+2), Thu 16 (+3).
1995        // Deadline: Thursday 2025-01-16 12:00 CET = 11:00 UTC.
1996        let received =
1997            OffsetDateTime::new_utc(date(2025, 1, 13), Time::from_hms(10, 0, 0).unwrap());
1998        let due = aperak_gas_initialprozess_due_at(received);
1999        assert_eq!(
2000            due.to_offset(time::UtcOffset::UTC).date(),
2001            date(2025, 1, 16)
2002        );
2003        assert_eq!(
2004            due.to_offset(time::UtcOffset::UTC).hour(),
2005            11,
2006            "12:00 CET = 11:00 UTC"
2007        );
2008        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
2009    }
2010
2011    #[test]
2012    fn aperak_gas_initialprozess_3_werktage_skips_holiday() {
2013        // Wednesday 2025-04-16 (day before Karfreitag).
2014        // +3 Werktage: Thu 17 (+1), Fri 18 = Karfreitag (skip), Sat 19 / Sun 20
2015        //              (not Werktage), Mon 21 = Ostermontag (skip), Tue 22 (+2),
2016        //              Wed 23 (+3).
2017        // Deadline: Wednesday 2025-04-23 12:00 CEST = 10:00 UTC.
2018        let received =
2019            OffsetDateTime::new_utc(date(2025, 4, 16), Time::from_hms(10, 0, 0).unwrap());
2020        let due = aperak_gas_initialprozess_due_at(received);
2021        assert_eq!(
2022            due.to_offset(time::UtcOffset::UTC).date(),
2023            date(2025, 4, 23)
2024        );
2025        assert_eq!(
2026            due.to_offset(time::UtcOffset::UTC).hour(),
2027            10,
2028            "12:00 CEST = 10:00 UTC"
2029        );
2030    }
2031
2032    #[test]
2033    fn aperak_gas_initialprozess_label_is_stable() {
2034        assert_eq!(
2035            APERAK_GAS_INITIALPROZESS_LABEL,
2036            "aperak-gas-initialprozess-3-werktage"
2037        );
2038    }
2039
2040    #[test]
2041    fn aperak_gas_folgeprozess_label_is_stable() {
2042        assert_eq!(
2043            APERAK_GAS_FOLGEPROZESS_LABEL,
2044            "aperak-gas-folgeprozess-naechster-wt-1200"
2045        );
2046    }
2047
2048    // ── next_werktag_at / end_of_werktag_after ────────────────────────────────
2049
2050    fn utc(y: i32, m: Month, d: u8, h: u8) -> OffsetDateTime {
2051        OffsetDateTime::new_utc(
2052            Date::from_calendar_date(y, m, d).expect("valid date"),
2053            Time::from_hms(h, 0, 0).expect("valid time"),
2054        )
2055    }
2056
2057    fn at(h: u8) -> Time {
2058        Time::from_hms(h, 0, 0).expect("valid time")
2059    }
2060
2061    /// The GPKE answer Frist is anchored on the first Werktag *after* the ÜT,
2062    /// so a Friday arrival is answerable on Monday — not 24 hours later on a
2063    /// Saturday, which is what a flat wall-clock window claims.
2064    #[test]
2065    fn a_friday_arrival_is_due_on_the_following_werktag() {
2066        // Friday 2025-01-10 13:00 UTC (14:00 CET).
2067        let due = next_werktag_at(
2068            utc(2025, Month::January, 10, 13),
2069            at(11),
2070            HolidayCalendar::BdewMaKo,
2071        );
2072        assert_eq!(
2073            due.date(),
2074            Date::from_calendar_date(2025, Month::January, 13).expect("valid date"),
2075            "Monday, not Saturday"
2076        );
2077        assert_eq!(
2078            due.to_offset(time::UtcOffset::UTC).hour(),
2079            10,
2080            "11:00 CET = 10:00 UTC"
2081        );
2082    }
2083
2084    /// The other direction, and the dangerous one: a late-evening arrival has
2085    /// **less** than 24 hours, so a 24-hour window reports a lapsed Frist as
2086    /// still running.
2087    #[test]
2088    fn a_late_evening_arrival_has_less_than_a_day() {
2089        // Monday 2025-01-13 19:00 UTC (20:00 CET).
2090        let received = utc(2025, Month::January, 13, 19);
2091        let due = next_werktag_at(received, at(11), HolidayCalendar::BdewMaKo);
2092        assert_eq!(
2093            due.date(),
2094            Date::from_calendar_date(2025, Month::January, 14).expect("valid date")
2095        );
2096        assert!(
2097            due - received < time::Duration::hours(24),
2098            "a flat 24 h window would overshoot the real Frist by {} h",
2099            (due - received - time::Duration::hours(24)).whole_hours()
2100        );
2101    }
2102
2103    /// A message arriving between 23:00 UTC and midnight is already on the next
2104    /// Berlin day; counting from the UTC date would give a Frist a day early.
2105    #[test]
2106    fn the_arrival_day_is_the_berlin_day() {
2107        // 2025-01-13 23:30 UTC = 2025-01-14 00:30 CET → next Werktag is the 15th.
2108        let received = OffsetDateTime::new_utc(
2109            Date::from_calendar_date(2025, Month::January, 13).expect("valid date"),
2110            Time::from_hms(23, 30, 0).expect("valid time"),
2111        );
2112        let due = next_werktag_at(received, at(6), HolidayCalendar::BdewMaKo);
2113        assert_eq!(
2114            due.date(),
2115            Date::from_calendar_date(2025, Month::January, 15).expect("valid date")
2116        );
2117    }
2118
2119    /// Holidays are skipped: the Werktag after Wednesday 2025-12-24 (a MaKo
2120    /// holiday) and Christmas is Monday 2025-12-29.
2121    #[test]
2122    fn next_werktag_at_skips_holidays() {
2123        let due = next_werktag_at(
2124            utc(2025, Month::December, 23, 10),
2125            at(11),
2126            HolidayCalendar::BdewMaKo,
2127        );
2128        assert_eq!(
2129            due.date(),
2130            Date::from_calendar_date(2025, Month::December, 29).expect("valid date"),
2131            "24.12., 25.12., 26.12. and the weekend are all non-Werktage"
2132        );
2133    }
2134
2135    /// GeLi Gas 3.0 Kap. 2.6: the arrival day is not counted (§ 187 Abs. 1
2136    /// BGB), and the Frist runs to the **end** of the n-th Werktag.
2137    #[test]
2138    fn end_of_werktag_after_counts_from_the_day_after_arrival() {
2139        // Monday 2025-01-13 → Werktage: Tue 14, Wed 15, Thu 16, Fri 17.
2140        let due = end_of_werktag_after(
2141            utc(2025, Month::January, 13, 10),
2142            4,
2143            HolidayCalendar::BdewMaKo,
2144        );
2145        assert_eq!(
2146            due.date(),
2147            Date::from_calendar_date(2025, Month::January, 17).expect("valid date")
2148        );
2149        assert_eq!(due.hour(), 23, "Ablauf des Werktags, not 17:00");
2150    }
2151
2152    /// The end-of-day form must never be shorter than the 17:00 form used for
2153    /// the WiM Antwortfristen — that difference is what made a met GeLi Gas
2154    /// obligation report as missed.
2155    #[test]
2156    fn end_of_werktag_is_later_than_the_1700_convention() {
2157        let received = utc(2025, Month::June, 3, 8);
2158        assert!(
2159            end_of_werktag_after(received, 3, HolidayCalendar::BdewMaKo)
2160                > deadline_at_werktage(received, 3, HolidayCalendar::BdewMaKo)
2161        );
2162    }
2163
2164    /// Summer: 11:00 CEST is 09:00 UTC. Computing the hour in UTC year-round
2165    /// would move the deadline by an hour for half the year.
2166    #[test]
2167    fn the_clock_hour_is_german_local_time_across_dst() {
2168        let winter = next_werktag_at(
2169            utc(2025, Month::January, 13, 8),
2170            at(11),
2171            HolidayCalendar::BdewMaKo,
2172        );
2173        let summer = next_werktag_at(
2174            utc(2025, Month::July, 14, 8),
2175            at(11),
2176            HolidayCalendar::BdewMaKo,
2177        );
2178        assert_eq!(winter.to_offset(time::UtcOffset::UTC).hour(), 10);
2179        assert_eq!(summer.to_offset(time::UtcOffset::UTC).hour(), 9);
2180    }
2181
2182    /// „Ablauf des Tages" runs to the last instant of it. A deadline stopping
2183    /// at 23:59:59.0 reports a message received in the final second of the day
2184    /// as a breach.
2185    #[test]
2186    fn the_end_of_a_day_is_its_last_instant() {
2187        let d = time::macros::date!(2026 - 03 - 17);
2188        let end = end_of_day_berlin(d);
2189        let last_second = berlin_at(d, Time::from_hms(23, 59, 59).unwrap());
2190        assert!(
2191            end > last_second,
2192            "the Frist must outlast 23:59:59.0 itself"
2193        );
2194        let next_midnight = berlin_at(d.next_day().unwrap(), Time::MIDNIGHT);
2195        assert!(end < next_midnight, "and must not reach the next day");
2196        assert_eq!(next_midnight - end, Duration::nanoseconds(1));
2197    }
2198}