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 Europe/Berlin is never inside a DST gap or fold, so Ambiguous
315        // and None are unreachable in practice. If the timezone database is
316        // broken or absent, we must not silently compute a wrong deadline:
317        // UTC+1 in a CEST month (UTC+2) produces a deadline 1 hour late, which
318        // is a reportable BNetzA regulatory violation.
319        OffsetResult::Ambiguous(earlier, _later) => earlier,
320        OffsetResult::None => {
321            // SAFETY: 17:00 is never inside a DST gap for Europe/Berlin. If we
322            // land here the timezone database is corrupt or missing. Panic loudly
323            // so the operator detects the failure before it silently produces
324            // wrong APERAK deadlines. A wrong deadline is worse than a crash
325            // because it is a regulatory violation without any visible signal.
326            panic!(
327                "CRITICAL: timezone database failure — could not resolve \
328                 17:00 Europe/Berlin for date {due_date}. \
329                 Cannot compute a correct APERAK deadline. \
330                 A wrong fallback offset violates BNetzA deadline obligations. \
331                 Ensure the system timezone database (tzdata) is installed and \
332                 up to date. Aborting rather than silently producing an \
333                 incorrect deadline."
334            );
335        }
336    }
337}
338
339// ── Holiday tables ────────────────────────────────────────────────────────────
340
341/// Return `true` when `date` is a non-Werktag public holiday under the
342/// [`HolidayCalendar::BdewMaKo`] calendar.
343///
344/// Covers all 9 *bundesweite* public holidays plus the *Landesfeiertage*
345/// observed in at least one German state. See [`HolidayCalendar::BdewMaKo`]
346/// for the complete list and rationale.
347///
348/// Easter is computed algorithmically using the Anonymous Gregorian algorithm —
349/// no pre-computed table, no year ceiling.
350#[must_use]
351fn is_bdew_mako_holiday(date: Date) -> bool {
352    let (y, m, d) = (date.year(), date.month() as u8, date.day());
353
354    // Fixed-date holidays (bundesweit + Landesfeiertage):
355    if matches!(
356        (m, d),
357        (1 | 5 | 11, 1) | (1, 6) | (8, 15) | (10, 3 | 31) | (12, 25 | 26) // 2. Weihnachtstag
358    ) {
359        return true;
360    }
361
362    // Moveable Easter-based holidays — computed algorithmically.
363    let e_date = easter_sunday(y);
364
365    let offsets: &[i64] = &[
366        -2, // Karfreitag
367        1,  // Ostermontag
368        39, // Christi Himmelfahrt
369        49, // Pfingstsonntag
370        50, // Pfingstmontag
371        60, // Fronleichnam (BW, BY, HE, NW, RP, SL, SN/TH parts)
372    ];
373
374    for &offset in offsets {
375        let holiday = e_date + Duration::days(offset);
376        if holiday == date {
377            return true;
378        }
379    }
380
381    false
382}
383
384/// Compute Easter Sunday for `year` using the Anonymous Gregorian algorithm.
385///
386/// Valid for all years in the proleptic Gregorian calendar. No table, no
387/// year ceiling.
388///
389/// # Example
390///
391/// ```rust,ignore
392/// // Easter 2025: 20 April
393/// let e = easter_sunday(2025);
394/// assert_eq!((e.year(), e.month() as u8, e.day()), (2025, 4, 20));
395/// ```
396#[allow(clippy::many_single_char_names)]
397fn easter_sunday(year: i32) -> Date {
398    let a = year % 19;
399    let b = year / 100;
400    let c = year % 100;
401    let d = b / 4;
402    let e = b % 4;
403    let f = (b + 8) / 25;
404    let g = (b - f + 1) / 3;
405    let h = (19 * a + b - d - g + 15) % 30;
406    let i = c / 4;
407    let k = c % 4;
408    let l = (32 + 2 * e + 2 * i - h - k) % 7;
409    let m = (a + 11 * h + 22 * l) / 451;
410    let month = (h + l - 7 * m + 114) / 31;
411    let day = (h + l - 7 * m + 114) % 31 + 1;
412    // The algorithm guarantees month in 3..=4 and day in 1..=31; both casts are safe.
413    let month_u8 = u8::try_from(month).expect("algorithm yields valid month index");
414    let day_u8 = u8::try_from(day).expect("algorithm yields valid day");
415    Date::from_calendar_date(
416        year,
417        time::Month::try_from(month_u8).expect("algorithm yields valid month"),
418        day_u8,
419    )
420    .expect("algorithm yields valid date")
421}
422
423/// Return `true` when `date` is a Werktag under `cal`.
424///
425/// In German energy regulation (BDEW AHB), Sundays and public holidays are
426/// **not** Werktage. Saturdays **are** Werktage.
427fn is_werktag(date: Date, cal: HolidayCalendar) -> bool {
428    if date.weekday() == Weekday::Sunday {
429        return false;
430    }
431    match cal {
432        HolidayCalendar::BdewMaKo => !is_bdew_mako_holiday(date),
433    }
434}
435
436// ── Tests ─────────────────────────────────────────────────────────────────────
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use time::{Date, Month, OffsetDateTime, Time};
442
443    fn date(y: i32, m: u8, d: u8) -> Date {
444        Date::from_calendar_date(y, Month::try_from(m).unwrap(), d).unwrap()
445    }
446
447    // ── add_hours ─────────────────────────────────────────────────────────────
448
449    #[test]
450    fn add_hours_advances_exactly() {
451        let t = OffsetDateTime::now_utc();
452        assert_eq!(add_hours(t, 24) - t, Duration::hours(24));
453    }
454
455    #[test]
456    fn add_hours_crosses_midnight() {
457        let t = OffsetDateTime::now_utc();
458        let due = add_hours(t, 24);
459        // 24h later is exactly one day forward (ignoring leap-seconds):
460        assert_eq!(due.date(), t.date() + Duration::days(1));
461    }
462
463    // ── contrl_due_at ─────────────────────────────────────────────────────────
464
465    #[test]
466    fn contrl_due_at_is_exactly_6h_after_received() {
467        let received = OffsetDateTime::now_utc();
468        let due = contrl_due_at(received);
469        assert_eq!(
470            due - received,
471            Duration::hours(6),
472            "CONTRL AHB 1.0 §1.2 requires exactly 6h frist"
473        );
474    }
475
476    #[test]
477    fn contrl_frist_label_is_stable() {
478        // Changing this label would silently orphan all existing Deadline records.
479        assert_eq!(CONTRL_FRIST_LABEL, "contrl-delivery");
480    }
481
482    #[test]
483    fn contrl_frist_hours_matches_constant() {
484        let received = OffsetDateTime::now_utc();
485        assert_eq!(
486            contrl_due_at(received) - received,
487            Duration::hours(CONTRL_FRIST_HOURS)
488        );
489    }
490
491    // ── is_bdew_mako_holiday ────────────────────────────────────────────────────
492
493    #[test]
494    fn fixed_holidays_are_detected() {
495        assert!(is_bdew_mako_holiday(date(2025, 1, 1)), "Neujahr");
496        assert!(
497            is_bdew_mako_holiday(date(2025, 1, 6)),
498            "Heilige Drei Könige"
499        );
500        assert!(is_bdew_mako_holiday(date(2025, 5, 1)), "Tag der Arbeit");
501        assert!(is_bdew_mako_holiday(date(2025, 8, 15)), "Mariä Himmelfahrt");
502        assert!(
503            is_bdew_mako_holiday(date(2025, 10, 3)),
504            "Tag der Deutschen Einheit"
505        );
506        assert!(is_bdew_mako_holiday(date(2025, 10, 31)), "Reformationstag");
507        assert!(is_bdew_mako_holiday(date(2025, 11, 1)), "Allerheiligen");
508        assert!(is_bdew_mako_holiday(date(2025, 12, 25)), "1. Weihnachtstag");
509        assert!(is_bdew_mako_holiday(date(2025, 12, 26)), "2. Weihnachtstag");
510    }
511
512    #[test]
513    fn easter_2025_moveable_holidays() {
514        // Easter Sunday 2025-04-20
515        assert!(is_bdew_mako_holiday(date(2025, 4, 18)), "Karfreitag");
516        assert!(is_bdew_mako_holiday(date(2025, 4, 21)), "Ostermontag");
517        assert!(
518            is_bdew_mako_holiday(date(2025, 5, 29)),
519            "Christi Himmelfahrt"
520        );
521        assert!(is_bdew_mako_holiday(date(2025, 6, 8)), "Pfingstsonntag");
522        assert!(is_bdew_mako_holiday(date(2025, 6, 9)), "Pfingstmontag");
523        assert!(is_bdew_mako_holiday(date(2025, 6, 19)), "Fronleichnam");
524    }
525
526    /// Verify the Anonymous Gregorian algorithm is correct beyond the old 2035
527    /// table ceiling.
528    #[test]
529    fn easter_beyond_2035_table_ceiling() {
530        // 2036: Easter Sunday = 13 April (verified against multiple Easter calculators)
531        assert_eq!(easter_sunday(2036), date(2036, 4, 13));
532        assert!(is_bdew_mako_holiday(date(2036, 4, 11)), "Karfreitag 2036"); // -2
533        assert!(is_bdew_mako_holiday(date(2036, 4, 14)), "Ostermontag 2036"); // +1
534        assert!(
535            is_bdew_mako_holiday(date(2036, 5, 22)),
536            "Christi Himmelfahrt 2036"
537        ); // +39
538        assert!(
539            is_bdew_mako_holiday(date(2036, 6, 1)),
540            "Pfingstsonntag 2036"
541        ); // +49
542        assert!(is_bdew_mako_holiday(date(2036, 6, 2)), "Pfingstmontag 2036"); // +50
543        assert!(is_bdew_mako_holiday(date(2036, 6, 12)), "Fronleichnam 2036"); // +60
544
545        // 2050: Easter Sunday = 10 April
546        assert_eq!(easter_sunday(2050), date(2050, 4, 10));
547    }
548
549    #[test]
550    fn saturday_is_not_a_holiday() {
551        // 2025-01-04 is a Saturday — not a holiday
552        assert!(!is_bdew_mako_holiday(date(2025, 1, 4)));
553    }
554
555    // ── is_werktag ────────────────────────────────────────────────────────────
556
557    #[test]
558    fn sunday_is_not_werktag() {
559        assert!(!is_werktag(date(2025, 1, 5), HolidayCalendar::BdewMaKo));
560    }
561
562    #[test]
563    fn saturday_is_werktag() {
564        assert!(is_werktag(date(2025, 1, 4), HolidayCalendar::BdewMaKo));
565    }
566
567    #[test]
568    fn holiday_is_not_werktag() {
569        assert!(!is_werktag(date(2025, 1, 1), HolidayCalendar::BdewMaKo));
570    }
571
572    #[test]
573    fn landesfeiertage_are_not_werktage() {
574        // Heilige Drei Könige
575        assert!(!is_werktag(date(2025, 1, 6), HolidayCalendar::BdewMaKo));
576        // Mariä Himmelfahrt
577        assert!(!is_werktag(date(2025, 8, 15), HolidayCalendar::BdewMaKo));
578        // Reformationstag 2025 falls on a Friday
579        assert!(!is_werktag(date(2025, 10, 31), HolidayCalendar::BdewMaKo));
580        // Allerheiligen
581        assert!(!is_werktag(date(2025, 11, 1), HolidayCalendar::BdewMaKo));
582    }
583
584    // ── add_werktage ──────────────────────────────────────────────────────────
585
586    #[test]
587    fn five_werktage_plain_week() {
588        // Monday 2025-01-06, no holidays.
589        // Tue 07, Wed 08, Thu 09, Fri 10, Sat 11 → 2025-01-11
590        // (Saturday counts as Werktag in German energy regulation)
591        let start = date(2025, 1, 6);
592        let due = add_werktage(start, 5, HolidayCalendar::BdewMaKo);
593        assert_eq!(due, date(2025, 1, 11));
594    }
595
596    #[test]
597    fn skips_reformationstag_and_allerheiligen() {
598        // 2025-10-29 is a Wednesday.
599        // +5 Werktage:
600        //   Thu 30 (+1), Fri 31 = Reformationstag (skip), Sat 01 Nov = Allerheiligen (skip),
601        //   Sun 02 (skip), Mon 03 (+2), Tue 04 (+3), Wed 05 (+4), Thu 06 (+5) → 2025-11-06
602        let start = date(2025, 10, 29);
603        let due = add_werktage(start, 5, HolidayCalendar::BdewMaKo);
604        assert_eq!(due, date(2025, 11, 6));
605    }
606
607    #[test]
608    fn skips_heilige_drei_koenige() {
609        // 2025-01-04 is a Saturday (Werktag).
610        // +1 Werktag: Sun 05 (skip), Mon 06 = Heilige Drei Könige (skip),
611        //              Tue 07 → 2025-01-07
612        let start = date(2025, 1, 4);
613        let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
614        assert_eq!(due, date(2025, 1, 7));
615    }
616
617    #[test]
618    fn skips_sunday_correctly() {
619        // Saturday 2025-01-11:  +1 Werktag → skip Sun 12 → Mon 13
620        // (Using a date that avoids Heilige Drei Könige on 06-Jan)
621        let start = date(2025, 1, 11);
622        let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
623        assert_eq!(due, date(2025, 1, 13));
624    }
625
626    #[test]
627    fn skips_holiday_and_sunday() {
628        // 2025-04-17 is Thursday before Easter.
629        // +1 Werktag: Fri 18 = Karfreitag (holiday → skip), Sat 19 is Werktag → 2025-04-19
630        let start = date(2025, 4, 17);
631        let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
632        assert_eq!(due, date(2025, 4, 19));
633    }
634
635    #[test]
636    fn zero_werktage_returns_start() {
637        let start = date(2025, 1, 6);
638        assert_eq!(add_werktage(start, 0, HolidayCalendar::BdewMaKo), start);
639    }
640
641    // ── next_werktag ──────────────────────────────────────────────────────────
642
643    #[test]
644    fn next_werktag_from_sunday_advances_to_monday() {
645        // Use Jan 12 (Sunday) → Jan 13 (Monday, no holiday).
646        // Jan 6 is Heilige Drei Könige (included in the fristen federal calendar),
647        // so that date cannot be used as the expected "next regular Monday".
648        let sunday = date(2025, 1, 12);
649        assert_eq!(
650            next_werktag(sunday, HolidayCalendar::BdewMaKo),
651            date(2025, 1, 13), // Monday
652        );
653    }
654
655    #[test]
656    fn next_werktag_from_werktag_returns_same() {
657        let monday = date(2025, 1, 13);
658        assert_eq!(next_werktag(monday, HolidayCalendar::BdewMaKo), monday);
659    }
660
661    #[test]
662    fn next_werktag_from_holiday_advances_to_next_werktag() {
663        // Neujahr 2025-01-01 is Wednesday; next Werktag is Thursday 2025-01-02.
664        assert_eq!(
665            next_werktag(date(2025, 1, 1), HolidayCalendar::BdewMaKo),
666            date(2025, 1, 2),
667        );
668    }
669
670    // ── deadline_at_werktage ──────────────────────────────────────────────────
671
672    ///  deadline must be 17:00 CET (16:00 UTC) in winter, not 17:00 UTC.
673    #[test]
674    fn deadline_at_werktage_winter_cet() {
675        // January is CET (UTC+1).  17:00 CET = 16:00 UTC.
676        let received = OffsetDateTime::new_utc(date(2025, 1, 6), Time::MIDNIGHT);
677        let due = deadline_at_werktage(received, 5, HolidayCalendar::BdewMaKo);
678        assert_eq!(due.date(), date(2025, 1, 11));
679        assert_eq!(
680            due.to_offset(time::UtcOffset::UTC).hour(),
681            16,
682            "winter: 17:00 CET = 16:00 UTC"
683        );
684        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
685    }
686
687    ///  deadline must be 17:00 CEST (15:00 UTC) in summer, not 17:00 UTC.
688    #[test]
689    fn deadline_at_werktage_summer_cest() {
690        // July is CEST (UTC+2).  17:00 CEST = 15:00 UTC.
691        let received = OffsetDateTime::new_utc(date(2025, 7, 1), Time::MIDNIGHT);
692        let due = deadline_at_werktage(received, 1, HolidayCalendar::BdewMaKo);
693        assert_eq!(
694            due.to_offset(time::UtcOffset::UTC).hour(),
695            15,
696            "summer: 17:00 CEST = 15:00 UTC"
697        );
698        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
699    }
700
701    /// Deadline that lands on the day *after* the spring-forward transition
702    /// must use CEST (UTC+2), not CET (UTC+1).
703    ///
704    /// 2025-03-30 02:00 CET → 03:00 CEST (spring-forward).
705    /// received = Wednesday 2025-03-26; +4 Werktage:
706    ///   Thu 27 (+1), Fri 28 (+2), Sat 29 (+3), Sun 30 (skip), Mon 31 (+4).
707    /// Deadline falls on Monday 2025-03-31 which is CEST: 17:00 CEST = 15:00 UTC.
708    #[test]
709    fn deadline_on_day_after_spring_forward_is_cest() {
710        let received = OffsetDateTime::new_utc(date(2025, 3, 26), Time::MIDNIGHT);
711        let due = deadline_at_werktage(received, 4, HolidayCalendar::BdewMaKo);
712        assert_eq!(
713            due.date(),
714            date(2025, 3, 31),
715            "should land on Monday 2025-03-31"
716        );
717        assert_eq!(
718            due.to_offset(time::UtcOffset::UTC).hour(),
719            15,
720            "CEST: 17:00 local = 15:00 UTC (spring-forward already happened)"
721        );
722        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
723    }
724
725    /// Deadline that lands on the day *after* the fall-back transition must
726    /// use CET (UTC+1), not CEST (UTC+2).
727    ///
728    /// 2025-10-26 03:00 CEST → 02:00 CET (fall-back).
729    /// received = Wednesday 2025-10-22; +4 Werktage:
730    ///   Thu 23 (+1), Fri 24 (+2), Sat 25 (+3), Sun 26 (skip), Mon 27 (+4).
731    /// Deadline falls on Monday 2025-10-27 which is CET: 17:00 CET = 16:00 UTC.
732    #[test]
733    fn deadline_on_day_after_fall_back_is_cet() {
734        let received = OffsetDateTime::new_utc(date(2025, 10, 22), Time::MIDNIGHT);
735        let due = deadline_at_werktage(received, 4, HolidayCalendar::BdewMaKo);
736        assert_eq!(
737            due.date(),
738            date(2025, 10, 27),
739            "should land on Monday 2025-10-27"
740        );
741        assert_eq!(
742            due.to_offset(time::UtcOffset::UTC).hour(),
743            16,
744            "CET: 17:00 local = 16:00 UTC (fall-back already happened)"
745        );
746        assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
747    }
748
749    /// Regression test for the UTC-date edge case (F-005).
750    ///
751    /// A message arriving at 23:30 UTC on 2025-01-06 (Monday) is already
752    /// 00:30 CET on 2025-01-07 (Tuesday) in Berlin local time.  The deadline
753    /// must be counted from 2025-01-07 (Tuesday), not 2025-01-06 (Monday).
754    ///
755    /// Counting from Monday: Tue 07, Wed 08, Thu 09, Fri 10, Sat 11 → 2025-01-11
756    /// Counting from Tuesday: Wed 08, Thu 09, Fri 10, Sat 11, Mon 13 → 2025-01-13
757    ///   (2025-01-12 is Sunday; 2025-01-13 is Monday)
758    #[test]
759    fn deadline_at_werktage_uses_berlin_date_not_utc_date() {
760        use time::Time;
761        // 23:30 UTC on 2025-01-06 (Monday) = 00:30 CET on 2025-01-07 (Tuesday)
762        let received =
763            OffsetDateTime::new_utc(date(2025, 1, 6), Time::from_hms(23, 30, 0).unwrap());
764        let due = deadline_at_werktage(received, 5, HolidayCalendar::BdewMaKo);
765        // Should start from 2025-01-07 (Tuesday Berlin date), not 2025-01-06
766        assert_eq!(
767            due.date(),
768            date(2025, 1, 13),
769            "5 WT from Tuesday 2025-01-07: Wed 08 (+1), Thu 09 (+2), Fri 10 (+3), \
770             Sat 11 (+4), Mon 13 (+5) — Sunday 12 skipped"
771        );
772    }
773
774    /// Edge case: message at 23:59 UTC on 2025-01-10 (Friday) is already
775    /// Saturday 00:59 CET in Berlin.  Saturday is a Werktag, so 1 WT after
776    /// Saturday is Monday (Sunday skipped).
777    #[test]
778    fn deadline_at_werktage_friday_night_utc_is_saturday_berlin() {
779        use time::Time;
780        // 23:59 UTC on Friday 2025-01-10 = 00:59 CET on Saturday 2025-01-11
781        let received =
782            OffsetDateTime::new_utc(date(2025, 1, 10), Time::from_hms(23, 59, 0).unwrap());
783        let due = deadline_at_werktage(received, 1, HolidayCalendar::BdewMaKo);
784        // Starting from Saturday 2025-01-11: 1 WT = Monday 2025-01-13 (Sunday skipped)
785        assert_eq!(
786            due.date(),
787            date(2025, 1, 13),
788            "1 WT from Saturday 2025-01-11 is Monday 2025-01-13 (Sunday not a Werktag)"
789        );
790    }
791}