use chrono::{DateTime, Utc};
use sqlx::{Row, postgres::PgRow};
use syrup_rail::{
BillingPeriod, ChargeAmount, CurrencyCode, DunningExhaustion, DunningRetryDelay,
DunningSchedule, GatewayAccountMode, PastDueAccessPolicy, PaymentMethodId, PlanKey,
RenewalFailurePolicy, Subscription, SubscriptionId, SubscriptionPeriodRule, SubscriptionPhase,
SubscriptionStatus,
};
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub(crate) enum SubscriptionPersistenceCodecError {
#[error("subscription persistence row could not be read")]
RowRead(#[from] sqlx::Error),
#[error("canonical subscription persistence state is invalid")]
InvalidState,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct SubscriptionPeriodRuleScalars<'a> {
kind: &'a str,
count: i32,
}
impl<'a> SubscriptionPeriodRuleScalars<'a> {
pub(crate) const fn new(kind: &'a str, count: i32) -> Self {
Self { kind, count }
}
}
#[derive(Debug)]
pub(crate) struct RenewalFailurePolicyScalars<'a> {
retry_delays_seconds: Vec<i64>,
exhaustion: &'a str,
past_due_access: &'a str,
}
impl<'a> RenewalFailurePolicyScalars<'a> {
pub(crate) const fn new(
retry_delays_seconds: Vec<i64>,
exhaustion: &'a str,
past_due_access: &'a str,
) -> Self {
Self {
retry_delays_seconds,
exhaustion,
past_due_access,
}
}
}
pub(crate) fn subscription_period_rule_from_scalars(
scalars: SubscriptionPeriodRuleScalars<'_>,
) -> Result<SubscriptionPeriodRule, SubscriptionPersistenceCodecError> {
SubscriptionPeriodRule::from_kind_and_count(
scalars.kind,
u16::try_from(scalars.count)
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
)
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)
}
pub(crate) fn renewal_failure_policy_from_scalars(
scalars: RenewalFailurePolicyScalars<'_>,
) -> Result<RenewalFailurePolicy, SubscriptionPersistenceCodecError> {
let schedule = DunningSchedule::new(
scalars
.retry_delays_seconds
.into_iter()
.map(|seconds| {
DunningRetryDelay::new(
u32::try_from(seconds)
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
)
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)
})
.collect::<Result<Vec<_>, _>>()?,
)
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?;
Ok(RenewalFailurePolicy::new(
schedule,
scalars
.exhaustion
.parse::<DunningExhaustion>()
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
scalars
.past_due_access
.parse::<PastDueAccessPolicy>()
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
))
}
pub(crate) fn subscription_from_row(
row: &PgRow,
) -> Result<Subscription, SubscriptionPersistenceCodecError> {
FullSubscriptionRow::read(row)?.into_subscription()
}
struct FullSubscriptionRow {
id: Uuid,
plan_key: String,
status: String,
required_gateway_account_mode: String,
payment_method_id: Uuid,
amount_cents: i32,
currency: String,
current_period_start_at: DateTime<Utc>,
current_period_end_at: DateTime<Utc>,
next_renewal_at: DateTime<Utc>,
phase: String,
recurring_period_kind: String,
recurring_period_count: i32,
dunning_retry_delays_seconds: Vec<i64>,
dunning_exhaustion: String,
past_due_access: String,
next_payment_attempt_at: Option<DateTime<Utc>>,
}
impl FullSubscriptionRow {
fn read(row: &PgRow) -> Result<Self, SubscriptionPersistenceCodecError> {
Ok(Self {
id: row.try_get("id")?,
plan_key: row.try_get("plan_key")?,
status: row.try_get("status")?,
required_gateway_account_mode: row.try_get("required_gateway_account_mode")?,
payment_method_id: row.try_get("payment_method_id")?,
amount_cents: row.try_get("amount_cents")?,
currency: row.try_get("currency")?,
current_period_start_at: row.try_get("current_period_start_at")?,
current_period_end_at: row.try_get("current_period_end_at")?,
next_renewal_at: row.try_get("next_renewal_at")?,
phase: row.try_get("phase")?,
recurring_period_kind: row.try_get("recurring_period_kind")?,
recurring_period_count: row.try_get("recurring_period_count")?,
dunning_retry_delays_seconds: row.try_get("dunning_retry_delays_seconds")?,
dunning_exhaustion: row.try_get("dunning_exhaustion")?,
past_due_access: row.try_get("past_due_access")?,
next_payment_attempt_at: row.try_get("next_payment_attempt_at")?,
})
}
fn into_subscription(self) -> Result<Subscription, SubscriptionPersistenceCodecError> {
let Self {
id,
plan_key,
status,
required_gateway_account_mode,
payment_method_id,
amount_cents,
currency,
current_period_start_at,
current_period_end_at,
next_renewal_at,
phase,
recurring_period_kind,
recurring_period_count,
dunning_retry_delays_seconds,
dunning_exhaustion,
past_due_access,
next_payment_attempt_at,
} = self;
let recurring_period = subscription_period_rule_from_scalars(
SubscriptionPeriodRuleScalars::new(&recurring_period_kind, recurring_period_count),
)?;
let renewal_failure =
renewal_failure_policy_from_scalars(RenewalFailurePolicyScalars::new(
dunning_retry_delays_seconds,
&dunning_exhaustion,
&past_due_access,
))?;
Ok(Subscription::new(
SubscriptionId::new(id),
PlanKey::new(plan_key).map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
status
.parse::<SubscriptionStatus>()
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
phase
.parse::<SubscriptionPhase>()
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
required_gateway_account_mode
.parse::<GatewayAccountMode>()
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
PaymentMethodId::new(payment_method_id),
ChargeAmount::new(
amount_cents,
CurrencyCode::new(¤cy)
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
)
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
recurring_period,
renewal_failure,
BillingPeriod::new(current_period_start_at, current_period_end_at)
.map_err(|_| SubscriptionPersistenceCodecError::InvalidState)?,
next_renewal_at,
next_payment_attempt_at,
))
}
}
#[cfg(test)]
mod tests {
use syrup_rail::{DunningExhaustion, PastDueAccessPolicy, SubscriptionPeriodRule};
use super::*;
#[test]
fn period_scalars_decode_fixed_days_and_calendar_months() {
assert_eq!(
subscription_period_rule_from_scalars(SubscriptionPeriodRuleScalars::new(
"fixed_days",
7,
))
.expect("valid fixed-day persistence scalar"),
SubscriptionPeriodRule::fixed_days(7).expect("valid fixed-day cadence"),
);
assert_eq!(
subscription_period_rule_from_scalars(SubscriptionPeriodRuleScalars::new(
"calendar_months",
3,
))
.expect("valid monthly persistence scalar"),
SubscriptionPeriodRule::calendar_months(3).expect("valid monthly cadence"),
);
}
#[test]
fn period_scalars_reject_zero_negative_overflow_and_unknown_values() {
for scalars in [
SubscriptionPeriodRuleScalars::new("fixed_days", 0),
SubscriptionPeriodRuleScalars::new("fixed_days", -1),
SubscriptionPeriodRuleScalars::new("calendar_months", i32::MAX),
SubscriptionPeriodRuleScalars::new("billing_weeks", 1),
] {
assert!(matches!(
subscription_period_rule_from_scalars(scalars),
Err(SubscriptionPersistenceCodecError::InvalidState)
));
}
}
#[test]
fn renewal_failure_scalars_decode_valid_policy() {
let policy = renewal_failure_policy_from_scalars(RenewalFailurePolicyScalars::new(
vec![60, 3_600],
"mark_unpaid",
"continue_until_dunning_exhausted",
))
.expect("valid dunning policy");
assert_eq!(policy.exhaustion(), DunningExhaustion::MarkUnpaid);
assert_eq!(
policy.past_due_access(),
PastDueAccessPolicy::ContinueUntilDunningExhausted
);
assert_eq!(
policy
.schedule()
.retry_delays()
.iter()
.map(|delay| delay.seconds().get())
.collect::<Vec<_>>(),
vec![60, 3_600]
);
}
#[test]
fn renewal_failure_scalars_reject_invalid_delay_and_policy_values() {
for retry_delays_seconds in [vec![0], vec![-1], vec![i64::from(u32::MAX) + 1]] {
assert!(matches!(
renewal_failure_policy_from_scalars(RenewalFailurePolicyScalars::new(
retry_delays_seconds,
"mark_unpaid",
"suspend_immediately",
)),
Err(SubscriptionPersistenceCodecError::InvalidState)
));
}
for (exhaustion, past_due_access) in [
("unknown", "suspend_immediately"),
("mark_unpaid", "unknown"),
] {
assert!(matches!(
renewal_failure_policy_from_scalars(RenewalFailurePolicyScalars::new(
vec![60],
exhaustion,
past_due_access,
)),
Err(SubscriptionPersistenceCodecError::InvalidState)
));
}
assert!(matches!(
renewal_failure_policy_from_scalars(RenewalFailurePolicyScalars::new(
vec![60; 17],
"mark_unpaid",
"suspend_immediately",
)),
Err(SubscriptionPersistenceCodecError::InvalidState)
));
}
}