Skip to main content

syrup_rail_postgres/
subscription_billing_service.rs

1#![warn(missing_docs)]
2
3use std::{fmt, sync::Arc, time::Duration};
4
5use sqlx::PgPool;
6use syrup_rail::{
7    BillingEventSubject, BillingScopeId, CancelSubscription, CancelSubscriptionOutcome,
8    ChargeHostTarget, ChargeRenewal, ClearSubscriptionDiscount, EndUserMutationAdmission,
9    EndUserMutationAdmissionResult, EndUserMutationCommand, EndUserMutationOperation,
10    EnrollSubscription, GatewayAccountId, GatewayAccountMode, GatewayDiagnostic, GatewayError,
11    GatewayNotSubmittedError, GatewayPaymentDescriptor, GatewayPaymentOutcome, GatewayProviderKey,
12    GatewayResolutionError, GatewayResolver, HostChargePaymentResult, HostChargeReservation,
13    HostChargeTargetRejection, PaymentAttempt, PaymentAttemptId, PaymentAttemptKind,
14    PaymentAttemptStatus, PaymentResolutionCode, ProcessorEvidence, RecoverSubscriptionPayment,
15    ReplaceSubscriptionPaymentMethod, SubscriptionDiscountClaim, SubscriptionDiscountClaimOutcome,
16    SubscriptionDiscountClearOutcome, SubscriptionEnrollmentPaymentResult,
17    SubscriptionEnrollmentPreflightOutcome, SubscriptionEnrollmentReservation,
18    SubscriptionEnrollmentReservationBuildError, SubscriptionEnrollmentReservationOutcome,
19    SubscriptionEnrollmentReservationRejection, SubscriptionEnrollmentSubmissionRejection,
20    SubscriptionPaymentMethodReplacement, SubscriptionPaymentMethodReplacementPreflightOutcome,
21    SubscriptionPaymentMethodReplacementRejection,
22    SubscriptionPaymentMethodReplacementReservationOutcome,
23    SubscriptionPaymentMethodReplacementSubmissionRejection, SubscriptionRecoveryPreflightOutcome,
24    SubscriptionRecoveryReservation, SubscriptionRecoveryReservationOutcome,
25    SubscriptionRecoveryReservationRejection, SubscriptionRecoverySubmissionRejection,
26    SubscriptionRenewalOutcome, SubscriptionRenewalReservation,
27    SubscriptionRenewalReservationOutcome, SubscriptionRenewalReservationRejection,
28};
29use thiserror::Error;
30
31use crate::enrollment_application::GatewayNotSubmittedPolicy;
32use crate::host_charge_application::{
33    HostChargeBeforeSubmissionResolution, resolve_host_charge_before_submission,
34};
35use crate::mode_verified_gateway::gateway_account_mode_mismatch_detail;
36use crate::{
37    BillingTransactionCoordinator, GatewayAccountModeVerificationError, HostChargeAdmissionOutcome,
38    HostChargeApplicationError, HostChargePreflightOutcome, HostChargeProviderResult,
39    HostChargeReservationOutcome, HostChargeStoreError, HostChargeTargetStore, ModeVerifiedGateway,
40    PaymentAttemptStoreError, SubscriptionEnrollmentAdmissionOutcome,
41    SubscriptionEnrollmentApplicationError, SubscriptionEnrollmentProviderResult,
42    SubscriptionOfferStore, SubscriptionPaymentMethodReplacementAdmissionOutcome,
43    SubscriptionPaymentMethodReplacementProviderResult, SubscriptionRecoveryAdmissionOutcome,
44    SubscriptionRecoveryProviderResult, SubscriptionRenewalAdmissionOutcome,
45    SubscriptionRenewalProviderResult, admit_host_charge_submission,
46    admit_subscription_enrollment_submission, admit_subscription_payment_method_replacement,
47    admit_subscription_recovery_submission, admit_subscription_renewal_submission,
48    apply_reconciled_host_charge_gateway_outcome,
49    apply_reconciled_subscription_enrollment_gateway_outcome,
50    apply_reconciled_subscription_payment_method_replacement_gateway_outcome,
51    apply_reconciled_subscription_recovery_gateway_outcome,
52    apply_reconciled_subscription_renewal_gateway_outcome,
53    attempts::{
54        AttemptReplayDisposition, AttemptResolutionStatus, LocalAttemptPolicy,
55        attempt_replay_disposition,
56    },
57    enrollment_application::{
58        OutcomeApplication, OutcomeResolutionBoundary, OutcomeResolutionCommand, RateLimitCooldown,
59        RateLimitCooldownPersistence, payment_result_for_attempt,
60        persist_bound_provider_rate_limit_cooldown, resolve_non_approved_outcome,
61        resolve_payment_method_replacement_non_approved_outcome,
62        resolve_recovery_non_approved_outcome, resolve_renewal_non_approved_outcome,
63        set_application_timeouts,
64    },
65    preflight_host_charge_in_transaction, preflight_subscription_enrollment_in_transaction,
66    preflight_subscription_payment_method_replacement_in_transaction,
67    preflight_subscription_recovery_in_transaction, reserve_host_charge_in_transaction,
68    reserve_subscription_enrollment_in_transaction,
69    reserve_subscription_payment_method_replacement_in_transaction,
70    reserve_subscription_recovery_in_transaction, reserve_subscription_renewal_in_transaction,
71    submit_admitted_host_charge, submit_admitted_subscription_enrollment,
72    submit_admitted_subscription_payment_method_replacement, submit_admitted_subscription_recovery,
73    submit_admitted_subscription_renewal, verify_gateway_account_mode,
74};
75
76mod enrollment;
77mod error_disposition;
78mod host_charge;
79mod payment_method_replacement;
80mod reconciliation;
81mod recovery;
82mod renewal;
83mod subscriber;
84mod subscriber_mutation;
85
86const INVALID_SERVICE_STATE: &str = "canonical subscription billing service state is invalid";
87
88/// Canonical cooldown level that stopped a gateway mutation before submission.
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub enum GatewayMutationCooldownScope {
91    /// Cooldown applies only to the resolved gateway account.
92    Account,
93    /// Cooldown applies to every configured account for the provider.
94    Provider,
95}
96
97impl GatewayMutationCooldownScope {
98    const fn from_rate_limit_cooldown(cooldown: RateLimitCooldown) -> Self {
99        match cooldown {
100            RateLimitCooldown::Account => Self::Account,
101            RateLimitCooldown::Provider => Self::Provider,
102        }
103    }
104}
105
106/// A stable, conservative operational category for a
107/// [`SubscriptionBillingServiceError`].
108///
109/// Hosts can use this value to decide whether to show a request/state problem,
110/// repair configuration, investigate an internal failure, or resubmit the
111/// same idempotent command later. This enum is non-exhaustive so hosts must
112/// keep a conservative wildcard branch when matching it.
113#[non_exhaustive]
114#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
115pub enum SubscriptionBillingServiceErrorDisposition {
116    /// The command's idempotency identity or authority snapshot no longer
117    /// matches current durable state.
118    ///
119    /// This is not retryable as-is. Hosts generally need to load and rebuild
120    /// against current authority, or reconcile the existing idempotency key,
121    /// before issuing another command.
122    Conflict,
123    /// The command cannot proceed because its current request or billing
124    /// state is semantically blocked.
125    Rejected,
126    /// The same idempotent command may safely be resubmitted later, although
127    /// another attempt is not guaranteed to succeed.
128    TemporarilyUnavailable,
129    /// A required gateway, gateway configuration, offer, or optional host
130    /// capability is missing or invalid.
131    Misconfigured,
132    /// A storage, application, transaction, durable-state, or contract fault
133    /// requires investigation rather than an automatic retry.
134    Internal,
135}
136
137/// Failure returned by the high-level subscription billing facade.
138///
139/// This covers subscriber-owned enrollment, recovery, renewal, payment-method
140/// replacement, cancellation, discount mutations, reconciliation, and the
141/// optional host-charge capability.
142#[non_exhaustive]
143#[derive(Error)]
144pub enum SubscriptionBillingServiceError {
145    /// Generic SQL failure whose retry safety is not proven by the owning path.
146    #[error("subscription billing storage failed")]
147    Sql(#[from] sqlx::Error),
148    /// A provider-free local transaction could not acquire capacity or failed
149    /// with an explicitly recognized transient SQLSTATE. Replaying the same
150    /// idempotent operation after the failed transaction is discarded is safe.
151    #[error("subscription billing storage is temporarily unavailable")]
152    StorageTemporarilyUnavailable(#[source] sqlx::Error),
153    /// Canonical payment-attempt persistence failed.
154    #[error("payment attempt storage failed")]
155    Attempt(#[from] PaymentAttemptStoreError),
156    /// A subscription payment outcome could not be applied atomically.
157    #[error("subscription payment application failed")]
158    Application(#[from] SubscriptionEnrollmentApplicationError),
159    /// A host-charge outcome could not be applied atomically.
160    #[error("host charge application failed")]
161    HostChargeApplication(#[from] HostChargeApplicationError),
162    /// Canonical host-charge reservation or ledger storage failed.
163    #[error("host charge storage failed")]
164    HostChargeStore(#[from] HostChargeStoreError),
165    /// Subscriber cancellation failed.
166    #[error("subscription cancellation failed")]
167    Cancellation(#[source] crate::SubscriptionCancellationError),
168    /// Subscriber discount mutation failed.
169    #[error("subscription discount operation failed")]
170    Discount(#[source] crate::SubscriptionDiscountOperationError),
171    /// The host-prepared billing transaction failed.
172    #[error("host billing transaction failed")]
173    BillingTransaction(#[from] crate::BillingTransactionError),
174    /// The typed event could not be appended to the host outbox.
175    #[error("host billing event append failed")]
176    BillingEvent(#[from] crate::BillingEventWriteError),
177    /// The optional host-charge target capability was not supplied.
178    #[error("host charge capability is not configured")]
179    HostChargeUnavailable,
180    /// The idempotency key already owns a different immutable request.
181    #[error("the idempotency key belongs to a different payment request")]
182    IdempotencyConflict,
183    /// Host admission denied the command with an exact retry delay.
184    #[error("end-user mutation admission was denied")]
185    AdmissionDenied {
186        /// Exact delay supplied by the host admission implementation.
187        retry_after: Duration,
188    },
189    /// Host admission did not complete before its bound.
190    #[error("end-user mutation admission timed out")]
191    AdmissionTimeout,
192    /// Host admission could not evaluate the command.
193    #[error("end-user mutation admission is unavailable")]
194    AdmissionUnavailable,
195    /// Durable gateway authority changed after the command was prepared.
196    #[error("gateway account or configuration changed")]
197    GatewayConfigurationChanged,
198    /// The host could not resolve canonical gateway authority.
199    #[error("gateway resolution failed")]
200    GatewayResolution(#[from] GatewayResolutionError),
201    /// The resolver returned a gateway for a different canonical identity.
202    #[error("gateway resolver returned a different canonical identity")]
203    ResolvedGatewayIdentityMismatch,
204    /// A durable account or provider cooldown stopped submission.
205    #[error("gateway mutation cooldown is active")]
206    GatewayMutationCooldown {
207        /// Canonical cooldown level that stopped the command.
208        scope: GatewayMutationCooldownScope,
209    },
210    /// Initial-enrollment reservation returned a semantic blocker.
211    #[error("subscription enrollment reservation was rejected")]
212    ReservationRejected(SubscriptionEnrollmentReservationRejection),
213    /// Initial-enrollment final admission returned a semantic blocker.
214    #[error("subscription enrollment submission was rejected")]
215    SubmissionRejected(SubscriptionEnrollmentSubmissionRejection),
216    /// Host-charge reservation returned a semantic blocker.
217    #[error("host charge reservation was rejected")]
218    HostChargeReservationRejected(HostChargeTargetRejection),
219    /// Host-charge final admission returned a semantic blocker.
220    #[error("host charge submission was rejected")]
221    HostChargeSubmissionRejected(HostChargeTargetRejection),
222    /// Subscriber recovery reservation returned a semantic blocker.
223    #[error("subscription recovery reservation was rejected")]
224    RecoveryReservationRejected(SubscriptionRecoveryReservationRejection),
225    /// Subscriber recovery final admission returned a semantic blocker.
226    #[error("subscription recovery submission was rejected")]
227    RecoverySubmissionRejected(SubscriptionRecoverySubmissionRejection),
228    /// Automatic-renewal reservation returned a semantic blocker.
229    #[error("subscription renewal reservation was rejected")]
230    RenewalReservationRejected(SubscriptionRenewalReservationRejection),
231    /// Payment-method replacement reservation returned a semantic blocker.
232    #[error("subscription payment method replacement reservation was rejected")]
233    PaymentMethodReplacementReservationRejected(SubscriptionPaymentMethodReplacementRejection),
234    /// Payment-method replacement final admission returned a semantic blocker.
235    #[error("subscription payment method replacement submission was rejected")]
236    PaymentMethodReplacementSubmissionRejected(
237        SubscriptionPaymentMethodReplacementSubmissionRejection,
238    ),
239    /// Gateway adaptation proved that the mutation was not submitted.
240    #[error("gateway mutation was not submitted")]
241    GatewayNotSubmitted(#[source] GatewayNotSubmittedError),
242    /// The gateway readiness query failed before mutation submission.
243    ///
244    /// This error does not by itself prove that no durable attempt exists. If
245    /// readiness fails after reservation, transient unavailability leaves the
246    /// prepared attempt retryable, while a determinate readiness failure may
247    /// terminalize it with an exact resolution code before returning this
248    /// error. Replaying the same command and idempotency key recovers that
249    /// canonical result before admission, resolution, or provider I/O.
250    #[error("gateway readiness check failed")]
251    GatewayReadiness(#[source] GatewayError),
252    /// Durable canonical state violated an invariant required by the facade.
253    #[error("{0}")]
254    InvalidState(&'static str),
255}
256
257impl fmt::Debug for SubscriptionBillingServiceError {
258    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
259        match self {
260            Self::Sql(_) => formatter.write_str("SubscriptionBillingServiceError::Sql"),
261            Self::StorageTemporarilyUnavailable(_) => formatter
262                .write_str("SubscriptionBillingServiceError::StorageTemporarilyUnavailable"),
263            Self::Attempt(_) => formatter.write_str("SubscriptionBillingServiceError::Attempt"),
264            Self::Application(_) => {
265                formatter.write_str("SubscriptionBillingServiceError::Application")
266            }
267            Self::HostChargeApplication(_) => {
268                formatter.write_str("SubscriptionBillingServiceError::HostChargeApplication")
269            }
270            Self::HostChargeStore(_) => {
271                formatter.write_str("SubscriptionBillingServiceError::HostChargeStore")
272            }
273            Self::Cancellation(_) => {
274                formatter.write_str("SubscriptionBillingServiceError::Cancellation")
275            }
276            Self::Discount(_) => formatter.write_str("SubscriptionBillingServiceError::Discount"),
277            Self::BillingTransaction(_) => {
278                formatter.write_str("SubscriptionBillingServiceError::BillingTransaction")
279            }
280            Self::BillingEvent(_) => {
281                formatter.write_str("SubscriptionBillingServiceError::BillingEvent")
282            }
283            Self::HostChargeUnavailable => {
284                formatter.write_str("SubscriptionBillingServiceError::HostChargeUnavailable")
285            }
286            Self::IdempotencyConflict => {
287                formatter.write_str("SubscriptionBillingServiceError::IdempotencyConflict")
288            }
289            Self::AdmissionDenied { retry_after } => formatter
290                .debug_struct("SubscriptionBillingServiceError::AdmissionDenied")
291                .field("retry_after", retry_after)
292                .finish(),
293            Self::AdmissionTimeout => {
294                formatter.write_str("SubscriptionBillingServiceError::AdmissionTimeout")
295            }
296            Self::AdmissionUnavailable => {
297                formatter.write_str("SubscriptionBillingServiceError::AdmissionUnavailable")
298            }
299            Self::GatewayConfigurationChanged => {
300                formatter.write_str("SubscriptionBillingServiceError::GatewayConfigurationChanged")
301            }
302            Self::GatewayResolution(error) => formatter
303                .debug_tuple("SubscriptionBillingServiceError::GatewayResolution")
304                .field(error)
305                .finish(),
306            Self::ResolvedGatewayIdentityMismatch => formatter
307                .write_str("SubscriptionBillingServiceError::ResolvedGatewayIdentityMismatch"),
308            Self::GatewayMutationCooldown { scope } => formatter
309                .debug_struct("SubscriptionBillingServiceError::GatewayMutationCooldown")
310                .field("scope", scope)
311                .finish(),
312            Self::ReservationRejected(reason) => formatter
313                .debug_tuple("SubscriptionBillingServiceError::ReservationRejected")
314                .field(reason)
315                .finish(),
316            Self::SubmissionRejected(reason) => formatter
317                .debug_tuple("SubscriptionBillingServiceError::SubmissionRejected")
318                .field(reason)
319                .finish(),
320            Self::HostChargeReservationRejected(reason) => formatter
321                .debug_tuple("SubscriptionBillingServiceError::HostChargeReservationRejected")
322                .field(reason)
323                .finish(),
324            Self::HostChargeSubmissionRejected(reason) => formatter
325                .debug_tuple("SubscriptionBillingServiceError::HostChargeSubmissionRejected")
326                .field(reason)
327                .finish(),
328            Self::RecoveryReservationRejected(reason) => formatter
329                .debug_tuple("SubscriptionBillingServiceError::RecoveryReservationRejected")
330                .field(reason)
331                .finish(),
332            Self::RecoverySubmissionRejected(reason) => formatter
333                .debug_tuple("SubscriptionBillingServiceError::RecoverySubmissionRejected")
334                .field(reason)
335                .finish(),
336            Self::RenewalReservationRejected(reason) => formatter
337                .debug_tuple("SubscriptionBillingServiceError::RenewalReservationRejected")
338                .field(reason)
339                .finish(),
340            Self::PaymentMethodReplacementReservationRejected(reason) => formatter
341                .debug_tuple(
342                    "SubscriptionBillingServiceError::PaymentMethodReplacementReservationRejected",
343                )
344                .field(reason)
345                .finish(),
346            Self::PaymentMethodReplacementSubmissionRejected(reason) => formatter
347                .debug_tuple(
348                    "SubscriptionBillingServiceError::PaymentMethodReplacementSubmissionRejected",
349                )
350                .field(reason)
351                .finish(),
352            Self::GatewayNotSubmitted(error) => formatter
353                .debug_tuple("SubscriptionBillingServiceError::GatewayNotSubmitted")
354                .field(error)
355                .finish(),
356            Self::GatewayReadiness(error) => formatter
357                .debug_tuple("SubscriptionBillingServiceError::GatewayReadiness")
358                .field(error)
359                .finish(),
360            Self::InvalidState(detail) => formatter
361                .debug_tuple("SubscriptionBillingServiceError::InvalidState")
362                .field(detail)
363                .finish(),
364        }
365    }
366}
367
368impl From<crate::SubscriptionCancellationError> for SubscriptionBillingServiceError {
369    fn from(error: crate::SubscriptionCancellationError) -> Self {
370        match error {
371            crate::SubscriptionCancellationError::Sql(source)
372                if is_retryable_provider_free_transaction_error(&source) =>
373            {
374                Self::StorageTemporarilyUnavailable(source)
375            }
376            error => Self::Cancellation(error),
377        }
378    }
379}
380
381impl From<crate::SubscriptionDiscountOperationError> for SubscriptionBillingServiceError {
382    fn from(error: crate::SubscriptionDiscountOperationError) -> Self {
383        match error {
384            crate::SubscriptionDiscountOperationError::Sql(source)
385                if is_retryable_provider_free_transaction_error(&source) =>
386            {
387                Self::StorageTemporarilyUnavailable(source)
388            }
389            error => Self::Discount(error),
390        }
391    }
392}
393
394fn provider_free_transaction_error(error: sqlx::Error) -> SubscriptionBillingServiceError {
395    if is_retryable_provider_free_transaction_error(&error) {
396        SubscriptionBillingServiceError::StorageTemporarilyUnavailable(error)
397    } else {
398        SubscriptionBillingServiceError::Sql(error)
399    }
400}
401
402fn is_retryable_provider_free_transaction_error(error: &sqlx::Error) -> bool {
403    if matches!(error, sqlx::Error::PoolTimedOut) {
404        return true;
405    }
406    let sqlx::Error::Database(error) = error else {
407        return false;
408    };
409    error
410        .code()
411        .is_some_and(|code| is_retryable_provider_free_transaction_sqlstate(code.as_ref()))
412}
413
414fn is_retryable_provider_free_transaction_sqlstate(code: &str) -> bool {
415    matches!(code, "40001" | "40P01" | "55P03" | "57014")
416}
417
418/// High-level provider-neutral billing facade for authorized host commands.
419///
420/// The service owns orchestration and retry classification. Hosts retain
421/// authentication, authorization, offer rows, credentials, abuse admission,
422/// and the transaction/outbox boundary supplied at construction.
423#[derive(Clone)]
424pub struct SubscriptionBillingService {
425    pool: PgPool,
426    offers: Arc<dyn SubscriptionOfferStore>,
427    resolver: Arc<dyn GatewayResolver>,
428    admission: Arc<dyn EndUserMutationAdmission>,
429    coordinator: Arc<dyn BillingTransactionCoordinator>,
430    required_gateway_account_mode: GatewayAccountMode,
431    host_charge_targets: Option<Arc<dyn HostChargeTargetStore>>,
432}
433
434impl SubscriptionBillingService {
435    /// Creates a service without the optional host-charge target capability.
436    pub fn new(
437        pool: PgPool,
438        offers: Arc<dyn SubscriptionOfferStore>,
439        resolver: Arc<dyn GatewayResolver>,
440        admission: Arc<dyn EndUserMutationAdmission>,
441        coordinator: Arc<dyn BillingTransactionCoordinator>,
442    ) -> Self {
443        Self {
444            pool,
445            offers,
446            resolver,
447            admission,
448            coordinator,
449            required_gateway_account_mode: GatewayAccountMode::Live,
450            host_charge_targets: None,
451        }
452    }
453
454    /// Requires an exact gateway account mode before any provider mutation.
455    ///
456    /// The default is [`GatewayAccountMode::Live`]. Selecting
457    /// [`GatewayAccountMode::Test`] permits test-mode mutations and rejects a
458    /// live account before submission. Hosts should bind this requirement to
459    /// their trusted deployment environment, never to end-user input.
460    ///
461    /// This service setting does not automatically partition entitlement or
462    /// billing-portal reads. Test-mode subscriptions are ordinary paid
463    /// subscriptions to the domain model and can satisfy
464    /// `Entitlement::permits_product_access`; constrain `EntitlementQuery` and
465    /// `EntitlementGuard` separately when modes share a database, and enforce
466    /// any remaining environment isolation before granting production access.
467    pub fn with_required_gateway_account_mode(mut self, mode: GatewayAccountMode) -> Self {
468        self.required_gateway_account_mode = mode;
469        self
470    }
471
472    /// Adds the optional host-charge target store to this service instance.
473    pub fn with_host_charge_targets(mut self, targets: Arc<dyn HostChargeTargetStore>) -> Self {
474        self.host_charge_targets = Some(targets);
475        self
476    }
477}
478
479/// The subscriber-initiated reservation families share readiness resolution,
480/// while keeping their operation-specific durable application functions
481/// explicit.
482enum SubscriberInitiatedReservation<'a> {
483    Initial(&'a SubscriptionEnrollmentReservation),
484    Recovery(&'a SubscriptionRecoveryReservation),
485    PaymentMethodReplacement(&'a SubscriptionPaymentMethodReplacement),
486}
487
488impl SubscriberInitiatedReservation<'_> {
489    async fn resolve_non_approved(
490        self,
491        pool: &PgPool,
492        evidence: &ProcessorEvidence,
493        code: PaymentResolutionCode,
494        cooldown: Option<RateLimitCooldown>,
495        boundary: OutcomeResolutionBoundary,
496    ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentApplicationError> {
497        match self {
498            Self::Initial(reservation) => {
499                resolve_non_approved_outcome(
500                    pool,
501                    reservation,
502                    evidence,
503                    AttemptResolutionStatus::Failed,
504                    Some(code),
505                    cooldown,
506                    boundary,
507                )
508                .await
509            }
510            Self::Recovery(reservation) => {
511                resolve_recovery_non_approved_outcome(
512                    pool,
513                    reservation,
514                    evidence,
515                    AttemptResolutionStatus::Failed,
516                    Some(code),
517                    cooldown,
518                    boundary,
519                )
520                .await
521            }
522            Self::PaymentMethodReplacement(reservation) => {
523                resolve_payment_method_replacement_non_approved_outcome(
524                    pool,
525                    reservation,
526                    evidence,
527                    AttemptResolutionStatus::Failed,
528                    Some(code),
529                    cooldown,
530                    boundary,
531                )
532                .await
533            }
534        }
535    }
536}
537
538/// A closed representation of the only pre-submission readiness failures
539/// shared by subscriber-initiated mutations.
540enum SubscriberReadinessFailure {
541    Cooldown(GatewayMutationCooldownScope),
542    Gateway(GatewayError),
543    AccountMode(GatewayAccountMode),
544}
545
546#[derive(Clone, Copy, Debug, Eq, PartialEq)]
547struct SubscriberReadinessPolicy {
548    resolution_code: PaymentResolutionCode,
549    cooldown: Option<RateLimitCooldown>,
550    cooldown_error_scope: Option<GatewayMutationCooldownScope>,
551}
552
553impl SubscriberReadinessPolicy {
554    const fn resolution_code(self) -> PaymentResolutionCode {
555        self.resolution_code
556    }
557
558    const fn cooldown(self) -> Option<RateLimitCooldown> {
559        self.cooldown
560    }
561
562    const fn cooldown_error_scope(self) -> Option<GatewayMutationCooldownScope> {
563        self.cooldown_error_scope
564    }
565}
566
567impl SubscriberReadinessFailure {
568    fn gateway_error(&self) -> Option<GatewayError> {
569        let Self::Gateway(error) = self else {
570            return None;
571        };
572        Some(clone_gateway_error(error))
573    }
574
575    fn into_detail(self) -> GatewayDiagnostic {
576        match self {
577            Self::Cooldown(GatewayMutationCooldownScope::Account) => {
578                GatewayDiagnostic::new("gateway account mutation cooldown is active")
579            }
580            Self::Cooldown(GatewayMutationCooldownScope::Provider) => {
581                GatewayDiagnostic::new("gateway provider cooldown is active")
582            }
583            Self::Gateway(error) => error.detail().clone(),
584            Self::AccountMode(_) => gateway_account_mode_mismatch_detail(),
585        }
586    }
587
588    const fn policy(&self) -> SubscriberReadinessPolicy {
589        match self {
590            Self::Cooldown(GatewayMutationCooldownScope::Account) => SubscriberReadinessPolicy {
591                resolution_code:
592                    PaymentResolutionCode::GatewayAccountMutationCooldownBeforeSubmission,
593                cooldown: None,
594                cooldown_error_scope: Some(GatewayMutationCooldownScope::Account),
595            },
596            Self::Cooldown(GatewayMutationCooldownScope::Provider) => SubscriberReadinessPolicy {
597                resolution_code: PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission,
598                cooldown: None,
599                cooldown_error_scope: Some(GatewayMutationCooldownScope::Provider),
600            },
601            Self::Gateway(error) => {
602                let policy = GatewayNotSubmittedPolicy::for_readiness_error(error);
603                let cooldown = policy.cooldown();
604                SubscriberReadinessPolicy {
605                    resolution_code: policy.resolution_code(),
606                    cooldown,
607                    cooldown_error_scope: match cooldown {
608                        Some(cooldown) => Some(
609                            GatewayMutationCooldownScope::from_rate_limit_cooldown(cooldown),
610                        ),
611                        None => None,
612                    },
613                }
614            }
615            Self::AccountMode(required) => {
616                let policy = GatewayNotSubmittedPolicy::for_account_mode_mismatch(*required);
617                SubscriberReadinessPolicy {
618                    resolution_code: policy.resolution_code(),
619                    cooldown: policy.cooldown(),
620                    cooldown_error_scope: None,
621                }
622            }
623        }
624    }
625}
626
627fn clone_gateway_error(error: &GatewayError) -> GatewayError {
628    match error {
629        GatewayError::RequestRejected(detail) => GatewayError::RequestRejected(detail.clone()),
630        GatewayError::Malformed(detail) => GatewayError::Malformed(detail.clone()),
631        GatewayError::Configuration(detail) => GatewayError::Configuration(detail.clone()),
632        GatewayError::Unavailable(detail) => GatewayError::Unavailable(detail.clone()),
633        GatewayError::RateLimited(detail) => GatewayError::RateLimited(detail.clone()),
634    }
635}
636
637fn map_subscriber_mutation_admission(
638    result: EndUserMutationAdmissionResult,
639) -> Result<(), SubscriptionBillingServiceError> {
640    match result {
641        EndUserMutationAdmissionResult::Allowed => Ok(()),
642        EndUserMutationAdmissionResult::Denied { retry_after } => {
643            Err(SubscriptionBillingServiceError::AdmissionDenied {
644                retry_after: retry_after.get(),
645            })
646        }
647        EndUserMutationAdmissionResult::Timeout => {
648            Err(SubscriptionBillingServiceError::AdmissionTimeout)
649        }
650        EndUserMutationAdmissionResult::Unavailable => {
651            Err(SubscriptionBillingServiceError::AdmissionUnavailable)
652        }
653    }
654}
655
656async fn subscriber_gateway_readiness(
657    gateway: &syrup_rail::ResolvedGateway,
658    required_mode: GatewayAccountMode,
659) -> Result<ModeVerifiedGateway<'_>, SubscriberReadinessFailure> {
660    match verify_gateway_account_mode(gateway, required_mode).await {
661        Ok(verified) => Ok(verified),
662        Err(GatewayAccountModeVerificationError::AccountModeMismatch { required, .. }) => {
663            Err(SubscriberReadinessFailure::AccountMode(required))
664        }
665        Err(GatewayAccountModeVerificationError::Gateway(error)) => {
666            Err(SubscriberReadinessFailure::Gateway(error))
667        }
668    }
669}
670
671fn attempt_is_prepared(attempt: &PaymentAttempt) -> bool {
672    attempt_replay_disposition(attempt) == AttemptReplayDisposition::ResumePrepared
673}
674
675fn resolved_gateway_matches_attempt(
676    gateway: &syrup_rail::ResolvedGateway,
677    attempt: &PaymentAttempt,
678) -> bool {
679    let identity = attempt.identity();
680    gateway.billing_scope_id() == identity.billing_scope_id()
681        && gateway.gateway_account_id() == identity.gateway_account_id()
682        && gateway.gateway_configuration_id() == identity.gateway_configuration_id()
683}
684
685fn is_retryable_renewal_admission_error(error: &SubscriptionEnrollmentApplicationError) -> bool {
686    let sqlstate = match error {
687        SubscriptionEnrollmentApplicationError::Sql(sqlx::Error::Database(error)) => error.code(),
688        SubscriptionEnrollmentApplicationError::Attempt(PaymentAttemptStoreError::Sql(
689            sqlx::Error::Database(error),
690        )) => error.code(),
691        _ => None,
692    };
693    matches!(
694        sqlstate.as_deref(),
695        Some("40001" | "40P01" | "55P03" | "57014")
696    )
697}
698
699struct GatewayAccountSnapshot {
700    account_id: GatewayAccountId,
701    provider_key: GatewayProviderKey,
702}
703
704/// Canonical identity the resolver must return for subscriber-initiated
705/// gateway mutations.
706struct ExpectedGatewayIdentity<'a> {
707    billing_scope_id: BillingScopeId,
708    gateway_account_id: GatewayAccountId,
709    gateway_configuration_id: syrup_rail::GatewayConfigurationId,
710    provider_key: &'a GatewayProviderKey,
711}
712
713impl<'a> ExpectedGatewayIdentity<'a> {
714    const fn for_account(
715        billing_scope_id: BillingScopeId,
716        gateway_configuration_id: syrup_rail::GatewayConfigurationId,
717        account: &'a GatewayAccountSnapshot,
718    ) -> Self {
719        Self {
720            billing_scope_id,
721            gateway_account_id: account.account_id,
722            gateway_configuration_id,
723            provider_key: &account.provider_key,
724        }
725    }
726
727    fn matches(&self, gateway: &syrup_rail::ResolvedGateway) -> bool {
728        self.matches_components(
729            gateway.billing_scope_id(),
730            gateway.gateway_account_id(),
731            gateway.gateway_configuration_id(),
732            gateway.provider_key(),
733        )
734    }
735
736    fn matches_components(
737        &self,
738        billing_scope_id: BillingScopeId,
739        gateway_account_id: GatewayAccountId,
740        gateway_configuration_id: syrup_rail::GatewayConfigurationId,
741        provider_key: &GatewayProviderKey,
742    ) -> bool {
743        billing_scope_id == self.billing_scope_id
744            && gateway_account_id == self.gateway_account_id
745            && gateway_configuration_id == self.gateway_configuration_id
746            && provider_key == self.provider_key
747    }
748}
749
750struct RenewalGatewayAccountSnapshot {
751    account_id: GatewayAccountId,
752    configuration_id: syrup_rail::GatewayConfigurationId,
753    provider_key: GatewayProviderKey,
754}
755
756impl RenewalGatewayAccountSnapshot {
757    fn as_gateway_snapshot(&self) -> GatewayAccountSnapshot {
758        GatewayAccountSnapshot {
759            account_id: self.account_id,
760            provider_key: self.provider_key.clone(),
761        }
762    }
763}
764
765const fn map_reservation_build_error(
766    error: SubscriptionEnrollmentReservationBuildError,
767) -> SubscriptionBillingServiceError {
768    match error {
769        SubscriptionEnrollmentReservationBuildError::GatewayIdentityMismatch => {
770            SubscriptionBillingServiceError::ResolvedGatewayIdentityMismatch
771        }
772        SubscriptionEnrollmentReservationBuildError::AttemptKindMismatch
773        | SubscriptionEnrollmentReservationBuildError::InvalidCharge
774        | SubscriptionEnrollmentReservationBuildError::InvalidTerms => {
775            SubscriptionBillingServiceError::InvalidState(INVALID_SERVICE_STATE)
776        }
777    }
778}
779
780#[cfg(test)]
781mod tests;