use std::{
num::{NonZeroU16, NonZeroU32},
str::FromStr,
time::Duration,
};
use thiserror::Error;
use crate::{ChargeAmount, PlanKey};
pub const MAX_DUNNING_RETRY_STEPS: usize = 16;
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum SubscriptionTermsError {
#[error("subscription period count must be positive")]
ZeroPeriodCount,
#[error("dunning retry delay must be positive")]
ZeroDunningDelay,
#[error("dunning retry delay must be a whole number of seconds")]
NonWholeSecondDunningDelay,
#[error("dunning retry delay exceeds the supported whole-second range")]
DunningDelaySecondsOutOfRange,
#[error("dunning schedule exceeds the supported retry-step limit")]
TooManyDunningRetrySteps,
#[error("paid-trial and recurring charges must use the same currency")]
CurrencyMismatch,
}
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
#[error("unknown subscription terms value")]
pub struct SubscriptionTermsParseError;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SubscriptionPeriodRule {
FixedDays(NonZeroU16),
CalendarMonths(NonZeroU16),
}
impl SubscriptionPeriodRule {
pub fn fixed_days(count: u16) -> Result<Self, SubscriptionTermsError> {
NonZeroU16::new(count)
.map(Self::FixedDays)
.ok_or(SubscriptionTermsError::ZeroPeriodCount)
}
pub fn calendar_months(count: u16) -> Result<Self, SubscriptionTermsError> {
NonZeroU16::new(count)
.map(Self::CalendarMonths)
.ok_or(SubscriptionTermsError::ZeroPeriodCount)
}
pub fn from_kind_and_count(
kind: &str,
count: u16,
) -> Result<Self, SubscriptionTermsParseError> {
let count = NonZeroU16::new(count).ok_or(SubscriptionTermsParseError)?;
match kind {
"fixed_days" => Ok(Self::FixedDays(count)),
"calendar_months" => Ok(Self::CalendarMonths(count)),
_ => Err(SubscriptionTermsParseError),
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::FixedDays(_) => "fixed_days",
Self::CalendarMonths(_) => "calendar_months",
}
}
pub const fn count(self) -> NonZeroU16 {
match self {
Self::FixedDays(count) | Self::CalendarMonths(count) => count,
}
}
pub const fn is_one_calendar_month(self) -> bool {
matches!(self, Self::CalendarMonths(count) if count.get() == 1)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct RecurringSubscriptionTerms {
charge: ChargeAmount,
period: SubscriptionPeriodRule,
}
impl RecurringSubscriptionTerms {
pub const fn new(charge: ChargeAmount, period: SubscriptionPeriodRule) -> Self {
Self { charge, period }
}
pub const fn charge(self) -> ChargeAmount {
self.charge
}
pub const fn period(self) -> SubscriptionPeriodRule {
self.period
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct PaidTrialTerms {
charge: ChargeAmount,
period: SubscriptionPeriodRule,
}
impl PaidTrialTerms {
pub const fn new(charge: ChargeAmount, period: SubscriptionPeriodRule) -> Self {
Self { charge, period }
}
pub const fn charge(self) -> ChargeAmount {
self.charge
}
pub const fn period(self) -> SubscriptionPeriodRule {
self.period
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SubscriptionStart {
RecurringImmediately,
PaidTrial(PaidTrialTerms),
}
impl SubscriptionStart {
pub const fn as_str(self) -> &'static str {
match self {
Self::RecurringImmediately => "recurring_immediately",
Self::PaidTrial(_) => "paid_trial",
}
}
pub const fn paid_trial(self) -> Option<PaidTrialTerms> {
match self {
Self::RecurringImmediately => None,
Self::PaidTrial(terms) => Some(terms),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct DunningRetryDelay {
seconds: NonZeroU32,
}
impl DunningRetryDelay {
const SECONDS_PER_HOUR: u32 = 60 * 60;
const SECONDS_PER_DAY: u32 = 24 * Self::SECONDS_PER_HOUR;
pub fn new(seconds: u32) -> Result<Self, SubscriptionTermsError> {
NonZeroU32::new(seconds)
.map(|seconds| Self { seconds })
.ok_or(SubscriptionTermsError::ZeroDunningDelay)
}
pub fn from_duration(duration: Duration) -> Result<Self, SubscriptionTermsError> {
if duration.is_zero() {
return Err(SubscriptionTermsError::ZeroDunningDelay);
}
if duration.subsec_nanos() != 0 {
return Err(SubscriptionTermsError::NonWholeSecondDunningDelay);
}
let seconds = u32::try_from(duration.as_secs())
.map_err(|_| SubscriptionTermsError::DunningDelaySecondsOutOfRange)?;
Self::new(seconds)
}
pub fn hours(hours: u32) -> Result<Self, SubscriptionTermsError> {
Self::from_whole_units(hours, Self::SECONDS_PER_HOUR)
}
pub fn days(days: u32) -> Result<Self, SubscriptionTermsError> {
Self::from_whole_units(days, Self::SECONDS_PER_DAY)
}
pub const fn from_non_zero_seconds(seconds: NonZeroU32) -> Self {
Self { seconds }
}
pub const fn seconds(self) -> NonZeroU32 {
self.seconds
}
pub fn duration(self) -> Duration {
Duration::from_secs(u64::from(self.seconds.get()))
}
fn from_whole_units(units: u32, seconds_per_unit: u32) -> Result<Self, SubscriptionTermsError> {
let seconds = units
.checked_mul(seconds_per_unit)
.ok_or(SubscriptionTermsError::DunningDelaySecondsOutOfRange)?;
Self::new(seconds)
}
}
impl TryFrom<Duration> for DunningRetryDelay {
type Error = SubscriptionTermsError;
fn try_from(duration: Duration) -> Result<Self, Self::Error> {
Self::from_duration(duration)
}
}
impl From<DunningRetryDelay> for Duration {
fn from(delay: DunningRetryDelay) -> Self {
delay.duration()
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct DunningSchedule {
retry_delays: Vec<DunningRetryDelay>,
}
impl DunningSchedule {
pub fn new(retry_delays: Vec<DunningRetryDelay>) -> Result<Self, SubscriptionTermsError> {
if retry_delays.len() > MAX_DUNNING_RETRY_STEPS {
return Err(SubscriptionTermsError::TooManyDunningRetrySteps);
}
Ok(Self { retry_delays })
}
pub fn from_delays<I>(retry_delays: I) -> Result<Self, SubscriptionTermsError>
where
I: IntoIterator<Item = DunningRetryDelay>,
{
let retry_delays = retry_delays
.into_iter()
.take(MAX_DUNNING_RETRY_STEPS + 1)
.collect();
Self::new(retry_delays)
}
pub fn from_seconds<I>(seconds: I) -> Result<Self, SubscriptionTermsError>
where
I: IntoIterator<Item = u32>,
{
let retry_delays = seconds
.into_iter()
.take(MAX_DUNNING_RETRY_STEPS + 1)
.map(DunningRetryDelay::new)
.collect::<Result<Vec<_>, _>>()?;
Self::new(retry_delays)
}
pub fn retry_delays(&self) -> &[DunningRetryDelay] {
&self.retry_delays
}
pub fn is_empty(&self) -> bool {
self.retry_delays.is_empty()
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum DunningExhaustion {
RemainPastDue,
MarkUnpaid,
}
impl DunningExhaustion {
pub const ALL: [Self; 2] = [Self::RemainPastDue, Self::MarkUnpaid];
pub const fn as_str(self) -> &'static str {
match self {
Self::RemainPastDue => "remain_past_due",
Self::MarkUnpaid => "mark_unpaid",
}
}
}
impl FromStr for DunningExhaustion {
type Err = SubscriptionTermsParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"remain_past_due" => Ok(Self::RemainPastDue),
"mark_unpaid" => Ok(Self::MarkUnpaid),
_ => Err(SubscriptionTermsParseError),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum PastDueAccessPolicy {
SuspendImmediately,
ContinueUntilDunningExhausted,
}
impl PastDueAccessPolicy {
pub const ALL: [Self; 2] = [
Self::SuspendImmediately,
Self::ContinueUntilDunningExhausted,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::SuspendImmediately => "suspend_immediately",
Self::ContinueUntilDunningExhausted => "continue_until_dunning_exhausted",
}
}
}
impl FromStr for PastDueAccessPolicy {
type Err = SubscriptionTermsParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"suspend_immediately" => Ok(Self::SuspendImmediately),
"continue_until_dunning_exhausted" => Ok(Self::ContinueUntilDunningExhausted),
_ => Err(SubscriptionTermsParseError),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RenewalFailurePolicy {
schedule: DunningSchedule,
exhaustion: DunningExhaustion,
past_due_access: PastDueAccessPolicy,
}
impl RenewalFailurePolicy {
pub const fn new(
schedule: DunningSchedule,
exhaustion: DunningExhaustion,
past_due_access: PastDueAccessPolicy,
) -> Self {
Self {
schedule,
exhaustion,
past_due_access,
}
}
pub const fn schedule(&self) -> &DunningSchedule {
&self.schedule
}
pub const fn exhaustion(&self) -> DunningExhaustion {
self.exhaustion
}
pub const fn past_due_access(&self) -> PastDueAccessPolicy {
self.past_due_access
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubscriptionOffer {
plan_key: PlanKey,
recurring: RecurringSubscriptionTerms,
start: SubscriptionStart,
renewal_failure: RenewalFailurePolicy,
}
impl SubscriptionOffer {
pub fn new(
plan_key: PlanKey,
recurring: RecurringSubscriptionTerms,
start: SubscriptionStart,
renewal_failure: RenewalFailurePolicy,
) -> Result<Self, SubscriptionTermsError> {
if let SubscriptionStart::PaidTrial(trial) = start
&& trial.charge().currency() != recurring.charge().currency()
{
return Err(SubscriptionTermsError::CurrencyMismatch);
}
Ok(Self {
plan_key,
recurring,
start,
renewal_failure,
})
}
pub const fn plan_key(&self) -> &PlanKey {
&self.plan_key
}
pub const fn recurring(&self) -> RecurringSubscriptionTerms {
self.recurring
}
pub const fn start(&self) -> SubscriptionStart {
self.start
}
pub const fn renewal_failure(&self) -> &RenewalFailurePolicy {
&self.renewal_failure
}
pub const fn currency(&self) -> crate::CurrencyCode {
self.recurring.charge().currency()
}
pub(crate) fn has_same_terms_except_recurring_amount(&self, other: &Self) -> bool {
self.plan_key == other.plan_key
&& self.currency() == other.currency()
&& self.recurring.period() == other.recurring.period()
&& self.start == other.start
&& self.renewal_failure == other.renewal_failure
}
}
#[cfg(test)]
mod tests {
use std::{cell::Cell, time::Duration};
use super::*;
use crate::CurrencyCode;
#[test]
fn period_and_delay_constructors_reject_zero() {
assert_eq!(
SubscriptionPeriodRule::fixed_days(0),
Err(SubscriptionTermsError::ZeroPeriodCount)
);
assert_eq!(
SubscriptionPeriodRule::calendar_months(0),
Err(SubscriptionTermsError::ZeroPeriodCount)
);
assert_eq!(
DunningRetryDelay::new(0),
Err(SubscriptionTermsError::ZeroDunningDelay)
);
assert_eq!(
DunningRetryDelay::from_duration(Duration::ZERO),
Err(SubscriptionTermsError::ZeroDunningDelay)
);
assert_eq!(
DunningRetryDelay::hours(0),
Err(SubscriptionTermsError::ZeroDunningDelay)
);
assert_eq!(
DunningRetryDelay::days(0),
Err(SubscriptionTermsError::ZeroDunningDelay)
);
}
#[test]
fn duration_conversion_requires_representable_whole_seconds() {
let one_second = DunningRetryDelay::from_duration(Duration::from_secs(1)).unwrap();
assert_eq!(one_second.seconds().get(), 1);
assert_eq!(one_second.duration(), Duration::from_secs(1));
assert_eq!(Duration::from(one_second), Duration::from_secs(1));
assert_eq!(
DunningRetryDelay::try_from(Duration::from_secs(1)),
Ok(one_second)
);
let max =
DunningRetryDelay::from_duration(Duration::from_secs(u64::from(u32::MAX))).unwrap();
assert_eq!(max.seconds().get(), u32::MAX);
assert_eq!(
DunningRetryDelay::from_duration(Duration::from_millis(1)),
Err(SubscriptionTermsError::NonWholeSecondDunningDelay)
);
assert_eq!(
DunningRetryDelay::from_duration(Duration::new(1, 1)),
Err(SubscriptionTermsError::NonWholeSecondDunningDelay)
);
assert_eq!(
DunningRetryDelay::from_duration(Duration::from_secs(u64::from(u32::MAX) + 1)),
Err(SubscriptionTermsError::DunningDelaySecondsOutOfRange)
);
}
#[test]
fn whole_hour_and_day_factories_are_checked() {
assert_eq!(DunningRetryDelay::hours(1).unwrap().seconds().get(), 3_600);
assert_eq!(DunningRetryDelay::days(1).unwrap().seconds().get(), 86_400);
assert_eq!(DunningRetryDelay::days(3).unwrap().seconds().get(), 259_200);
assert_eq!(
DunningRetryDelay::hours(u32::MAX),
Err(SubscriptionTermsError::DunningDelaySecondsOutOfRange)
);
assert_eq!(
DunningRetryDelay::days(u32::MAX),
Err(SubscriptionTermsError::DunningDelaySecondsOutOfRange)
);
}
#[test]
fn schedule_accepts_arrays_through_the_retry_cap() {
assert!(DunningSchedule::new(Vec::new()).unwrap().is_empty());
let delay = DunningRetryDelay::new(1).unwrap();
assert_eq!(
DunningSchedule::from_delays([delay; MAX_DUNNING_RETRY_STEPS])
.unwrap()
.retry_delays()
.len(),
MAX_DUNNING_RETRY_STEPS
);
assert_eq!(
DunningSchedule::from_delays([delay; MAX_DUNNING_RETRY_STEPS + 1]),
Err(SubscriptionTermsError::TooManyDunningRetrySteps)
);
}
#[test]
fn persisted_schedule_stops_after_observing_one_value_beyond_the_cap() {
let requested = Cell::new(0);
let seconds = std::iter::from_fn(|| {
let next = requested.get() + 1;
assert!(
next <= MAX_DUNNING_RETRY_STEPS + 1,
"from_seconds requested an unnecessary value"
);
requested.set(next);
Some(1)
});
assert_eq!(
DunningSchedule::from_seconds(seconds),
Err(SubscriptionTermsError::TooManyDunningRetrySteps)
);
assert_eq!(requested.get(), MAX_DUNNING_RETRY_STEPS + 1);
}
#[test]
fn paid_trial_and_recurring_currency_must_match() {
let usd = CurrencyCode::new("USD").unwrap();
let eur = CurrencyCode::new("EUR").unwrap();
let recurring = RecurringSubscriptionTerms::new(
ChargeAmount::new(1_000, usd).unwrap(),
SubscriptionPeriodRule::calendar_months(1).unwrap(),
);
let trial = PaidTrialTerms::new(
ChargeAmount::new(100, eur).unwrap(),
SubscriptionPeriodRule::fixed_days(7).unwrap(),
);
assert_eq!(
SubscriptionOffer::new(
PlanKey::new("basic").unwrap(),
recurring,
SubscriptionStart::PaidTrial(trial),
RenewalFailurePolicy::new(
DunningSchedule::default(),
DunningExhaustion::MarkUnpaid,
PastDueAccessPolicy::ContinueUntilDunningExhausted,
),
),
Err(SubscriptionTermsError::CurrencyMismatch)
);
}
#[test]
fn offer_comparison_ignores_only_the_recurring_amount() {
fn offer(recurring_cents: i32, trial_cents: i32) -> SubscriptionOffer {
let usd = CurrencyCode::new("USD").unwrap();
SubscriptionOffer::new(
PlanKey::new("basic").unwrap(),
RecurringSubscriptionTerms::new(
ChargeAmount::new(recurring_cents, usd).unwrap(),
SubscriptionPeriodRule::calendar_months(1).unwrap(),
),
SubscriptionStart::PaidTrial(PaidTrialTerms::new(
ChargeAmount::new(trial_cents, usd).unwrap(),
SubscriptionPeriodRule::fixed_days(7).unwrap(),
)),
RenewalFailurePolicy::new(
DunningSchedule::default(),
DunningExhaustion::MarkUnpaid,
PastDueAccessPolicy::ContinueUntilDunningExhausted,
),
)
.unwrap()
}
fn immediate_offer(currency: &str) -> SubscriptionOffer {
SubscriptionOffer::new(
PlanKey::new("basic").unwrap(),
RecurringSubscriptionTerms::new(
ChargeAmount::new(1_000, CurrencyCode::new(currency).unwrap()).unwrap(),
SubscriptionPeriodRule::calendar_months(1).unwrap(),
),
SubscriptionStart::RecurringImmediately,
RenewalFailurePolicy::new(
DunningSchedule::default(),
DunningExhaustion::MarkUnpaid,
PastDueAccessPolicy::ContinueUntilDunningExhausted,
),
)
.unwrap()
}
let accepted = offer(1_000, 100);
assert!(accepted.has_same_terms_except_recurring_amount(&offer(1_200, 100)));
assert!(!accepted.has_same_terms_except_recurring_amount(&offer(1_000, 200)));
assert!(
!immediate_offer("USD").has_same_terms_except_recurring_amount(&immediate_offer("EUR"))
);
}
#[test]
fn persisted_policy_values_round_trip_exhaustively() {
for value in DunningExhaustion::ALL {
assert_eq!(value.as_str().parse(), Ok(value));
}
for value in PastDueAccessPolicy::ALL {
assert_eq!(value.as_str().parse(), Ok(value));
}
for rule in [
SubscriptionPeriodRule::fixed_days(7).unwrap(),
SubscriptionPeriodRule::calendar_months(1).unwrap(),
] {
assert_eq!(
SubscriptionPeriodRule::from_kind_and_count(rule.as_str(), rule.count().get()),
Ok(rule)
);
}
}
}