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}