Skip to main content

mako_engine/
fristen.rs

1//! Regulatory deadline calculation helpers.
2//!
3//! Two fundamentally different deadline semantics apply in BNetzA MaKo
4//! processes, and they **must not be mixed up**:
5//!
6//! | Process family | Deadline unit | Reason |
7//! |---|---|---|
8//! | **GPKE Lieferantenwechsel** (BK6-22-024) | 24 wall-clock hours | BNetzA decision; no Werktag exemption |
9//! | **WiM / GeLi Gas / MABIS** | Werktage (working days) | BDEW AHB Fristenregeln |
10//!
11//! ## GPKE 24h Lieferantenwechsel
12//!
13//! After receiving a UTILMD Lieferbeginn request, the network operator **must**
14//! dispatch the APERAK acknowledgement within **24 consecutive wall-clock
15//! hours** (BNetzA decision BK6-22-024). Weekends and public holidays do
16//! **not** extend this window.
17//!
18//! ```rust
19//! use mako_engine::fristen;
20//! use time::OffsetDateTime;
21//!
22//! let received = OffsetDateTime::now_utc();
23//! let due = fristen::add_hours(received, 24);
24//! assert!(due > received);
25//! ```
26//!
27//! ## WiM / GeLi Gas / MABIS Werktage
28//!
29//! ```rust
30//! use mako_engine::fristen::{self, HolidayCalendar};
31//! use time::{Date, Month};
32//!
33//! // 5 Werktage after Monday 2025-01-06 (federal only):
34//! let start = Date::from_calendar_date(2025, Month::January, 6).unwrap();
35//! let due   = fristen::add_werktage(start, 5, HolidayCalendar::BdewMaKo);
36//! // Tue 07, Wed 08, Thu 09, Fri 10, Sat 11 → 2025-01-11
37//! // (Saturday counts as Werktag in German energy regulation)
38//! assert_eq!(due, Date::from_calendar_date(2025, Month::January, 11).unwrap());
39//! ```
40//!
41//! ## Holiday calendar: BDEW-defined Germany-wide calendar
42//!
43//! [`HolidayCalendar::BdewMaKo`] is the single holiday calendar used in all
44//! BNetzA MaKo processes. BDEW EDI@Energy specifies a conservative-inclusive
45//! approach: every public holiday observed in *any* German state is treated as
46//! a non-Werktag. This guarantees no APERAK Frist is ever shorter than the AHB
47//! requires. Per-state calendars are **not** used in BDEW MaKo — there is one
48//! Germany-wide calendar that all market participants use.
49//!
50//! ## CONTRL 6h Übertragungsquittung
51//!
52//! CONTRL AHB 1.0 §1.2 mandates that the recipient confirms syntactic validity
53//! of a received EDIFACT interchange **within 6 wall-clock hours** of receipt.
54//! This obligation applies at the transport layer (before any workflow
55//! processing) and is independent of the process-level APERAK fristen.
56//!
57//! ```rust
58//! use mako_engine::fristen;
59//! use time::OffsetDateTime;
60//!
61//! let received = OffsetDateTime::now_utc();
62//! let due = fristen::contrl_due_at(received);
63//! assert_eq!(due - received, time::Duration::hours(6));
64//! ```
65
66use time::{Date, Duration, OffsetDateTime, PrimitiveDateTime, Time, Weekday};
67use time_tz::{OffsetDateTimeExt, OffsetResult, PrimitiveDateTimeExt, timezones};
68
69// ── CONTRL Übertragungsquittung ───────────────────────────────────────────────
70
71/// Maximum wall-clock hours within which a CONTRL must be sent after receiving
72/// an EDIFACT interchange.
73///
74/// Per CONTRL AHB 1.0 §1.2: "Der Empfänger teilt dem Absender **unverzüglich,
75/// jedoch spätestens 6 Stunden** nach Erhalt der Übertragungsdatei das
76/// Ergebnis seiner syntaktischen Prüfung mittels CONTRL mit."
77pub const CONTRL_FRIST_HOURS: i64 = 6;
78
79/// Deadline label used in the `DeadlineStore` for CONTRL delivery obligations.
80///
81/// Register a `Deadline` with this label when enqueueing a CONTRL `PendingOutbox`
82/// entry. The outbox worker clears the deadline after successful CONTRL delivery.
83/// If the deadline fires before the CONTRL is delivered, the 6h Frist has been
84/// violated (CONTRL AHB 1.0 §1.2).
85pub const CONTRL_FRIST_LABEL: &str = "contrl-delivery";
86
87/// Compute the CONTRL delivery deadline as 6 wall-clock hours after `received`.
88///
89/// # Example
90///
91/// ```rust
92/// use mako_engine::fristen;
93/// use time::OffsetDateTime;
94///
95/// let received = OffsetDateTime::now_utc();
96/// let due = fristen::contrl_due_at(received);
97/// assert_eq!(due - received, time::Duration::hours(6));
98/// ```
99#[must_use]
100pub fn contrl_due_at(received: OffsetDateTime) -> OffsetDateTime {
101    received + Duration::hours(CONTRL_FRIST_HOURS)
102}
103
104/// Selects which set of public holidays to observe when counting Werktage.
105///
106/// BDEW MaKo processes use a single Germany-wide holiday calendar defined by
107/// BDEW EDI@Energy. This calendar is conservative-inclusive: it treats every
108/// public holiday observed in *any* German state as a non-Werktag, ensuring
109/// no deadline is ever shorter than the AHB requires for any counterparty.
110#[non_exhaustive]
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum HolidayCalendar {
113    /// BDEW-defined Germany-wide holiday calendar for MaKo Werktag calculations.
114    ///
115    /// This is the single calendar used by all BNetzA MaKo processes (GPKE,
116    /// WiM, GeLi Gas, MABIS). BDEW EDI@Energy specifies a conservative-inclusive
117    /// approach: every holiday observed in *any* German state is treated as a
118    /// non-Werktag. This guarantees that no APERAK Frist is ever computed shorter
119    /// than the AHB requires for any market participant in Germany.
120    ///
121    /// Includes the 9 nationwide (*bundesweite*) public holidays **plus** all
122    /// *Landesfeiertage* that are observed in at least one German state:
123    ///
124    /// | Date | Holiday | States |
125    /// |------|---------|--------|
126    /// | 1 Jan | Neujahr | all |
127    /// | 6 Jan | Heilige Drei Könige | BY, BW, ST |
128    /// | 1 May | Tag der Arbeit | all |
129    /// | 3 Oct | Tag der Deutschen Einheit | all |
130    /// | 31 Oct | Reformationstag | BB, HB, HH, MV, NI, SN, ST, SH, TH |
131    /// | 1 Nov | Allerheiligen | BW, BY, NW, RP, SL |
132    /// | 25 Dec | 1. Weihnachtstag | all |
133    /// | 26 Dec | 2. Weihnachtstag | all |
134    /// | Easter−2 | Karfreitag | all |
135    /// | Easter+1 | Ostermontag | all |
136    /// | Easter+39 | Christi Himmelfahrt | all |
137    /// | Easter+49 | Pfingstsonntag | all |
138    /// | Easter+50 | Pfingstmontag | all |
139    /// | Easter+60 | Fronleichnam | BW, BY, HE, NW, RP, SL, SN (parts), TH (parts) |
140    /// | 15 Aug | Mariä Himmelfahrt | BY, SL |
141    ///
142    /// **Rationale**: A counterparty in any of these states is legally entitled
143    /// not to process messages on their regional holiday. Using a maximally
144    /// inclusive calendar ensures no deadline is shorter than the AHB requires
145    /// for any market participant in Germany, at the cost of occasionally
146    /// granting one extra day to counterparties in states where that day is a
147    /// regular Werktag.
148    BdewMaKo,
149}
150
151// ── Wall-clock helpers ────────────────────────────────────────────────────────
152
153/// Add `hours` wall-clock hours to `from`.
154///
155/// Use this for the **GPKE 24h Lieferantenwechsel** window (BK6-22-024).
156/// Weekends and public holidays do **not** extend the window.
157///
158/// # Example
159///
160/// ```rust
161/// use mako_engine::fristen;
162/// use time::OffsetDateTime;
163///
164/// let received = OffsetDateTime::now_utc();
165/// let due = fristen::add_hours(received, 24);
166/// assert_eq!(due - received, time::Duration::hours(24));
167/// ```
168#[must_use]
169pub fn add_hours(from: OffsetDateTime, hours: u32) -> OffsetDateTime {
170    from + Duration::hours(i64::from(hours))
171}
172
173// ── Werktage helpers ──────────────────────────────────────────────────────────
174
175/// Add `n` Werktage (working days) to `from`.
176///
177/// A Werktag is any day that is neither a Sunday nor a public holiday in the
178/// given `cal`. **Saturdays count as Werktage** in German energy regulation
179/// (BDEW AHB).
180///
181/// Use this for **WiM / GeLi Gas / MABIS** deadlines.
182///
183/// # Semantics of `n = 0`
184///
185/// Returns `from` unchanged regardless of whether `from` is itself a Werktag.
186/// To find the first Werktag on or after a given date, use
187/// [`next_werktag`] instead.
188///
189/// # Example
190///
191/// ```rust
192/// use mako_engine::fristen::{self, HolidayCalendar};
193/// use time::{Date, Month};
194///
195/// // Monday + 5 Werktage (no holidays in this week):
196/// let start = Date::from_calendar_date(2025, Month::January, 6).unwrap();
197/// let due   = fristen::add_werktage(start, 5, HolidayCalendar::BdewMaKo);
198/// // Tue 07, Wed 08, Thu 09, Fri 10, Sat 11 → 2025-01-11
199/// assert_eq!(due, Date::from_calendar_date(2025, Month::January, 11).unwrap());
200/// ```
201///
202/// # Panics
203///
204/// Panics if date arithmetic overflows the calendar (unreachable for any
205/// realistic date within the Gregorian calendar range).
206#[must_use]
207pub fn add_werktage(from: Date, n: u32, cal: HolidayCalendar) -> Date {
208    let mut current = from;
209    let mut remaining = n;
210    while remaining > 0 {
211        current = current.next_day().expect("date overflow");
212        if is_werktag(current, cal) {
213            remaining -= 1;
214        }
215    }
216    current
217}
218
219/// Return the first Werktag that is on or after `from`.
220///
221/// Unlike `add_werktage(from, 0, cal)` (which always returns `from`
222/// unchanged), `next_werktag` advances past Sundays and public holidays.
223///
224/// # Example
225///
226/// ```rust
227/// use mako_engine::fristen::{self, HolidayCalendar};
228/// use time::{Date, Month};
229///
230/// // Sunday 2025-01-12 → next Werktag is Monday 2025-01-13 (no holiday).
231/// // Note: 2025-01-06 (Heilige Drei Könige) is in the fristen federal
232/// // calendar and must not be used as the expected "next Monday" here.
233/// let sunday = Date::from_calendar_date(2025, Month::January, 12).unwrap();
234/// assert_eq!(
235///     fristen::next_werktag(sunday, HolidayCalendar::BdewMaKo),
236///     Date::from_calendar_date(2025, Month::January, 13).unwrap(),
237/// );
238///
239/// // Monday 2025-01-13 is already a Werktag → returned unchanged.
240/// let monday = Date::from_calendar_date(2025, Month::January, 13).unwrap();
241/// assert_eq!(fristen::next_werktag(monday, HolidayCalendar::BdewMaKo), monday);
242/// ```
243///
244/// # Panics
245///
246/// Panics if date arithmetic overflows the calendar (unreachable for any
247/// realistic date within the Gregorian calendar range).
248#[must_use]
249pub fn next_werktag(from: Date, cal: HolidayCalendar) -> Date {
250    let mut current = from;
251    while !is_werktag(current, cal) {
252        current = current.next_day().expect("date overflow");
253    }
254    current
255}
256
257/// Compute a deadline `werktage` Werktage after `from`, expressed as an
258/// [`OffsetDateTime`] at **17:00 Europe/Berlin** on the deadline date.
259///
260/// The deadline is computed in German local time (CET in winter, CEST in
261/// summer). 17:00 CET = 16:00 UTC; 17:00 CEST = 15:00 UTC. Using UTC
262/// directly would give a systematic 1–2 hour error on every regulatory
263/// deadline.
264///
265/// 17:00 is never in a DST transition window for Europe/Berlin (transitions
266/// happen at 02:00), so the conversion is unambiguous on all dates.
267///
268/// # Example
269///
270/// ```rust
271/// use mako_engine::fristen::{self, HolidayCalendar};
272/// use time::{Date, Month, OffsetDateTime, Time, UtcOffset};
273///
274/// let received = OffsetDateTime::new_utc(
275///     Date::from_calendar_date(2025, Month::January, 6).unwrap(),
276///     Time::MIDNIGHT,
277/// );
278/// let due = fristen::deadline_at_werktage(received, 5, HolidayCalendar::BdewMaKo);
279/// assert_eq!(due.date(), Date::from_calendar_date(2025, Month::January, 11).unwrap());
280/// // January is CET (UTC+1): the deadline is 17:00 local time.
281/// // Local hour is 17; the UTC equivalent is 16:00.
282/// assert_eq!(due.hour(), 17);  // local time (CET)
283/// assert_eq!(due.to_offset(UtcOffset::UTC).hour(), 16); // UTC equivalent
284/// ```
285///
286/// # Panics
287///
288/// Panics if date arithmetic overflows the calendar (unreachable for any
289/// realistic date within the Gregorian calendar range).
290#[must_use]
291pub fn deadline_at_werktage(
292    from: OffsetDateTime,
293    werktage: u32,
294    cal: HolidayCalendar,
295) -> OffsetDateTime {
296    let berlin = timezones::db::europe::BERLIN;
297    // Convert to Berlin local time before extracting the calendar date.
298    // `from.date()` returns the UTC date which is wrong for messages arriving
299    // between 23:00–00:00 UTC (= 00:00–01:00 CET next day in winter, or
300    // 00:00–02:00 CEST in summer).  Using the UTC date would count Werktage
301    // starting from yesterday's calendar date, yielding a deadline that is one
302    // calendar day — and potentially one Werktag — too early.
303    let start_date = from.to_timezone(berlin).date();
304    let due_date = add_werktage(start_date, werktage, cal);
305    // Construct 17:00 as a PrimitiveDateTime in local (Europe/Berlin) time, then
306    // obtain the correct UTC offset for that moment.  17:00 is never inside a
307    // DST gap or fold for Europe/Berlin, so assume_timezone always returns Some.
308    let local_17 = PrimitiveDateTime::new(
309        due_date,
310        Time::from_hms(17, 0, 0).expect("17:00:00 is valid"),
311    );
312    match local_17.assume_timezone(berlin) {
313        OffsetResult::Some(dt) => dt,
314        // 17:00 is unambiguous for Europe/Berlin; these branches are unreachable
315        // in practice but handled gracefully rather than panicking.
316        OffsetResult::Ambiguous(earlier, _later) => earlier,
317        OffsetResult::None => {
318            // Fallback: compute as CET (UTC+1) if the timezone db is broken.
319            local_17.assume_offset(time::UtcOffset::from_hms(1, 0, 0).unwrap())
320        }
321    }
322}
323
324// ── Holiday tables ────────────────────────────────────────────────────────────
325
326/// Return `true` when `date` is a non-Werktag public holiday under the
327/// [`HolidayCalendar::BdewMaKo`] calendar.
328///
329/// Covers all 9 *bundesweite* public holidays plus the *Landesfeiertage*
330/// observed in at least one German state. See [`HolidayCalendar::BdewMaKo`]
331/// for the complete list and rationale.
332///
333/// Easter is computed algorithmically using the Anonymous Gregorian algorithm —
334/// no pre-computed table, no year ceiling.
335#[must_use]
336fn is_bdew_mako_holiday(date: Date) -> bool {
337    let (y, m, d) = (date.year(), date.month() as u8, date.day());
338
339    // Fixed-date holidays (bundesweit + Landesfeiertage):
340    if matches!(
341        (m, d),
342        (1 | 5 | 11, 1) | (1, 6) | (8, 15) | (10, 3 | 31) | (12, 25 | 26) // 2. Weihnachtstag
343    ) {
344        return true;
345    }
346
347    // Moveable Easter-based holidays — computed algorithmically.
348    let e_date = easter_sunday(y);
349
350    let offsets: &[i64] = &[
351        -2, // Karfreitag
352        1,  // Ostermontag
353        39, // Christi Himmelfahrt
354        49, // Pfingstsonntag
355        50, // Pfingstmontag
356        60, // Fronleichnam (BW, BY, HE, NW, RP, SL, SN/TH parts)
357    ];
358
359    for &offset in offsets {
360        let holiday = e_date + Duration::days(offset);
361        if holiday == date {
362            return true;
363        }
364    }
365
366    false
367}
368
369/// Compute Easter Sunday for `year` using the Anonymous Gregorian algorithm.
370///
371/// Valid for all years in the proleptic Gregorian calendar. No table, no
372/// year ceiling.
373///
374/// # Example
375///
376/// ```rust,ignore
377/// // Easter 2025: 20 April
378/// let e = easter_sunday(2025);
379/// assert_eq!((e.year(), e.month() as u8, e.day()), (2025, 4, 20));
380/// ```
381#[allow(clippy::many_single_char_names)]
382fn easter_sunday(year: i32) -> Date {
383    let a = year % 19;
384    let b = year / 100;
385    let c = year % 100;
386    let d = b / 4;
387    let e = b % 4;
388    let f = (b + 8) / 25;
389    let g = (b - f + 1) / 3;
390    let h = (19 * a + b - d - g + 15) % 30;
391    let i = c / 4;
392    let k = c % 4;
393    let l = (32 + 2 * e + 2 * i - h - k) % 7;
394    let m = (a + 11 * h + 22 * l) / 451;
395    let month = (h + l - 7 * m + 114) / 31;
396    let day = (h + l - 7 * m + 114) % 31 + 1;
397    // The algorithm guarantees month in 3..=4 and day in 1..=31; both casts are safe.
398    let month_u8 = u8::try_from(month).expect("algorithm yields valid month index");
399    let day_u8 = u8::try_from(day).expect("algorithm yields valid day");
400    Date::from_calendar_date(
401        year,
402        time::Month::try_from(month_u8).expect("algorithm yields valid month"),
403        day_u8,
404    )
405    .expect("algorithm yields valid date")
406}
407
408/// Return `true` when `date` is a Werktag under `cal`.
409///
410/// In German energy regulation (BDEW AHB), Sundays and public holidays are
411/// **not** Werktage. Saturdays **are** Werktage.
412fn is_werktag(date: Date, cal: HolidayCalendar) -> bool {
413    if date.weekday() == Weekday::Sunday {
414        return false;
415    }
416    match cal {
417        HolidayCalendar::BdewMaKo => !is_bdew_mako_holiday(date),
418    }
419}
420
421// ── Tests ─────────────────────────────────────────────────────────────────────
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use time::{Date, Month, OffsetDateTime, Time};
427
428    fn date(y: i32, m: u8, d: u8) -> Date {
429        Date::from_calendar_date(y, Month::try_from(m).unwrap(), d).unwrap()
430    }
431
432    // ── add_hours ─────────────────────────────────────────────────────────────
433
434    #[test]
435    fn add_hours_advances_exactly() {
436        let t = OffsetDateTime::now_utc();
437        assert_eq!(add_hours(t, 24) - t, Duration::hours(24));
438    }
439
440    #[test]
441    fn add_hours_crosses_midnight() {
442        let t = OffsetDateTime::now_utc();
443        let due = add_hours(t, 24);
444        // 24h later is exactly one day forward (ignoring leap-seconds):
445        assert_eq!(due.date(), t.date() + Duration::days(1));
446    }
447
448    // ── contrl_due_at ─────────────────────────────────────────────────────────
449
450    #[test]
451    fn contrl_due_at_is_exactly_6h_after_received() {
452        let received = OffsetDateTime::now_utc();
453        let due = contrl_due_at(received);
454        assert_eq!(
455            due - received,
456            Duration::hours(6),
457            "CONTRL AHB 1.0 §1.2 requires exactly 6h frist"
458        );
459    }
460
461    #[test]
462    fn contrl_frist_label_is_stable() {
463        // Changing this label would silently orphan all existing Deadline records.
464        assert_eq!(CONTRL_FRIST_LABEL, "contrl-delivery");
465    }
466
467    #[test]
468    fn contrl_frist_hours_matches_constant() {
469        let received = OffsetDateTime::now_utc();
470        assert_eq!(
471            contrl_due_at(received) - received,
472            Duration::hours(CONTRL_FRIST_HOURS)
473        );
474    }
475
476    // ── is_bdew_mako_holiday ────────────────────────────────────────────────────
477
478    #[test]
479    fn fixed_holidays_are_detected() {
480        assert!(is_bdew_mako_holiday(date(2025, 1, 1)), "Neujahr");
481        assert!(
482            is_bdew_mako_holiday(date(2025, 1, 6)),
483            "Heilige Drei Könige"
484        );
485        assert!(is_bdew_mako_holiday(date(2025, 5, 1)), "Tag der Arbeit");
486        assert!(is_bdew_mako_holiday(date(2025, 8, 15)), "Mariä Himmelfahrt");
487        assert!(
488            is_bdew_mako_holiday(date(2025, 10, 3)),
489            "Tag der Deutschen Einheit"
490        );
491        assert!(is_bdew_mako_holiday(date(2025, 10, 31)), "Reformationstag");
492        assert!(is_bdew_mako_holiday(date(2025, 11, 1)), "Allerheiligen");
493        assert!(is_bdew_mako_holiday(date(2025, 12, 25)), "1. Weihnachtstag");
494        assert!(is_bdew_mako_holiday(date(2025, 12, 26)), "2. Weihnachtstag");
495    }
496
497    #[test]
498    fn easter_2025_moveable_holidays() {
499        // Easter Sunday 2025-04-20
500        assert!(is_bdew_mako_holiday(date(2025, 4, 18)), "Karfreitag");
501        assert!(is_bdew_mako_holiday(date(2025, 4, 21)), "Ostermontag");
502        assert!(
503            is_bdew_mako_holiday(date(2025, 5, 29)),
504            "Christi Himmelfahrt"
505        );
506        assert!(is_bdew_mako_holiday(date(2025, 6, 8)), "Pfingstsonntag");
507        assert!(is_bdew_mako_holiday(date(2025, 6, 9)), "Pfingstmontag");
508        assert!(is_bdew_mako_holiday(date(2025, 6, 19)), "Fronleichnam");
509    }
510
511    /// Verify the Anonymous Gregorian algorithm is correct beyond the old 2035
512    /// table ceiling.
513    #[test]
514    fn easter_beyond_2035_table_ceiling() {
515        // 2036: Easter Sunday = 13 April (verified against multiple Easter calculators)
516        assert_eq!(easter_sunday(2036), date(2036, 4, 13));
517        assert!(is_bdew_mako_holiday(date(2036, 4, 11)), "Karfreitag 2036"); // -2
518        assert!(is_bdew_mako_holiday(date(2036, 4, 14)), "Ostermontag 2036"); // +1
519        assert!(
520            is_bdew_mako_holiday(date(2036, 5, 22)),
521            "Christi Himmelfahrt 2036"
522        ); // +39
523        assert!(
524            is_bdew_mako_holiday(date(2036, 6, 1)),
525            "Pfingstsonntag 2036"
526        ); // +49
527        assert!(is_bdew_mako_holiday(date(2036, 6, 2)), "Pfingstmontag 2036"); // +50
528        assert!(is_bdew_mako_holiday(date(2036, 6, 12)), "Fronleichnam 2036"); // +60
529
530        // 2050: Easter Sunday = 10 April
531        assert_eq!(easter_sunday(2050), date(2050, 4, 10));
532    }
533
534    #[test]
535    fn saturday_is_not_a_holiday() {
536        // 2025-01-04 is a Saturday — not a holiday
537        assert!(!is_bdew_mako_holiday(date(2025, 1, 4)));
538    }
539
540    // ── is_werktag ────────────────────────────────────────────────────────────
541
542    #[test]
543    fn sunday_is_not_werktag() {
544        assert!(!is_werktag(date(2025, 1, 5), HolidayCalendar::BdewMaKo));
545    }
546
547    #[test]
548    fn saturday_is_werktag() {
549        assert!(is_werktag(date(2025, 1, 4), HolidayCalendar::BdewMaKo));
550    }
551
552    #[test]
553    fn holiday_is_not_werktag() {
554        assert!(!is_werktag(date(2025, 1, 1), HolidayCalendar::BdewMaKo));
555    }
556
557    #[test]
558    fn landesfeiertage_are_not_werktage() {
559        // Heilige Drei Könige
560        assert!(!is_werktag(date(2025, 1, 6), HolidayCalendar::BdewMaKo));
561        // Mariä Himmelfahrt
562        assert!(!is_werktag(date(2025, 8, 15), HolidayCalendar::BdewMaKo));
563        // Reformationstag 2025 falls on a Friday
564        assert!(!is_werktag(date(2025, 10, 31), HolidayCalendar::BdewMaKo));
565        // Allerheiligen
566        assert!(!is_werktag(date(2025, 11, 1), HolidayCalendar::BdewMaKo));
567    }
568
569    // ── add_werktage ──────────────────────────────────────────────────────────
570
571    #[test]
572    fn five_werktage_plain_week() {
573        // Monday 2025-01-06, no holidays.
574        // Tue 07, Wed 08, Thu 09, Fri 10, Sat 11 → 2025-01-11
575        // (Saturday counts as Werktag in German energy regulation)
576        let start = date(2025, 1, 6);
577        let due = add_werktage(start, 5, HolidayCalendar::BdewMaKo);
578        assert_eq!(due, date(2025, 1, 11));
579    }
580
581    #[test]
582    fn skips_reformationstag_and_allerheiligen() {
583        // 2025-10-29 is a Wednesday.
584        // +5 Werktage:
585        //   Thu 30 (+1), Fri 31 = Reformationstag (skip), Sat 01 Nov = Allerheiligen (skip),
586        //   Sun 02 (skip), Mon 03 (+2), Tue 04 (+3), Wed 05 (+4), Thu 06 (+5) → 2025-11-06
587        let start = date(2025, 10, 29);
588        let due = add_werktage(start, 5, HolidayCalendar::BdewMaKo);
589        assert_eq!(due, date(2025, 11, 6));
590    }
591
592    #[test]
593    fn skips_heilige_drei_koenige() {
594        // 2025-01-04 is a Saturday (Werktag).
595        // +1 Werktag: Sun 05 (skip), Mon 06 = Heilige Drei Könige (skip),
596        //              Tue 07 → 2025-01-07
597        let start = date(2025, 1, 4);
598        let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
599        assert_eq!(due, date(2025, 1, 7));
600    }
601
602    #[test]
603    fn skips_sunday_correctly() {
604        // Saturday 2025-01-11:  +1 Werktag → skip Sun 12 → Mon 13
605        // (Using a date that avoids Heilige Drei Könige on 06-Jan)
606        let start = date(2025, 1, 11);
607        let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
608        assert_eq!(due, date(2025, 1, 13));
609    }
610
611    #[test]
612    fn skips_holiday_and_sunday() {
613        // 2025-04-17 is Thursday before Easter.
614        // +1 Werktag: Fri 18 = Karfreitag (holiday → skip), Sat 19 is Werktag → 2025-04-19
615        let start = date(2025, 4, 17);
616        let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
617        assert_eq!(due, date(2025, 4, 19));
618    }
619
620    #[test]
621    fn zero_werktage_returns_start() {
622        let start = date(2025, 1, 6);
623        assert_eq!(add_werktage(start, 0, HolidayCalendar::BdewMaKo), start);
624    }
625
626    // ── next_werktag ──────────────────────────────────────────────────────────
627
628    #[test]
629    fn next_werktag_from_sunday_advances_to_monday() {
630        // Use Jan 12 (Sunday) → Jan 13 (Monday, no holiday).
631        // Jan 6 is Heilige Drei Könige (included in the fristen federal calendar),
632        // so that date cannot be used as the expected "next regular Monday".
633        let sunday = date(2025, 1, 12);
634        assert_eq!(
635            next_werktag(sunday, HolidayCalendar::BdewMaKo),
636            date(2025, 1, 13), // Monday
637        );
638    }
639
640    #[test]
641    fn next_werktag_from_werktag_returns_same() {
642        let monday = date(2025, 1, 13);
643        assert_eq!(next_werktag(monday, HolidayCalendar::BdewMaKo), monday);
644    }
645
646    #[test]
647    fn next_werktag_from_holiday_advances_to_next_werktag() {
648        // Neujahr 2025-01-01 is Wednesday; next Werktag is Thursday 2025-01-02.
649        assert_eq!(
650            next_werktag(date(2025, 1, 1), HolidayCalendar::BdewMaKo),
651            date(2025, 1, 2),
652        );
653    }
654
655    // ── deadline_at_werktage ──────────────────────────────────────────────────
656
657    ///  deadline must be 17:00 CET (16:00 UTC) in winter, not 17:00 UTC.
658    #[test]
659    fn deadline_at_werktage_winter_cet() {
660        // January is CET (UTC+1).  17:00 CET = 16:00 UTC.
661        let received = OffsetDateTime::new_utc(date(2025, 1, 6), Time::MIDNIGHT);
662        let due = deadline_at_werktage(received, 5, HolidayCalendar::BdewMaKo);
663        assert_eq!(due.date(), date(2025, 1, 11));
664        assert_eq!(
665            due.to_offset(time::UtcOffset::UTC).hour(),
666            16,
667            "winter: 17:00 CET = 16:00 UTC"
668        );
669        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
670    }
671
672    ///  deadline must be 17:00 CEST (15:00 UTC) in summer, not 17:00 UTC.
673    #[test]
674    fn deadline_at_werktage_summer_cest() {
675        // July is CEST (UTC+2).  17:00 CEST = 15:00 UTC.
676        let received = OffsetDateTime::new_utc(date(2025, 7, 1), Time::MIDNIGHT);
677        let due = deadline_at_werktage(received, 1, HolidayCalendar::BdewMaKo);
678        assert_eq!(
679            due.to_offset(time::UtcOffset::UTC).hour(),
680            15,
681            "summer: 17:00 CEST = 15:00 UTC"
682        );
683        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
684    }
685
686    /// Deadline that lands on the day *after* the spring-forward transition
687    /// must use CEST (UTC+2), not CET (UTC+1).
688    ///
689    /// 2025-03-30 02:00 CET → 03:00 CEST (spring-forward).
690    /// received = Wednesday 2025-03-26; +4 Werktage:
691    ///   Thu 27 (+1), Fri 28 (+2), Sat 29 (+3), Sun 30 (skip), Mon 31 (+4).
692    /// Deadline falls on Monday 2025-03-31 which is CEST: 17:00 CEST = 15:00 UTC.
693    #[test]
694    fn deadline_on_day_after_spring_forward_is_cest() {
695        let received = OffsetDateTime::new_utc(date(2025, 3, 26), Time::MIDNIGHT);
696        let due = deadline_at_werktage(received, 4, HolidayCalendar::BdewMaKo);
697        assert_eq!(
698            due.date(),
699            date(2025, 3, 31),
700            "should land on Monday 2025-03-31"
701        );
702        assert_eq!(
703            due.to_offset(time::UtcOffset::UTC).hour(),
704            15,
705            "CEST: 17:00 local = 15:00 UTC (spring-forward already happened)"
706        );
707        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
708    }
709
710    /// Deadline that lands on the day *after* the fall-back transition must
711    /// use CET (UTC+1), not CEST (UTC+2).
712    ///
713    /// 2025-10-26 03:00 CEST → 02:00 CET (fall-back).
714    /// received = Wednesday 2025-10-22; +4 Werktage:
715    ///   Thu 23 (+1), Fri 24 (+2), Sat 25 (+3), Sun 26 (skip), Mon 27 (+4).
716    /// Deadline falls on Monday 2025-10-27 which is CET: 17:00 CET = 16:00 UTC.
717    #[test]
718    fn deadline_on_day_after_fall_back_is_cet() {
719        let received = OffsetDateTime::new_utc(date(2025, 10, 22), Time::MIDNIGHT);
720        let due = deadline_at_werktage(received, 4, HolidayCalendar::BdewMaKo);
721        assert_eq!(
722            due.date(),
723            date(2025, 10, 27),
724            "should land on Monday 2025-10-27"
725        );
726        assert_eq!(
727            due.to_offset(time::UtcOffset::UTC).hour(),
728            16,
729            "CET: 17:00 local = 16:00 UTC (fall-back already happened)"
730        );
731        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
732    }
733
734    /// Regression test for the UTC-date edge case (F-005).
735    ///
736    /// A message arriving at 23:30 UTC on 2025-01-06 (Monday) is already
737    /// 00:30 CET on 2025-01-07 (Tuesday) in Berlin local time.  The deadline
738    /// must be counted from 2025-01-07 (Tuesday), not 2025-01-06 (Monday).
739    ///
740    /// Counting from Monday: Tue 07, Wed 08, Thu 09, Fri 10, Sat 11 → 2025-01-11
741    /// Counting from Tuesday: Wed 08, Thu 09, Fri 10, Sat 11, Mon 13 → 2025-01-13
742    ///   (2025-01-12 is Sunday; 2025-01-13 is Monday)
743    #[test]
744    fn deadline_at_werktage_uses_berlin_date_not_utc_date() {
745        use time::Time;
746        // 23:30 UTC on 2025-01-06 (Monday) = 00:30 CET on 2025-01-07 (Tuesday)
747        let received =
748            OffsetDateTime::new_utc(date(2025, 1, 6), Time::from_hms(23, 30, 0).unwrap());
749        let due = deadline_at_werktage(received, 5, HolidayCalendar::BdewMaKo);
750        // Should start from 2025-01-07 (Tuesday Berlin date), not 2025-01-06
751        assert_eq!(
752            due.date(),
753            date(2025, 1, 13),
754            "5 WT from Tuesday 2025-01-07: Wed 08 (+1), Thu 09 (+2), Fri 10 (+3), \
755             Sat 11 (+4), Mon 13 (+5) — Sunday 12 skipped"
756        );
757    }
758
759    /// Edge case: message at 23:59 UTC on 2025-01-10 (Friday) is already
760    /// Saturday 00:59 CET in Berlin.  Saturday is a Werktag, so 1 WT after
761    /// Saturday is Monday (Sunday skipped).
762    #[test]
763    fn deadline_at_werktage_friday_night_utc_is_saturday_berlin() {
764        use time::Time;
765        // 23:59 UTC on Friday 2025-01-10 = 00:59 CET on Saturday 2025-01-11
766        let received =
767            OffsetDateTime::new_utc(date(2025, 1, 10), Time::from_hms(23, 59, 0).unwrap());
768        let due = deadline_at_werktage(received, 1, HolidayCalendar::BdewMaKo);
769        // Starting from Saturday 2025-01-11: 1 WT = Monday 2025-01-13 (Sunday skipped)
770        assert_eq!(
771            due.date(),
772            date(2025, 1, 13),
773            "1 WT from Saturday 2025-01-11 is Monday 2025-01-13 (Sunday not a Werktag)"
774        );
775    }
776}