Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
182 lines
6.1 KiB
Rust
182 lines
6.1 KiB
Rust
//! Settlement cutoff schedules.
|
|
//!
|
|
//! A cutoff is the rule that decides *when* a batch closes. The
|
|
//! settlement engine ticks the cutoff with a wall-clock timestamp;
|
|
//! the cutoff says whether to close the currently-open batch.
|
|
//!
|
|
//! We keep the model deliberately small (three variants — `Daily`,
|
|
//! `MultiDaily`, `Manual`) because almost every real-world payment
|
|
//! processor uses one of these patterns. Operators with stranger
|
|
//! schedules construct a `Manual` cutoff and drive it themselves.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// How frequently the batch closes.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Cutoff {
|
|
/// Once a day at the given UTC hour `[0, 23]`. Most card
|
|
/// processors run nightly cutoffs around 02:00 ET — operators
|
|
/// pass `7` (UTC) for that.
|
|
Daily {
|
|
/// UTC hour of day, `[0, 23]`.
|
|
hour_utc: u8,
|
|
},
|
|
|
|
/// Multiple times a day. Common for instant-rail operators
|
|
/// (`FedNow`, RTP) who pay out same-day multiple times. Hours
|
|
/// must be in ascending order and each in `[0, 23]`.
|
|
MultiDaily {
|
|
/// Sorted ascending UTC hours.
|
|
hours_utc: Vec<u8>,
|
|
},
|
|
|
|
/// Never auto-closes. Operator calls
|
|
/// [`crate::SettlementEngine::close_batch`] explicitly. Useful
|
|
/// for tests and operators with bespoke schedules (e.g. weekly,
|
|
/// hourly, signal-driven).
|
|
Manual,
|
|
}
|
|
|
|
impl Cutoff {
|
|
/// Daily at `hour_utc` (`[0, 23]`).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::Error::Invalid`] if `hour_utc >= 24`.
|
|
pub fn daily(hour_utc: u8) -> crate::Result<Self> {
|
|
if hour_utc >= 24 {
|
|
return Err(crate::Error::Invalid(format!(
|
|
"hour_utc {hour_utc} not in [0,23]"
|
|
)));
|
|
}
|
|
Ok(Self::Daily { hour_utc })
|
|
}
|
|
|
|
/// Multiple times a day. Hours must be ascending and all
|
|
/// `[0, 23]`. Empty list is rejected.
|
|
///
|
|
/// # Errors
|
|
/// [`crate::Error::Invalid`] on empty / out-of-range / unsorted.
|
|
pub fn multi_daily(hours_utc: Vec<u8>) -> crate::Result<Self> {
|
|
if hours_utc.is_empty() {
|
|
return Err(crate::Error::Invalid("multi_daily needs ≥1 hour".into()));
|
|
}
|
|
let mut last: i16 = -1;
|
|
for h in &hours_utc {
|
|
if *h >= 24 {
|
|
return Err(crate::Error::Invalid(format!("hour {h} not in [0,23]")));
|
|
}
|
|
if i16::from(*h) <= last {
|
|
return Err(crate::Error::Invalid(
|
|
"multi_daily hours must be strictly ascending".into(),
|
|
));
|
|
}
|
|
last = i16::from(*h);
|
|
}
|
|
Ok(Self::MultiDaily { hours_utc })
|
|
}
|
|
|
|
/// Returns `true` if the cutoff fires for the second-since-epoch
|
|
/// `now_unix_secs`, given the previous tick observed at
|
|
/// `last_tick_unix_secs` (the batch's `opened_at` or the last
|
|
/// cutoff fire). The semantics: did a scheduled cutoff hour
|
|
/// occur in `(last_tick, now_unix_secs]`?
|
|
///
|
|
/// `Manual` never fires automatically.
|
|
#[must_use]
|
|
pub fn should_close(&self, last_tick_unix_secs: u64, now_unix_secs: u64) -> bool {
|
|
if now_unix_secs <= last_tick_unix_secs {
|
|
return false;
|
|
}
|
|
match self {
|
|
Self::Manual => false,
|
|
Self::Daily { hour_utc } => hour_crossed(last_tick_unix_secs, now_unix_secs, *hour_utc),
|
|
Self::MultiDaily { hours_utc } => hours_utc
|
|
.iter()
|
|
.any(|h| hour_crossed(last_tick_unix_secs, now_unix_secs, *h)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// True iff the `(last, now]` window covers a UTC-`hour:00:00`
|
|
/// boundary on any calendar day.
|
|
///
|
|
/// Implementation walks at most a handful of days — the engine
|
|
/// ticks at least daily in practice, and even months of arrears
|
|
/// settle in `<366` iterations.
|
|
fn hour_crossed(last_unix_secs: u64, now_unix_secs: u64, hour_utc: u8) -> bool {
|
|
const DAY: u64 = 86_400;
|
|
if now_unix_secs <= last_unix_secs {
|
|
return false;
|
|
}
|
|
let hour_offset = u64::from(hour_utc) * 3_600;
|
|
// Find the first cutoff timestamp strictly after `last`.
|
|
let day_floor = (last_unix_secs / DAY) * DAY;
|
|
let mut candidate = day_floor + hour_offset;
|
|
if candidate <= last_unix_secs {
|
|
candidate += DAY;
|
|
}
|
|
candidate <= now_unix_secs
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn daily_rejects_bad_hour() {
|
|
assert!(Cutoff::daily(24).is_err());
|
|
assert!(Cutoff::daily(7).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn multi_daily_rejects_empty() {
|
|
assert!(Cutoff::multi_daily(vec![]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn multi_daily_rejects_unsorted() {
|
|
assert!(Cutoff::multi_daily(vec![10, 4, 16]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn multi_daily_rejects_duplicate() {
|
|
assert!(Cutoff::multi_daily(vec![6, 6]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn manual_never_fires() {
|
|
assert!(!Cutoff::Manual.should_close(0, u64::from(u32::MAX)));
|
|
}
|
|
|
|
#[test]
|
|
fn daily_fires_when_hour_crossed() {
|
|
// 1700000000 = Tue Nov 14 22:13:20 UTC 2023
|
|
// Daily(07) -> next 07:00 is Nov 15 07:00:00 = 1700031600 + ?
|
|
let last = 1_700_000_000;
|
|
let same_day = last + 3_600; // still Nov 14 23:13:20 UTC
|
|
let next_day_after_07 = 1_700_032_000; // Nov 15 ~07:06 UTC
|
|
let c = Cutoff::daily(7).unwrap();
|
|
assert!(!c.should_close(last, same_day));
|
|
assert!(c.should_close(last, next_day_after_07));
|
|
}
|
|
|
|
#[test]
|
|
fn daily_idempotent_within_same_window() {
|
|
// Once we've ticked past 07:00, ticking again at 08:00
|
|
// (`last=07:30, now=08:00`) should not re-fire — the
|
|
// 07:00 boundary is already behind `last`.
|
|
let last_after_fire = 1_700_032_200; // ~07:10
|
|
let later_same_day = 1_700_036_000; // ~08:13
|
|
let c = Cutoff::daily(7).unwrap();
|
|
assert!(!c.should_close(last_after_fire, later_same_day));
|
|
}
|
|
|
|
#[test]
|
|
fn multi_daily_fires_on_either_hour() {
|
|
let c = Cutoff::multi_daily(vec![7, 19]).unwrap();
|
|
// 1700060400 = Nov 15 14:20:00, after the 07:00 cutoff.
|
|
let last = 1_700_060_400;
|
|
let after_19 = 1_700_080_000; // ~19:46 UTC
|
|
assert!(c.should_close(last, after_19));
|
|
}
|
|
}
|