Skip to main content

paykit_lib/allowance/
periods.rs

1//! UTC period boundaries calculated from the original anchor.
2
3use chrono::{DateTime, Datelike, NaiveDate, TimeDelta, Utc};
4
5use super::{AllowanceEvaluationBlock, AllowancePeriod, AllowancePeriodKind, AllowancePeriodUnit};
6use crate::validation::parse_utc_timestamp;
7
8type MathResult<T> = std::result::Result<T, AllowanceEvaluationBlock>;
9const OVERFLOW: AllowanceEvaluationBlock = AllowanceEvaluationBlock::ArithmeticOverflow;
10const NANOS_PER_SECOND: i128 = 1_000_000_000;
11
12/// One exact UTC usage window: anchored `[start, end)` or rolling `(start, end]`.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct AllowancePeriodWindow {
15    starts_at: DateTime<Utc>,
16    ends_at: DateTime<Utc>,
17    kind: AllowancePeriodKind,
18}
19
20impl AllowancePeriodWindow {
21    /// Start boundary; inclusive only for anchored windows.
22    pub fn starts_at(&self) -> DateTime<Utc> {
23        self.starts_at
24    }
25
26    /// End boundary; inclusive only for rolling windows.
27    pub fn ends_at(&self) -> DateTime<Utc> {
28        self.ends_at
29    }
30
31    /// Whether the original admission time contributes to this window.
32    pub fn contains(&self, admitted_at: DateTime<Utc>) -> bool {
33        match self.kind {
34            AllowancePeriodKind::Anchored => {
35                self.starts_at <= admitted_at && admitted_at < self.ends_at
36            }
37            AllowancePeriodKind::Rolling => {
38                self.starts_at < admitted_at && admitted_at <= self.ends_at
39            }
40        }
41    }
42}
43
44/// Calculate a usage period at an explicit trusted time without reading a clock.
45///
46/// Calendar boundaries always derive from the original anchor, including before
47/// that anchor. Arithmetic outside the representable UTC range fails closed.
48/// Leap-second instants cannot be represented by this fixed-second math and
49/// return `ArithmeticOverflow` rather than being silently normalized.
50pub fn allowance_period_window(
51    period: &AllowancePeriod,
52    trusted_time: DateTime<Utc>,
53) -> MathResult<AllowancePeriodWindow> {
54    timestamp_nanos(trusted_time)?;
55    let (starts_at, ends_at) = match period.kind() {
56        AllowancePeriodKind::Rolling => {
57            let duration = TimeDelta::try_seconds(fixed_seconds(period)?).ok_or(OVERFLOW)?;
58            (
59                trusted_time.checked_sub_signed(duration).ok_or(OVERFLOW)?,
60                trusted_time,
61            )
62        }
63        AllowancePeriodKind::Anchored => anchored_window(period, trusted_time)?,
64    };
65    Ok(AllowancePeriodWindow {
66        starts_at,
67        ends_at,
68        kind: period.kind(),
69    })
70}
71
72fn anchored_window(
73    period: &AllowancePeriod,
74    time: DateTime<Utc>,
75) -> MathResult<(DateTime<Utc>, DateTime<Utc>)> {
76    let anchor = parse_utc_timestamp(period.anchor().ok_or(OVERFLOW)?, "Allowance anchor")
77        .map_err(|_| OVERFLOW)?
78        .with_timezone(&Utc);
79    timestamp_nanos(anchor)?;
80    match period.unit() {
81        AllowancePeriodUnit::Month | AllowancePeriodUnit::Year => {
82            calendar_window(period, anchor, time)
83        }
84        _ => fixed_window(period, anchor, time),
85    }
86}
87
88fn fixed_seconds(period: &AllowancePeriod) -> MathResult<i64> {
89    let factor = match period.unit() {
90        AllowancePeriodUnit::Minute => 60,
91        AllowancePeriodUnit::Hour => 3600,
92        AllowancePeriodUnit::Day => 86400,
93        AllowancePeriodUnit::Week => 604800,
94        _ => return Err(OVERFLOW),
95    };
96    i64::try_from(period.every().checked_mul(factor).ok_or(OVERFLOW)?).map_err(|_| OVERFLOW)
97}
98
99fn fixed_window(
100    period: &AllowancePeriod,
101    anchor: DateTime<Utc>,
102    time: DateTime<Utc>,
103) -> MathResult<(DateTime<Utc>, DateTime<Utc>)> {
104    let length = i128::from(fixed_seconds(period)?)
105        .checked_mul(NANOS_PER_SECOND)
106        .ok_or(OVERFLOW)?;
107    let anchor = timestamp_nanos(anchor)?;
108    let k = timestamp_nanos(time)?
109        .checked_sub(anchor)
110        .ok_or(OVERFLOW)?
111        .div_euclid(length);
112    let start = anchor
113        .checked_add(k.checked_mul(length).ok_or(OVERFLOW)?)
114        .ok_or(OVERFLOW)?;
115    Ok((
116        from_nanos(start)?,
117        from_nanos(start.checked_add(length).ok_or(OVERFLOW)?)?,
118    ))
119}
120
121fn timestamp_nanos(time: DateTime<Utc>) -> MathResult<i128> {
122    if time.timestamp_subsec_nanos() >= 1_000_000_000 {
123        return Err(OVERFLOW);
124    }
125    i128::from(time.timestamp())
126        .checked_mul(NANOS_PER_SECOND)
127        .and_then(|seconds| seconds.checked_add(i128::from(time.timestamp_subsec_nanos())))
128        .ok_or(OVERFLOW)
129}
130
131fn from_nanos(value: i128) -> MathResult<DateTime<Utc>> {
132    let seconds = i64::try_from(value.div_euclid(NANOS_PER_SECOND)).map_err(|_| OVERFLOW)?;
133    let nanos = u32::try_from(value.rem_euclid(NANOS_PER_SECOND)).map_err(|_| OVERFLOW)?;
134    DateTime::from_timestamp(seconds, nanos).ok_or(OVERFLOW)
135}
136
137fn calendar_window(
138    period: &AllowancePeriod,
139    anchor: DateTime<Utc>,
140    time: DateTime<Utc>,
141) -> MathResult<(DateTime<Utc>, DateTime<Utc>)> {
142    let months = period
143        .every()
144        .checked_mul(if period.unit() == AllowancePeriodUnit::Year {
145            12
146        } else {
147            1
148        })
149        .ok_or(OVERFLOW)?;
150    let step = i64::try_from(months).map_err(|_| OVERFLOW)?;
151    let mut k = (month_index(time) - month_index(anchor)).div_euclid(step);
152    let mut start = calendar_boundary(anchor, k, step)?;
153    if start > time {
154        k = k.checked_sub(1).ok_or(OVERFLOW)?;
155        start = calendar_boundary(anchor, k, step)?;
156    }
157    Ok((
158        start,
159        calendar_boundary(anchor, k.checked_add(1).ok_or(OVERFLOW)?, step)?,
160    ))
161}
162
163fn month_index(time: DateTime<Utc>) -> i64 {
164    i64::from(time.year()) * 12 + i64::from(time.month0())
165}
166
167fn calendar_boundary(anchor: DateTime<Utc>, k: i64, step: i64) -> MathResult<DateTime<Utc>> {
168    let index = month_index(anchor)
169        .checked_add(k.checked_mul(step).ok_or(OVERFLOW)?)
170        .ok_or(OVERFLOW)?;
171    let year = i32::try_from(index.div_euclid(12)).map_err(|_| OVERFLOW)?;
172    let month = u32::try_from(index.rem_euclid(12) + 1).map_err(|_| OVERFLOW)?;
173    let day = anchor.day().min(days_in_month(year, month));
174    let date = NaiveDate::from_ymd_opt(year, month, day).ok_or(OVERFLOW)?;
175    Ok(date.and_time(anchor.time()).and_utc())
176}
177
178fn days_in_month(year: i32, month: u32) -> u32 {
179    match month {
180        2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
181        2 => 28,
182        4 | 6 | 9 | 11 => 30,
183        _ => 31,
184    }
185}