use jiff::civil::date;
use jiff::{Timestamp, ToSpan};
use crate::units::CostUnits;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Period {
UtcCalendarMonth,
}
impl Period {
pub const ALL: &'static [Period] = &[Period::UtcCalendarMonth];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Period::UtcCalendarMonth => "utc_calendar_month",
}
}
#[must_use]
pub fn start_of(self, now: Timestamp) -> Timestamp {
match self {
Period::UtcCalendarMonth => {
let zoned = now.to_zoned(jiff::tz::TimeZone::UTC);
date(zoned.year(), zoned.month(), 1)
.to_zoned(jiff::tz::TimeZone::UTC)
.expect("the first of a month is a valid civil date in UTC")
.timestamp()
}
}
}
#[must_use]
pub fn end_after(self, now: Timestamp) -> Timestamp {
match self {
Period::UtcCalendarMonth => {
let start = self.start_of(now);
start
.to_zoned(jiff::tz::TimeZone::UTC)
.checked_add(1.month())
.expect("one month past a month start is representable")
.timestamp()
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Rollover {
#[default]
None,
}
impl Rollover {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Rollover::None => "none",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BudgetSchedule {
pub allowance: CostUnits,
pub period: Period,
pub rollover: Rollover,
}
impl BudgetSchedule {
#[must_use]
pub const fn monthly(allowance: CostUnits) -> Self {
BudgetSchedule {
allowance,
period: Period::UtcCalendarMonth,
rollover: Rollover::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BudgetView {
pub balance_at_publish: CostUnits,
pub period_end: Option<Timestamp>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BalanceExhaustion {
#[cfg_attr(feature = "serde", serde(deserialize_with = "required_period_end"))]
pub period_end: Option<Timestamp>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BalanceShortfall {
pub remaining: CostUnits,
#[cfg_attr(feature = "serde", serde(deserialize_with = "required_period_end"))]
pub period_end: Option<Timestamp>,
}
impl BalanceShortfall {
#[must_use]
pub fn exhaustion(self) -> Option<BalanceExhaustion> {
self.remaining.is_zero().then_some(BalanceExhaustion {
period_end: self.period_end,
})
}
}
impl From<BalanceExhaustion> for BalanceShortfall {
fn from(evidence: BalanceExhaustion) -> Self {
BalanceShortfall {
remaining: CostUnits::ZERO,
period_end: evidence.period_end,
}
}
}
#[cfg(feature = "serde")]
fn required_period_end<'de, D>(deserializer: D) -> Result<Option<Timestamp>, D::Error>
where
D: serde::Deserializer<'de>,
{
serde::Deserialize::deserialize(deserializer)
}
impl BudgetView {
#[must_use]
pub fn shortfall(self) -> BalanceShortfall {
BalanceShortfall {
remaining: self.balance_at_publish,
period_end: self.period_end,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(s: &str) -> Timestamp {
s.parse().expect("a valid RFC 3339 instant")
}
#[test]
fn a_month_starts_at_midnight_utc_on_the_first() {
let period = Period::UtcCalendarMonth;
assert_eq!(
period.start_of(at("2026-03-17T09:41:12Z")),
at("2026-03-01T00:00:00Z")
);
assert_eq!(
period.start_of(at("2026-03-01T00:00:00Z")),
at("2026-03-01T00:00:00Z"),
"an instant exactly at a boundary belongs to the period it opens"
);
}
#[test]
fn month_ends_handle_short_months_leap_years_and_the_year_wrap() {
let period = Period::UtcCalendarMonth;
for (now, expected) in [
("2026-01-31T23:59:59Z", "2026-02-01T00:00:00Z"),
("2026-02-14T00:00:00Z", "2026-03-01T00:00:00Z"),
("2028-02-29T12:00:00Z", "2028-03-01T00:00:00Z"),
("2026-12-25T00:00:00Z", "2027-01-01T00:00:00Z"),
] {
assert_eq!(
period.end_after(at(now)),
at(expected),
"period containing {now} must end at {expected}"
);
}
}
#[test]
fn the_period_start_is_what_makes_two_instants_the_same_period() {
let period = Period::UtcCalendarMonth;
let early = period.start_of(at("2026-05-01T00:00:00Z"));
let late = period.start_of(at("2026-05-31T23:59:59Z"));
let next = period.start_of(at("2026-06-01T00:00:00Z"));
assert_eq!(early, late);
assert_ne!(late, next);
assert_eq!(period.end_after(at("2026-05-31T23:59:59Z")), next);
}
#[test]
fn every_period_is_swept() {
for period in Period::ALL {
match period {
Period::UtcCalendarMonth => {}
}
}
assert_eq!(Period::ALL.len(), 1, "every variant is listed exactly once");
}
#[test]
fn stored_names_are_stable() {
assert_eq!(Period::UtcCalendarMonth.as_str(), "utc_calendar_month");
assert_eq!(Rollover::None.as_str(), "none");
}
#[test]
fn a_monthly_schedule_spells_out_both_defaults() {
let schedule = BudgetSchedule::monthly(CostUnits(10_000));
assert_eq!(schedule.allowance, CostUnits(10_000));
assert_eq!(schedule.period, Period::UtcCalendarMonth);
assert_eq!(schedule.rollover, Rollover::None);
}
}