use std::fmt;
use crate::{
BillingContact, BillingContactSnapshot, BillingPeriod, BillingScopeId, ChargeAmount,
GatewayAccountId, GatewayAccountMode, GatewayConfigurationId, GatewayProviderKey,
GatewayTransactionId, IdempotencyKey, PaymentAttempt, PaymentAttemptFingerprint,
PaymentAttemptId, PaymentAttemptIdentity, PaymentAttemptKind, PaymentAttemptRequest,
PaymentAttemptTarget, PaymentMethodId, PaymentToken, PlanKey, ResolvedGateway, SubscriberId,
SubscriptionId, SubscriptionPaymentContext, SubscriptionPaymentStateSnapshot,
SubscriptionStatus,
};
use thiserror::Error;
#[derive(Clone)]
pub struct RecoverSubscriptionPayment {
context: SubscriptionPaymentContext,
plan_key: PlanKey,
}
impl RecoverSubscriptionPayment {
pub const fn new(context: SubscriptionPaymentContext, plan_key: PlanKey) -> Self {
Self { context, plan_key }
}
pub const fn context(&self) -> &SubscriptionPaymentContext {
&self.context
}
pub const fn attempt_id(&self) -> PaymentAttemptId {
self.context.attempt_id()
}
pub const fn billing_scope_id(&self) -> BillingScopeId {
self.context.billing_scope_id()
}
pub const fn subscriber_id(&self) -> SubscriberId {
self.context.subscriber_id()
}
pub const fn plan_key(&self) -> &PlanKey {
&self.plan_key
}
pub const fn gateway_configuration_id(&self) -> GatewayConfigurationId {
self.context.gateway_configuration_id()
}
pub const fn idempotency_key(&self) -> &IdempotencyKey {
self.context.idempotency_key()
}
pub const fn payment_token(&self) -> &PaymentToken {
self.context.payment_token()
}
pub const fn billing_contact(&self) -> &BillingContact {
self.context.billing_contact()
}
}
impl fmt::Debug for RecoverSubscriptionPayment {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RecoverSubscriptionPayment")
.field("attempt_id", &self.attempt_id())
.field("billing_scope_id", &self.billing_scope_id())
.field("subscriber_id", &self.subscriber_id())
.field("plan_key", &self.plan_key)
.field("gateway_configuration_id", &self.gateway_configuration_id())
.field("has_idempotency_key", &true)
.field("has_payment_token", &true)
.field("has_billing_contact", &true)
.finish()
}
}
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum SubscriptionRecoveryReservationBuildError {
#[error("resolved gateway identity does not match the recovery command")]
GatewayIdentityMismatch,
#[error("only subscription-recovery attempts can become recovery reservations")]
AttemptKindMismatch,
#[error("subscription recovery has an invalid payment-state snapshot")]
InvalidPaymentState,
#[error("subscription recovery attempt has an invalid charge amount")]
InvalidCharge,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubscriptionRecoveryLockedTerms {
gateway_account_id: GatewayAccountId,
expected_state: SubscriptionPaymentStateSnapshot,
period: BillingPeriod,
charge: ChargeAmount,
}
impl SubscriptionRecoveryLockedTerms {
pub const fn new(
gateway_account_id: GatewayAccountId,
expected_state: SubscriptionPaymentStateSnapshot,
period: BillingPeriod,
charge: ChargeAmount,
) -> Self {
Self {
gateway_account_id,
expected_state,
period,
charge,
}
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct SubscriptionRecoveryReservation {
identity: PaymentAttemptIdentity,
provider_key: GatewayProviderKey,
request: PaymentAttemptRequest,
}
impl SubscriptionRecoveryReservation {
#[allow(clippy::too_many_arguments)]
pub fn from_locked_subscription(
command: &RecoverSubscriptionPayment,
gateway: &ResolvedGateway,
attempt_id: PaymentAttemptId,
subscription_id: SubscriptionId,
payment_method_id: PaymentMethodId,
initial_transaction_id: GatewayTransactionId,
status: SubscriptionStatus,
period: BillingPeriod,
charge: ChargeAmount,
required_gateway_account_mode: GatewayAccountMode,
) -> Result<Self, SubscriptionRecoveryReservationBuildError> {
if gateway.billing_scope_id() != command.billing_scope_id()
|| gateway.gateway_configuration_id() != command.gateway_configuration_id()
{
return Err(SubscriptionRecoveryReservationBuildError::GatewayIdentityMismatch);
}
let expected_state = SubscriptionPaymentStateSnapshot::new(
subscription_id,
payment_method_id,
initial_transaction_id,
status,
)
.map_err(|_| SubscriptionRecoveryReservationBuildError::InvalidPaymentState)?;
Self::from_locked_subscription_terms(
command,
gateway,
attempt_id,
SubscriptionRecoveryLockedTerms::new(
gateway.gateway_account_id(),
expected_state,
period,
charge,
),
required_gateway_account_mode,
)
}
pub fn from_locked_subscription_terms(
command: &RecoverSubscriptionPayment,
gateway: &ResolvedGateway,
attempt_id: PaymentAttemptId,
terms: SubscriptionRecoveryLockedTerms,
required_gateway_account_mode: GatewayAccountMode,
) -> Result<Self, SubscriptionRecoveryReservationBuildError> {
let SubscriptionRecoveryLockedTerms {
gateway_account_id,
expected_state,
period,
charge,
} = terms;
if gateway.billing_scope_id() != command.billing_scope_id()
|| gateway.gateway_configuration_id() != command.gateway_configuration_id()
|| gateway_account_id != gateway.gateway_account_id()
{
return Err(SubscriptionRecoveryReservationBuildError::GatewayIdentityMismatch);
}
let identity = PaymentAttemptIdentity::new(
attempt_id,
command.billing_scope_id(),
command.subscriber_id(),
gateway_account_id,
gateway.gateway_configuration_id(),
required_gateway_account_mode,
);
let fingerprint = PaymentAttemptFingerprint::for_subscription_recovery(
command.plan_key(),
expected_state.subscription_id(),
expected_state.payment_method_id(),
*period.start_at(),
charge.money(),
);
let target = PaymentAttemptTarget::SubscriptionRecovery {
plan_key: command.plan_key().clone(),
payment_method_id: expected_state.payment_method_id(),
period,
expected_state,
};
let request = PaymentAttemptRequest::new(
target,
command.idempotency_key().clone(),
fingerprint,
charge.money(),
gateway
.mutation_reference_factory()
.for_attempt(PaymentAttemptKind::SubscriptionRecovery, attempt_id),
BillingContactSnapshot::from_billing_contact(command.billing_contact()),
);
Ok(Self {
identity,
provider_key: gateway.provider_key().clone(),
request,
})
}
pub fn from_attempt(
attempt: &PaymentAttempt,
provider_key: GatewayProviderKey,
) -> Result<Self, SubscriptionRecoveryReservationBuildError> {
let PaymentAttemptTarget::SubscriptionRecovery {
plan_key,
period,
expected_state,
..
} = attempt.request().target()
else {
return Err(SubscriptionRecoveryReservationBuildError::AttemptKindMismatch);
};
ChargeAmount::try_from(attempt.request().amount())
.map_err(|_| SubscriptionRecoveryReservationBuildError::InvalidCharge)?;
if !attempt
.request()
.fingerprint()
.matches_subscription_recovery(
plan_key,
expected_state.subscription_id(),
expected_state.payment_method_id(),
*period.start_at(),
attempt.request().amount(),
)
{
return Err(SubscriptionRecoveryReservationBuildError::AttemptKindMismatch);
}
Ok(Self {
identity: attempt.identity(),
provider_key,
request: attempt.request().clone(),
})
}
pub fn matches_submission(
&self,
command: &RecoverSubscriptionPayment,
gateway: &ResolvedGateway,
) -> bool {
let Ok(charge) = ChargeAmount::try_from(self.request.amount()) else {
return false;
};
Self::from_locked_subscription_terms(
command,
gateway,
self.identity.attempt_id(),
SubscriptionRecoveryLockedTerms::new(
self.identity.gateway_account_id(),
self.expected_state().clone(),
self.period().clone(),
charge,
),
self.identity.required_gateway_account_mode(),
)
.is_ok_and(|candidate| candidate.eq(self))
}
pub const fn identity(&self) -> PaymentAttemptIdentity {
self.identity
}
pub const fn provider_key(&self) -> &GatewayProviderKey {
&self.provider_key
}
pub const fn request(&self) -> &PaymentAttemptRequest {
&self.request
}
pub const fn plan_key(&self) -> &PlanKey {
match self.request.target().plan_key() {
Some(plan_key) => plan_key,
None => unreachable!(),
}
}
pub const fn subscription_id(&self) -> SubscriptionId {
match self.request.target().subscription_id() {
Some(subscription_id) => subscription_id,
None => unreachable!(),
}
}
pub const fn period(&self) -> &BillingPeriod {
match self.request.target().period() {
Some(period) => period,
None => unreachable!(),
}
}
pub const fn expected_state(&self) -> &SubscriptionPaymentStateSnapshot {
match self.request.target().subscription_payment_state_snapshot() {
Some(expected_state) => expected_state,
None => unreachable!(),
}
}
}
impl fmt::Debug for SubscriptionRecoveryReservation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SubscriptionRecoveryReservation")
.field("identity", &self.identity)
.field("provider_key", &self.provider_key)
.field("request", &self.request)
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SubscriptionRecoveryReservationRejection {
SubscriptionNotFound,
PaymentNotDue,
AttemptInProgress,
PaymentMethodUpdateInProgress,
GatewayConfigurationChanged,
GatewayAccountModeChanged,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SubscriptionRecoveryReservationOutcome {
Reserved(Box<SubscriptionRecoveryReservation>, Box<PaymentAttempt>),
Replay(Box<PaymentAttempt>),
IdempotencyConflict,
Rejected(SubscriptionRecoveryReservationRejection),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SubscriptionRecoveryPreflightOutcome {
Continue,
Replay(Box<PaymentAttempt>),
IdempotencyConflict,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SubscriptionRecoverySubmissionRejection {
BillingStateChanged,
GatewayConfigurationChanged,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SubscriptionRecoverySubmissionOutcome {
Admitted(PaymentAttempt),
AlreadyAdmitted(PaymentAttempt),
Rejected {
attempt: PaymentAttempt,
reason: SubscriptionRecoverySubmissionRejection,
},
}