Skip to main content

tollgate_core/
budget.rs

1//! Periodic allowances: what an account is granted each period, and what
2//! happens to what it did not spend.
3//!
4//! Tollgate stores a schedule and applies it; it does not interpret product
5//! vocabulary. A plan that calls its allowance "included credits" and its period
6//! "a calendar month" compiles to a [`BudgetSchedule`] here, and the ledger
7//! knows only units and instants.
8//!
9//! The rollover itself is a control-plane operation on the store
10//! (`AdminStore::roll_period`), not something this module performs: only the
11//! store can make the deposit and the expiry one transaction, and only the
12//! store can make two replicas racing a boundary produce one of each.
13
14use jiff::civil::date;
15use jiff::{Timestamp, ToSpan};
16
17use crate::units::CostUnits;
18
19/// How often an allowance is replenished.
20///
21/// One variant today. The enum exists rather than a bare "monthly" flag
22/// because the calendar arithmetic differs per period in ways a duration
23/// cannot express — months are not a fixed number of seconds — so a later
24/// weekly or annual period is a variant here rather than a second field
25/// somewhere else.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28#[non_exhaustive]
29pub enum Period {
30    /// Boundaries at 00:00:00 UTC on the first of each calendar month.
31    ///
32    /// UTC and not a customer's local zone: a boundary that moved with a
33    /// timezone would make "the 1st" ambiguous across a fleet, and two
34    /// replicas in different regions could each believe they were first to
35    /// roll. One instant, one boundary, everywhere.
36    UtcCalendarMonth,
37}
38
39impl Period {
40    /// Every period a rollover pass must sweep.
41    ///
42    /// A backend rolls one statement per entry, because the boundary is a
43    /// property of the period: a weekly schedule and a monthly one are due at
44    /// different instants. A variant missing from this list would simply never
45    /// be rolled — silently, and only visibly as an account whose allowance
46    /// stopped arriving — so `every_period_is_swept` matches exhaustively over
47    /// the enum to make the omission a compile error instead.
48    pub const ALL: &'static [Period] = &[Period::UtcCalendarMonth];
49
50    /// The stored name of this period, for backends that persist a schedule.
51    ///
52    /// A name and not an ordinal, for the reason [`AccountStatus::as_str`]
53    /// gives: a variant added ahead of this one in the enum would silently
54    /// re-label every stored row, where an unrecognized name is a decode error
55    /// the backend reports.
56    ///
57    /// [`AccountStatus::as_str`]: crate::AccountStatus::as_str
58    #[must_use]
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Period::UtcCalendarMonth => "utc_calendar_month",
62        }
63    }
64
65    /// The first instant of the period containing `now`.
66    ///
67    /// This is the value the ledger stamps and compares against, so it is the
68    /// definition of "which period is this": two instants belong to the same
69    /// period exactly when this returns the same answer for both.
70    #[must_use]
71    pub fn start_of(self, now: Timestamp) -> Timestamp {
72        match self {
73            Period::UtcCalendarMonth => {
74                let zoned = now.to_zoned(jiff::tz::TimeZone::UTC);
75                date(zoned.year(), zoned.month(), 1)
76                    .to_zoned(jiff::tz::TimeZone::UTC)
77                    .expect("the first of a month is a valid civil date in UTC")
78                    .timestamp()
79            }
80        }
81    }
82
83    /// The first instant of the period after the one containing `now`, which
84    /// is also the instant this period's allowance stops being spendable.
85    ///
86    /// Half-open, like every other deadline here: an instant exactly at the
87    /// boundary belongs to the *new* period.
88    ///
89    /// Month arithmetic is `jiff`'s problem rather than ours — 31 January plus
90    /// one month, February in a leap year, and December's wrap into the next
91    /// year are exactly the cases a hand-rolled version gets wrong.
92    #[must_use]
93    pub fn end_after(self, now: Timestamp) -> Timestamp {
94        match self {
95            Period::UtcCalendarMonth => {
96                let start = self.start_of(now);
97                start
98                    .to_zoned(jiff::tz::TimeZone::UTC)
99                    .checked_add(1.month())
100                    .expect("one month past a month start is representable")
101                    .timestamp()
102            }
103        }
104    }
105}
106
107/// What happens to an allowance's unspent units at a period boundary.
108///
109/// One variant today, and it is the one the product needs: an allowance that
110/// resets. Carry-over is a variant here when something asks for it, not a
111/// boolean that would leave "how much carries over" unrepresentable.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
113#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
114#[non_exhaustive]
115pub enum Rollover {
116    /// Unspent allowance does not carry over: at the boundary it is expired
117    /// and the new period starts at exactly `allowance`.
118    #[default]
119    None,
120}
121
122impl Rollover {
123    /// The stored name of this rule. See [`Period::as_str`].
124    #[must_use]
125    pub const fn as_str(self) -> &'static str {
126        match self {
127            Rollover::None => "none",
128        }
129    }
130}
131
132/// An account's periodic allowance.
133///
134/// Absent means "no schedule": the account keeps the manual-deposit behaviour
135/// it has always had, and a rollover pass leaves it alone. That is why this is
136/// stored as an `Option` on the account rather than as a schedule with a zero
137/// allowance — zero is a schedule that expires everything each month, which is
138/// a very different thing from having no schedule at all.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141pub struct BudgetSchedule {
142    /// Units deposited at each period boundary.
143    pub allowance: CostUnits,
144    /// How the calendar is divided into periods; each boundary deposits
145    /// `allowance`.
146    pub period: Period,
147    /// What happens at a boundary to allowance left unspent.
148    pub rollover: Rollover,
149}
150
151impl BudgetSchedule {
152    /// A monthly allowance that does not carry over — the shape every
153    /// consumer wants today, named so a caller does not have to spell out two
154    /// single-variant enums to say it.
155    #[must_use]
156    pub const fn monthly(allowance: CostUnits) -> Self {
157        BudgetSchedule {
158            allowance,
159            period: Period::UtcCalendarMonth,
160            rollover: Rollover::None,
161        }
162    }
163}
164
165/// What an instance is told about its account's budget, carried by the
166/// snapshot (GL-97).
167///
168/// A *projection of the ledger at publication*, not a live balance: the
169/// request path performs no I/O, so this is the last thing the control plane
170/// said, and it ages between refreshes. Readers combine it with what the
171/// instance has spent since — see `estimate_remaining` in
172/// `tollgate-admission` — and the result is an estimate that names itself one.
173///
174/// The store stamps it. A publisher cannot supply it, because a balance is not
175/// a compiled policy decision the way permissions and limits are: it moves
176/// constantly and has exactly one authority. That is why
177/// [`AccountSnapshot`](crate::AccountSnapshot) has no builder setter for it,
178/// and `PublishableSnapshot::with_budget` is the only way to attach one.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
181pub struct BudgetView {
182    /// Everything the account could still spend when this snapshot was
183    /// published — its balance *plus* the unspent remainder of every active
184    /// lease, because units out on lease are still the account's.
185    ///
186    /// Equivalently, and this is how a backend computes it in one row read:
187    /// what the account was funded with, minus what it has consumed or lost.
188    pub balance_at_publish: CostUnits,
189    /// When the current period's allowance stops being spendable, for an
190    /// account that has a [`BudgetSchedule`]. `None` means no schedule — the
191    /// balance does not expire — and is not the same as "unknown".
192    pub period_end: Option<Timestamp>,
193}
194
195/// Allocator evidence that all account funding has been consumed or lost,
196/// including units held in leases. Missing usage cannot establish this proof.
197/// A period end bounds its validity; no end means an unscheduled balance.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200pub struct BalanceExhaustion {
201    /// When the current period ends, after which a new allowance may fund
202    /// the account and this evidence no longer holds. `None` for an account
203    /// without a schedule, whose exhaustion lasts until a deposit.
204    #[cfg_attr(feature = "serde", serde(deserialize_with = "required_period_end"))]
205    pub period_end: Option<Timestamp>,
206}
207
208/// Allocator evidence of an account's remaining funding: what it was funded
209/// with minus what it has consumed or lost, including units held in leases.
210///
211/// An upper bound on what the account can still spend. Unreported
212/// consumption can only lower true remaining funding, so a quote above
213/// `remaining` cannot be funded until new funding or a new period arrives.
214/// The converse does not hold: a quote within it may still find no lease.
215/// `period_end` bounds validity as it does for [`BalanceExhaustion`].
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
218pub struct BalanceShortfall {
219    /// The most the account can still spend, counting units held in leases.
220    /// An upper bound, never a balance to display.
221    pub remaining: CostUnits,
222    /// When the current period ends, bounding how long this evidence holds;
223    /// `None` for an account without a schedule.
224    #[cfg_attr(feature = "serde", serde(deserialize_with = "required_period_end"))]
225    pub period_end: Option<Timestamp>,
226}
227
228impl BalanceShortfall {
229    /// Zero remaining is exhaustion, the one shortfall no quote survives.
230    #[must_use]
231    pub fn exhaustion(self) -> Option<BalanceExhaustion> {
232        self.remaining.is_zero().then_some(BalanceExhaustion {
233            period_end: self.period_end,
234        })
235    }
236}
237
238impl From<BalanceExhaustion> for BalanceShortfall {
239    fn from(evidence: BalanceExhaustion) -> Self {
240        BalanceShortfall {
241            remaining: CostUnits::ZERO,
242            period_end: evidence.period_end,
243        }
244    }
245}
246
247#[cfg(feature = "serde")]
248fn required_period_end<'de, D>(deserializer: D) -> Result<Option<Timestamp>, D::Error>
249where
250    D: serde::Deserializer<'de>,
251{
252    serde::Deserialize::deserialize(deserializer)
253}
254
255impl BudgetView {
256    /// Called by a store against its transaction's current ledger, never an
257    /// instance's stale snapshot or remaining-balance estimate. Exhaustion is
258    /// [`BalanceShortfall::exhaustion`] of the result.
259    #[must_use]
260    pub fn shortfall(self) -> BalanceShortfall {
261        BalanceShortfall {
262            remaining: self.balance_at_publish,
263            period_end: self.period_end,
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn at(s: &str) -> Timestamp {
273        s.parse().expect("a valid RFC 3339 instant")
274    }
275
276    #[test]
277    fn a_month_starts_at_midnight_utc_on_the_first() {
278        let period = Period::UtcCalendarMonth;
279        assert_eq!(
280            period.start_of(at("2026-03-17T09:41:12Z")),
281            at("2026-03-01T00:00:00Z")
282        );
283        assert_eq!(
284            period.start_of(at("2026-03-01T00:00:00Z")),
285            at("2026-03-01T00:00:00Z"),
286            "an instant exactly at a boundary belongs to the period it opens"
287        );
288    }
289
290    /// The cases a hand-rolled month would get wrong, which is why this
291    /// delegates to `jiff` rather than adding 30 days.
292    #[test]
293    fn month_ends_handle_short_months_leap_years_and_the_year_wrap() {
294        let period = Period::UtcCalendarMonth;
295        for (now, expected) in [
296            // 31-day month into a 28-day one: the end is March, not "the 31st
297            // of February".
298            ("2026-01-31T23:59:59Z", "2026-02-01T00:00:00Z"),
299            ("2026-02-14T00:00:00Z", "2026-03-01T00:00:00Z"),
300            // Leap year: February has a 29th, and the boundary is still the
301            // 1st of March.
302            ("2028-02-29T12:00:00Z", "2028-03-01T00:00:00Z"),
303            // The year wrap.
304            ("2026-12-25T00:00:00Z", "2027-01-01T00:00:00Z"),
305        ] {
306            assert_eq!(
307                period.end_after(at(now)),
308                at(expected),
309                "period containing {now} must end at {expected}"
310            );
311        }
312    }
313
314    /// Two instants are in the same period exactly when they share a start,
315    /// which is the comparison the ledger's idempotency rests on.
316    #[test]
317    fn the_period_start_is_what_makes_two_instants_the_same_period() {
318        let period = Period::UtcCalendarMonth;
319        let early = period.start_of(at("2026-05-01T00:00:00Z"));
320        let late = period.start_of(at("2026-05-31T23:59:59Z"));
321        let next = period.start_of(at("2026-06-01T00:00:00Z"));
322        assert_eq!(early, late);
323        assert_ne!(late, next);
324        assert_eq!(period.end_after(at("2026-05-31T23:59:59Z")), next);
325    }
326
327    /// A new period variant must join `ALL`, or nothing would ever roll it.
328    /// The match is exhaustive on purpose: adding a variant stops this
329    /// compiling, and the fix is one line in the list above.
330    #[test]
331    fn every_period_is_swept() {
332        for period in Period::ALL {
333            match period {
334                Period::UtcCalendarMonth => {}
335            }
336        }
337        assert_eq!(Period::ALL.len(), 1, "every variant is listed exactly once");
338    }
339
340    /// The names a backend writes into a row. Pinned, because changing one
341    /// silently orphans every schedule already stored under the old spelling —
342    /// a rename is a migration, not an edit.
343    #[test]
344    fn stored_names_are_stable() {
345        assert_eq!(Period::UtcCalendarMonth.as_str(), "utc_calendar_month");
346        assert_eq!(Rollover::None.as_str(), "none");
347    }
348
349    #[test]
350    fn a_monthly_schedule_spells_out_both_defaults() {
351        let schedule = BudgetSchedule::monthly(CostUnits(10_000));
352        assert_eq!(schedule.allowance, CostUnits(10_000));
353        assert_eq!(schedule.period, Period::UtcCalendarMonth);
354        assert_eq!(schedule.rollover, Rollover::None);
355    }
356}