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}