Skip to main content

syrup_rail_postgres/subscription_billing_service/
error_disposition.rs

1use std::time::Duration;
2
3use syrup_rail::{
4    GatewayError, GatewayNotSubmittedError, GatewayResolutionError, HostChargeTargetRejection,
5    SubscriptionEnrollmentReservationRejection, SubscriptionEnrollmentSubmissionRejection,
6    SubscriptionPaymentMethodReplacementRejection,
7    SubscriptionPaymentMethodReplacementSubmissionRejection,
8    SubscriptionRecoveryReservationRejection, SubscriptionRecoverySubmissionRejection,
9    SubscriptionRenewalReservationRejection,
10};
11
12use super::{
13    GatewayMutationCooldownScope, SubscriptionBillingServiceError,
14    SubscriptionBillingServiceErrorDisposition,
15};
16
17impl SubscriptionBillingServiceError {
18    /// Returns the stable, conservative operational category for this error.
19    ///
20    /// The category intentionally does not expose provider diagnostics,
21    /// payment values, or durable identifiers.
22    pub const fn disposition(&self) -> SubscriptionBillingServiceErrorDisposition {
23        match self {
24            Self::StorageTemporarilyUnavailable(_) => {
25                SubscriptionBillingServiceErrorDisposition::TemporarilyUnavailable
26            }
27            Self::Sql(_)
28            | Self::Attempt(_)
29            | Self::Application(_)
30            | Self::HostChargeApplication(_)
31            | Self::HostChargeStore(_)
32            | Self::BillingTransaction(_)
33            | Self::BillingEvent(_)
34            | Self::ResolvedGatewayIdentityMismatch
35            | Self::InvalidState(_) => SubscriptionBillingServiceErrorDisposition::Internal,
36            Self::Cancellation(error) => cancellation_error_disposition(error),
37            Self::Discount(error) => discount_error_disposition(error),
38            Self::HostChargeUnavailable => {
39                SubscriptionBillingServiceErrorDisposition::Misconfigured
40            }
41            Self::IdempotencyConflict => SubscriptionBillingServiceErrorDisposition::Conflict,
42            Self::AdmissionDenied { .. } | Self::AdmissionTimeout | Self::AdmissionUnavailable => {
43                SubscriptionBillingServiceErrorDisposition::TemporarilyUnavailable
44            }
45            Self::GatewayConfigurationChanged => {
46                SubscriptionBillingServiceErrorDisposition::Conflict
47            }
48            Self::GatewayResolution(error) => gateway_resolution_disposition(*error),
49            Self::GatewayMutationCooldown { scope } => match scope {
50                GatewayMutationCooldownScope::Account | GatewayMutationCooldownScope::Provider => {
51                    SubscriptionBillingServiceErrorDisposition::TemporarilyUnavailable
52                }
53            },
54            Self::ReservationRejected(reason) => {
55                enrollment_reservation_rejection_disposition(*reason)
56            }
57            Self::SubmissionRejected(reason) => {
58                enrollment_submission_rejection_disposition(*reason)
59            }
60            Self::HostChargeReservationRejected(reason)
61            | Self::HostChargeSubmissionRejected(reason) => {
62                host_charge_rejection_disposition(*reason)
63            }
64            Self::RecoveryReservationRejected(reason) => {
65                recovery_reservation_rejection_disposition(*reason)
66            }
67            Self::RecoverySubmissionRejected(reason) => {
68                recovery_submission_rejection_disposition(*reason)
69            }
70            Self::RenewalReservationRejected(reason) => {
71                renewal_reservation_rejection_disposition(*reason)
72            }
73            Self::PaymentMethodReplacementReservationRejected(reason) => {
74                payment_method_replacement_reservation_rejection_disposition(*reason)
75            }
76            Self::PaymentMethodReplacementSubmissionRejected(reason) => {
77                payment_method_replacement_submission_rejection_disposition(*reason)
78            }
79            Self::GatewayNotSubmitted(error) => gateway_not_submitted_disposition(error),
80            Self::GatewayReadiness(error) => gateway_readiness_disposition(error),
81        }
82    }
83
84    /// Returns whether it is safe to resubmit the **same idempotent command**.
85    ///
86    /// A `true` result means only that Syrup Rail can safely accept another
87    /// submission of that unchanged command and idempotency key. It does not
88    /// promise that the next call will perform another payment; it may return a
89    /// canonical terminal result. Do not create a new command or idempotency
90    /// key merely because this returns `true`. Inspect the canonical result
91    /// before deciding whether a later payment needs a newly issued key.
92    pub const fn is_retryable(&self) -> bool {
93        matches!(
94            self.disposition(),
95            SubscriptionBillingServiceErrorDisposition::TemporarilyUnavailable
96        )
97    }
98
99    /// Returns whether the command conflicts with an existing idempotency or
100    /// authority snapshot.
101    ///
102    /// Conflicts are not retryable as-is; reload current authority or reconcile
103    /// the existing idempotency key before constructing another command.
104    pub const fn is_conflict(&self) -> bool {
105        matches!(
106            self.disposition(),
107            SubscriptionBillingServiceErrorDisposition::Conflict
108        )
109    }
110
111    /// Returns an exact retry delay when the service was given one.
112    ///
113    /// `None` does not imply that the error is non-retryable. For example,
114    /// gateway/account cooldowns are temporarily unavailable but do not carry
115    /// an exact delay that this API can safely fabricate.
116    pub const fn retry_after(&self) -> Option<Duration> {
117        match self {
118            Self::AdmissionDenied { retry_after } => Some(*retry_after),
119            Self::Sql(_)
120            | Self::StorageTemporarilyUnavailable(_)
121            | Self::Attempt(_)
122            | Self::Application(_)
123            | Self::HostChargeApplication(_)
124            | Self::HostChargeStore(_)
125            | Self::Cancellation(_)
126            | Self::Discount(_)
127            | Self::BillingTransaction(_)
128            | Self::BillingEvent(_)
129            | Self::HostChargeUnavailable
130            | Self::IdempotencyConflict
131            | Self::AdmissionTimeout
132            | Self::AdmissionUnavailable
133            | Self::GatewayConfigurationChanged
134            | Self::GatewayResolution(_)
135            | Self::ResolvedGatewayIdentityMismatch
136            | Self::GatewayMutationCooldown { .. }
137            | Self::ReservationRejected(_)
138            | Self::SubmissionRejected(_)
139            | Self::HostChargeReservationRejected(_)
140            | Self::HostChargeSubmissionRejected(_)
141            | Self::RecoveryReservationRejected(_)
142            | Self::RecoverySubmissionRejected(_)
143            | Self::RenewalReservationRejected(_)
144            | Self::PaymentMethodReplacementReservationRejected(_)
145            | Self::PaymentMethodReplacementSubmissionRejected(_)
146            | Self::GatewayNotSubmitted(_)
147            | Self::GatewayReadiness(_)
148            | Self::InvalidState(_) => None,
149        }
150    }
151}
152
153const fn cancellation_error_disposition(
154    error: &crate::SubscriptionCancellationError,
155) -> SubscriptionBillingServiceErrorDisposition {
156    match error {
157        crate::SubscriptionCancellationError::Sql(_)
158        | crate::SubscriptionCancellationError::InvalidState(_) => {
159            SubscriptionBillingServiceErrorDisposition::Internal
160        }
161    }
162}
163
164const fn discount_error_disposition(
165    error: &crate::SubscriptionDiscountOperationError,
166) -> SubscriptionBillingServiceErrorDisposition {
167    match error {
168        crate::SubscriptionDiscountOperationError::Sql(_)
169        | crate::SubscriptionDiscountOperationError::InvalidState(_) => {
170            SubscriptionBillingServiceErrorDisposition::Internal
171        }
172        crate::SubscriptionDiscountOperationError::OfferUnavailable
173        | crate::SubscriptionDiscountOperationError::InvalidConfiguration
174        | crate::SubscriptionDiscountOperationError::LimitedDiscountCadence => {
175            SubscriptionBillingServiceErrorDisposition::Misconfigured
176        }
177        crate::SubscriptionDiscountOperationError::OfferPlanMismatch => {
178            SubscriptionBillingServiceErrorDisposition::Conflict
179        }
180    }
181}
182
183const fn gateway_resolution_disposition(
184    error: GatewayResolutionError,
185) -> SubscriptionBillingServiceErrorDisposition {
186    match error {
187        GatewayResolutionError::NotFound | GatewayResolutionError::InvalidConfiguration => {
188            SubscriptionBillingServiceErrorDisposition::Misconfigured
189        }
190        GatewayResolutionError::ConfigurationChanged => {
191            SubscriptionBillingServiceErrorDisposition::Conflict
192        }
193        GatewayResolutionError::Unavailable => {
194            SubscriptionBillingServiceErrorDisposition::TemporarilyUnavailable
195        }
196    }
197}
198
199const fn gateway_readiness_disposition(
200    error: &GatewayError,
201) -> SubscriptionBillingServiceErrorDisposition {
202    match error {
203        GatewayError::RequestRejected(_) => SubscriptionBillingServiceErrorDisposition::Rejected,
204        // A malformed gateway response violates the adapter contract; retrying
205        // the same command would not establish that provider I/O is safe.
206        GatewayError::Malformed(_) => SubscriptionBillingServiceErrorDisposition::Internal,
207        GatewayError::Configuration(_) => SubscriptionBillingServiceErrorDisposition::Misconfigured,
208        GatewayError::Unavailable(_) | GatewayError::RateLimited(_) => {
209            SubscriptionBillingServiceErrorDisposition::TemporarilyUnavailable
210        }
211    }
212}
213
214const fn gateway_not_submitted_disposition(
215    error: &GatewayNotSubmittedError,
216) -> SubscriptionBillingServiceErrorDisposition {
217    match error {
218        GatewayNotSubmittedError::RequestRejected(_) => {
219            SubscriptionBillingServiceErrorDisposition::Rejected
220        }
221        // A malformed request was definitely not submitted, but it still
222        // indicates a service/adapter contract fault rather than a retryable
223        // transport condition.
224        GatewayNotSubmittedError::Malformed(_) => {
225            SubscriptionBillingServiceErrorDisposition::Internal
226        }
227        GatewayNotSubmittedError::Configuration(_) => {
228            SubscriptionBillingServiceErrorDisposition::Misconfigured
229        }
230        GatewayNotSubmittedError::AccountModeMismatch { .. } => {
231            SubscriptionBillingServiceErrorDisposition::Misconfigured
232        }
233        GatewayNotSubmittedError::AccountModeVerification(error) => {
234            gateway_readiness_disposition(error)
235        }
236        GatewayNotSubmittedError::NotTransmitted(_) | GatewayNotSubmittedError::RateLimited(_) => {
237            SubscriptionBillingServiceErrorDisposition::TemporarilyUnavailable
238        }
239    }
240}
241
242const fn enrollment_reservation_rejection_disposition(
243    rejection: SubscriptionEnrollmentReservationRejection,
244) -> SubscriptionBillingServiceErrorDisposition {
245    match rejection {
246        SubscriptionEnrollmentReservationRejection::CurrentSubscription
247        | SubscriptionEnrollmentReservationRejection::ActiveGrant
248        | SubscriptionEnrollmentReservationRejection::UnresolvedProcessorCharge
249        | SubscriptionEnrollmentReservationRejection::AttemptInProgress => {
250            SubscriptionBillingServiceErrorDisposition::Rejected
251        }
252        SubscriptionEnrollmentReservationRejection::EnrollmentTermsChanged
253        | SubscriptionEnrollmentReservationRejection::GatewayConfigurationChanged
254        | SubscriptionEnrollmentReservationRejection::GatewayAccountModeChanged => {
255            SubscriptionBillingServiceErrorDisposition::Conflict
256        }
257    }
258}
259
260const fn enrollment_submission_rejection_disposition(
261    rejection: SubscriptionEnrollmentSubmissionRejection,
262) -> SubscriptionBillingServiceErrorDisposition {
263    match rejection {
264        SubscriptionEnrollmentSubmissionRejection::BillingStateChanged
265        | SubscriptionEnrollmentSubmissionRejection::EnrollmentTermsChanged
266        | SubscriptionEnrollmentSubmissionRejection::GatewayConfigurationChanged
267        | SubscriptionEnrollmentSubmissionRejection::GatewayAccountModeChanged => {
268            SubscriptionBillingServiceErrorDisposition::Conflict
269        }
270    }
271}
272
273const fn host_charge_rejection_disposition(
274    rejection: HostChargeTargetRejection,
275) -> SubscriptionBillingServiceErrorDisposition {
276    match rejection {
277        HostChargeTargetRejection::TargetUnavailable | HostChargeTargetRejection::LedgerUnsafe => {
278            SubscriptionBillingServiceErrorDisposition::Rejected
279        }
280        HostChargeTargetRejection::ChargeChanged => {
281            SubscriptionBillingServiceErrorDisposition::Conflict
282        }
283    }
284}
285
286const fn recovery_reservation_rejection_disposition(
287    rejection: SubscriptionRecoveryReservationRejection,
288) -> SubscriptionBillingServiceErrorDisposition {
289    match rejection {
290        SubscriptionRecoveryReservationRejection::SubscriptionNotFound
291        | SubscriptionRecoveryReservationRejection::PaymentNotDue
292        | SubscriptionRecoveryReservationRejection::AttemptInProgress
293        | SubscriptionRecoveryReservationRejection::PaymentMethodUpdateInProgress => {
294            SubscriptionBillingServiceErrorDisposition::Rejected
295        }
296        SubscriptionRecoveryReservationRejection::GatewayConfigurationChanged
297        | SubscriptionRecoveryReservationRejection::GatewayAccountModeChanged => {
298            SubscriptionBillingServiceErrorDisposition::Conflict
299        }
300    }
301}
302
303const fn recovery_submission_rejection_disposition(
304    rejection: SubscriptionRecoverySubmissionRejection,
305) -> SubscriptionBillingServiceErrorDisposition {
306    match rejection {
307        SubscriptionRecoverySubmissionRejection::BillingStateChanged
308        | SubscriptionRecoverySubmissionRejection::GatewayConfigurationChanged => {
309            SubscriptionBillingServiceErrorDisposition::Conflict
310        }
311    }
312}
313
314const fn renewal_reservation_rejection_disposition(
315    rejection: SubscriptionRenewalReservationRejection,
316) -> SubscriptionBillingServiceErrorDisposition {
317    match rejection {
318        SubscriptionRenewalReservationRejection::SubscriptionNotFound
319        | SubscriptionRenewalReservationRejection::PaymentNotDue
320        | SubscriptionRenewalReservationRejection::AttemptInProgress
321        | SubscriptionRenewalReservationRejection::PaymentMethodUpdateInProgress
322        | SubscriptionRenewalReservationRejection::RetryBlocked => {
323            SubscriptionBillingServiceErrorDisposition::Rejected
324        }
325        SubscriptionRenewalReservationRejection::GatewayAccountModeChanged
326        | SubscriptionRenewalReservationRejection::GatewayConfigurationChanged => {
327            SubscriptionBillingServiceErrorDisposition::Conflict
328        }
329    }
330}
331
332const fn payment_method_replacement_reservation_rejection_disposition(
333    rejection: SubscriptionPaymentMethodReplacementRejection,
334) -> SubscriptionBillingServiceErrorDisposition {
335    match rejection {
336        SubscriptionPaymentMethodReplacementRejection::SubscriptionNotFound
337        | SubscriptionPaymentMethodReplacementRejection::SubscriptionIneligible
338        | SubscriptionPaymentMethodReplacementRejection::ChargeAttemptInProgress
339        | SubscriptionPaymentMethodReplacementRejection::PaymentMethodUpdateInProgress => {
340            SubscriptionBillingServiceErrorDisposition::Rejected
341        }
342        SubscriptionPaymentMethodReplacementRejection::GatewayConfigurationChanged
343        | SubscriptionPaymentMethodReplacementRejection::GatewayAccountModeChanged => {
344            SubscriptionBillingServiceErrorDisposition::Conflict
345        }
346    }
347}
348
349const fn payment_method_replacement_submission_rejection_disposition(
350    rejection: SubscriptionPaymentMethodReplacementSubmissionRejection,
351) -> SubscriptionBillingServiceErrorDisposition {
352    match rejection {
353        SubscriptionPaymentMethodReplacementSubmissionRejection::BillingStateChanged
354        | SubscriptionPaymentMethodReplacementSubmissionRejection::GatewayConfigurationChanged => {
355            SubscriptionBillingServiceErrorDisposition::Conflict
356        }
357    }
358}