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}