Skip to main content

syrup_rail_postgres/
subscription_billing_service.rs

1use std::{fmt, sync::Arc, time::Duration};
2
3use sqlx::PgPool;
4use syrup_rail::{
5    BillingScopeId, ChargeHostTarget, ChargeRenewal, EndUserMutationAdmission,
6    EndUserMutationAdmissionResult, EndUserMutationCommand, EndUserMutationOperation,
7    EnrollSubscription, GatewayAccountId, GatewayAccountMode, GatewayDiagnostic, GatewayError,
8    GatewayNotSubmittedError, GatewayPaymentDescriptor, GatewayPaymentOutcome, GatewayProviderKey,
9    GatewayResolutionError, GatewayResolver, HostChargePaymentResult, HostChargeReservation,
10    HostChargeTargetRejection, PaymentAttempt, PaymentAttemptId, PaymentAttemptKind,
11    PaymentAttemptStatus, PaymentResolutionCode, ProcessorEvidence, RecoverSubscriptionPayment,
12    ReplaceSubscriptionPaymentMethod, SubscriptionEnrollmentPaymentResult,
13    SubscriptionEnrollmentPreflightOutcome, SubscriptionEnrollmentReservation,
14    SubscriptionEnrollmentReservationBuildError, SubscriptionEnrollmentReservationOutcome,
15    SubscriptionEnrollmentReservationRejection, SubscriptionEnrollmentSubmissionRejection,
16    SubscriptionPaymentMethodReplacement, SubscriptionPaymentMethodReplacementPreflightOutcome,
17    SubscriptionPaymentMethodReplacementRejection,
18    SubscriptionPaymentMethodReplacementReservationOutcome,
19    SubscriptionPaymentMethodReplacementSubmissionRejection, SubscriptionRecoveryPreflightOutcome,
20    SubscriptionRecoveryReservation, SubscriptionRecoveryReservationOutcome,
21    SubscriptionRecoveryReservationRejection, SubscriptionRecoverySubmissionRejection,
22    SubscriptionRenewalOutcome, SubscriptionRenewalReservation,
23    SubscriptionRenewalReservationOutcome, SubscriptionRenewalReservationRejection,
24};
25use thiserror::Error;
26
27use crate::host_charge_application::{
28    HostChargeBeforeSubmissionResolution, resolve_host_charge_before_submission,
29};
30use crate::{
31    BillingTransactionCoordinator, HostChargeAdmissionOutcome, HostChargeApplicationError,
32    HostChargePreflightOutcome, HostChargeProviderResult, HostChargeReservationOutcome,
33    HostChargeStoreError, HostChargeTargetStore, PaymentAttemptStoreError,
34    SubscriptionEnrollmentAdmissionOutcome, SubscriptionEnrollmentApplicationError,
35    SubscriptionEnrollmentProviderResult, SubscriptionOfferStore,
36    SubscriptionPaymentMethodReplacementAdmissionOutcome,
37    SubscriptionPaymentMethodReplacementProviderResult, SubscriptionRecoveryAdmissionOutcome,
38    SubscriptionRecoveryProviderResult, SubscriptionRenewalAdmissionOutcome,
39    SubscriptionRenewalProviderResult, admit_host_charge_submission,
40    admit_subscription_enrollment_submission, admit_subscription_payment_method_replacement,
41    admit_subscription_recovery_submission, admit_subscription_renewal_submission,
42    apply_reconciled_host_charge_gateway_outcome,
43    apply_reconciled_subscription_enrollment_gateway_outcome,
44    apply_reconciled_subscription_payment_method_replacement_gateway_outcome,
45    apply_reconciled_subscription_recovery_gateway_outcome,
46    apply_reconciled_subscription_renewal_gateway_outcome,
47    enrollment_application::{
48        OutcomeResolutionBoundary, RateLimitCooldown, payment_result_for_attempt,
49        resolve_non_approved_outcome, resolve_payment_method_replacement_non_approved_outcome,
50        resolve_recovery_non_approved_outcome, resolve_renewal_non_approved_outcome,
51    },
52    preflight_host_charge_in_transaction, preflight_subscription_enrollment_in_transaction,
53    preflight_subscription_payment_method_replacement_in_transaction,
54    preflight_subscription_recovery_in_transaction, reserve_host_charge_in_transaction,
55    reserve_subscription_enrollment_in_transaction,
56    reserve_subscription_payment_method_replacement_in_transaction,
57    reserve_subscription_recovery_in_transaction, reserve_subscription_renewal_in_transaction,
58    submit_admitted_host_charge, submit_admitted_subscription_enrollment,
59    submit_admitted_subscription_payment_method_replacement, submit_admitted_subscription_recovery,
60    submit_admitted_subscription_renewal,
61};
62
63const INVALID_SERVICE_STATE: &str = "canonical subscription enrollment service state is invalid";
64const LIVE_READINESS_FAILED_TEXT: &str =
65    "Payment was not submitted because the payment processor was not ready for live transactions.";
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum GatewayMutationCooldownScope {
69    Account,
70    Provider,
71}
72
73#[derive(Error)]
74pub enum SubscriptionEnrollmentServiceError {
75    #[error("subscription enrollment storage failed")]
76    Sql(#[from] sqlx::Error),
77    #[error("subscription enrollment attempt storage failed")]
78    Attempt(#[from] PaymentAttemptStoreError),
79    #[error("subscription enrollment application failed")]
80    Application(#[from] SubscriptionEnrollmentApplicationError),
81    #[error("host charge application failed")]
82    HostChargeApplication(#[from] HostChargeApplicationError),
83    #[error("host charge storage failed")]
84    HostChargeStore(#[from] HostChargeStoreError),
85    #[error("host charge capability is not configured")]
86    HostChargeUnavailable,
87    #[error("the idempotency key belongs to a different payment request")]
88    IdempotencyConflict,
89    #[error("end-user mutation admission was denied")]
90    AdmissionDenied { retry_after: Duration },
91    #[error("end-user mutation admission timed out")]
92    AdmissionTimeout,
93    #[error("end-user mutation admission is unavailable")]
94    AdmissionUnavailable,
95    #[error("gateway account or configuration changed")]
96    GatewayConfigurationChanged,
97    #[error("gateway resolution failed")]
98    GatewayResolution(#[from] GatewayResolutionError),
99    #[error("gateway resolver returned a different canonical identity")]
100    ResolvedGatewayIdentityMismatch,
101    #[error("gateway mutation cooldown is active")]
102    GatewayMutationCooldown { scope: GatewayMutationCooldownScope },
103    #[error("subscription enrollment reservation was rejected")]
104    ReservationRejected(SubscriptionEnrollmentReservationRejection),
105    #[error("subscription enrollment submission was rejected")]
106    SubmissionRejected(SubscriptionEnrollmentSubmissionRejection),
107    #[error("host charge reservation was rejected")]
108    HostChargeReservationRejected(HostChargeTargetRejection),
109    #[error("host charge submission was rejected")]
110    HostChargeSubmissionRejected(HostChargeTargetRejection),
111    #[error("subscription recovery reservation was rejected")]
112    RecoveryReservationRejected(SubscriptionRecoveryReservationRejection),
113    #[error("subscription recovery submission was rejected")]
114    RecoverySubmissionRejected(SubscriptionRecoverySubmissionRejection),
115    #[error("subscription renewal reservation was rejected")]
116    RenewalReservationRejected(SubscriptionRenewalReservationRejection),
117    #[error("subscription payment method replacement reservation was rejected")]
118    PaymentMethodReplacementReservationRejected(SubscriptionPaymentMethodReplacementRejection),
119    #[error("subscription payment method replacement submission was rejected")]
120    PaymentMethodReplacementSubmissionRejected(
121        SubscriptionPaymentMethodReplacementSubmissionRejection,
122    ),
123    #[error("gateway mutation was not submitted")]
124    GatewayNotSubmitted(#[source] GatewayNotSubmittedError),
125    #[error("gateway readiness check failed")]
126    GatewayReadiness(#[source] GatewayError),
127    #[error("{0}")]
128    InvalidState(&'static str),
129}
130
131impl fmt::Debug for SubscriptionEnrollmentServiceError {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Self::Sql(_) => formatter.write_str("SubscriptionEnrollmentServiceError::Sql"),
135            Self::Attempt(_) => formatter.write_str("SubscriptionEnrollmentServiceError::Attempt"),
136            Self::Application(_) => {
137                formatter.write_str("SubscriptionEnrollmentServiceError::Application")
138            }
139            Self::HostChargeApplication(_) => {
140                formatter.write_str("SubscriptionEnrollmentServiceError::HostChargeApplication")
141            }
142            Self::HostChargeStore(_) => {
143                formatter.write_str("SubscriptionEnrollmentServiceError::HostChargeStore")
144            }
145            Self::HostChargeUnavailable => formatter
146                .write_str("SubscriptionEnrollmentServiceError::HostChargeUnavailable"),
147            Self::IdempotencyConflict => {
148                formatter.write_str("SubscriptionEnrollmentServiceError::IdempotencyConflict")
149            }
150            Self::AdmissionDenied { retry_after } => formatter
151                .debug_struct("SubscriptionEnrollmentServiceError::AdmissionDenied")
152                .field("retry_after", retry_after)
153                .finish(),
154            Self::AdmissionTimeout => {
155                formatter.write_str("SubscriptionEnrollmentServiceError::AdmissionTimeout")
156            }
157            Self::AdmissionUnavailable => {
158                formatter.write_str("SubscriptionEnrollmentServiceError::AdmissionUnavailable")
159            }
160            Self::GatewayConfigurationChanged => formatter
161                .write_str("SubscriptionEnrollmentServiceError::GatewayConfigurationChanged"),
162            Self::GatewayResolution(error) => formatter
163                .debug_tuple("SubscriptionEnrollmentServiceError::GatewayResolution")
164                .field(error)
165                .finish(),
166            Self::ResolvedGatewayIdentityMismatch => formatter
167                .write_str("SubscriptionEnrollmentServiceError::ResolvedGatewayIdentityMismatch"),
168            Self::GatewayMutationCooldown { scope } => formatter
169                .debug_struct("SubscriptionEnrollmentServiceError::GatewayMutationCooldown")
170                .field("scope", scope)
171                .finish(),
172            Self::ReservationRejected(reason) => formatter
173                .debug_tuple("SubscriptionEnrollmentServiceError::ReservationRejected")
174                .field(reason)
175                .finish(),
176            Self::SubmissionRejected(reason) => formatter
177                .debug_tuple("SubscriptionEnrollmentServiceError::SubmissionRejected")
178                .field(reason)
179                .finish(),
180            Self::HostChargeReservationRejected(reason) => formatter
181                .debug_tuple("SubscriptionEnrollmentServiceError::HostChargeReservationRejected")
182                .field(reason)
183                .finish(),
184            Self::HostChargeSubmissionRejected(reason) => formatter
185                .debug_tuple("SubscriptionEnrollmentServiceError::HostChargeSubmissionRejected")
186                .field(reason)
187                .finish(),
188            Self::RecoveryReservationRejected(reason) => formatter
189                .debug_tuple("SubscriptionEnrollmentServiceError::RecoveryReservationRejected")
190                .field(reason)
191                .finish(),
192            Self::RecoverySubmissionRejected(reason) => formatter
193                .debug_tuple("SubscriptionEnrollmentServiceError::RecoverySubmissionRejected")
194                .field(reason)
195                .finish(),
196            Self::RenewalReservationRejected(reason) => formatter
197                .debug_tuple("SubscriptionEnrollmentServiceError::RenewalReservationRejected")
198                .field(reason)
199                .finish(),
200            Self::PaymentMethodReplacementReservationRejected(reason) => formatter
201                .debug_tuple(
202                    "SubscriptionEnrollmentServiceError::PaymentMethodReplacementReservationRejected",
203                )
204                .field(reason)
205                .finish(),
206            Self::PaymentMethodReplacementSubmissionRejected(reason) => formatter
207                .debug_tuple(
208                    "SubscriptionEnrollmentServiceError::PaymentMethodReplacementSubmissionRejected",
209                )
210                .field(reason)
211                .finish(),
212            Self::GatewayNotSubmitted(error) => formatter
213                .debug_tuple("SubscriptionEnrollmentServiceError::GatewayNotSubmitted")
214                .field(error)
215                .finish(),
216            Self::GatewayReadiness(error) => formatter
217                .debug_tuple("SubscriptionEnrollmentServiceError::GatewayReadiness")
218                .field(error)
219                .finish(),
220            Self::InvalidState(detail) => formatter
221                .debug_tuple("SubscriptionEnrollmentServiceError::InvalidState")
222                .field(detail)
223                .finish(),
224        }
225    }
226}
227
228#[derive(Clone)]
229pub struct SubscriptionBillingService {
230    pool: PgPool,
231    offers: Arc<dyn SubscriptionOfferStore>,
232    resolver: Arc<dyn GatewayResolver>,
233    admission: Arc<dyn EndUserMutationAdmission>,
234    coordinator: Arc<dyn BillingTransactionCoordinator>,
235    host_charge_targets: Option<Arc<dyn HostChargeTargetStore>>,
236}
237
238impl SubscriptionBillingService {
239    pub fn new(
240        pool: PgPool,
241        offers: Arc<dyn SubscriptionOfferStore>,
242        resolver: Arc<dyn GatewayResolver>,
243        admission: Arc<dyn EndUserMutationAdmission>,
244        coordinator: Arc<dyn BillingTransactionCoordinator>,
245    ) -> Self {
246        Self {
247            pool,
248            offers,
249            resolver,
250            admission,
251            coordinator,
252            host_charge_targets: None,
253        }
254    }
255
256    pub fn with_host_charge_targets(mut self, targets: Arc<dyn HostChargeTargetStore>) -> Self {
257        self.host_charge_targets = Some(targets);
258        self
259    }
260
261    /// Charges one host-owned target through the canonical attempt ledger.
262    ///
263    /// Target eligibility and economics are supplied by the configured host
264    /// extension. No transaction or target lock spans gateway I/O.
265    pub async fn charge_host_target(
266        &self,
267        command: ChargeHostTarget,
268    ) -> Result<HostChargePaymentResult, SubscriptionEnrollmentServiceError> {
269        let targets = self
270            .host_charge_targets
271            .as_deref()
272            .ok_or(SubscriptionEnrollmentServiceError::HostChargeUnavailable)?;
273        let (snapshot, prepared_reservation) =
274            match self.preflight_host_charge(targets, &command).await? {
275                HostChargePreflightOutcome::Continue(snapshot) => (snapshot, None),
276                HostChargePreflightOutcome::Replay(attempt)
277                    if attempt.status() == PaymentAttemptStatus::Pending
278                        && attempt.state().timestamps().submitted_at().is_none() =>
279                {
280                    let reservation =
281                        HostChargeReservation::from_attempt(&attempt).map_err(|_| {
282                            SubscriptionEnrollmentServiceError::InvalidState(INVALID_SERVICE_STATE)
283                        })?;
284                    (reservation.snapshot(), Some(reservation))
285                }
286                HostChargePreflightOutcome::Replay(attempt) => {
287                    return Ok(HostChargePaymentResult::new(*attempt));
288                }
289                HostChargePreflightOutcome::IdempotencyConflict => {
290                    return Err(SubscriptionEnrollmentServiceError::IdempotencyConflict);
291                }
292                HostChargePreflightOutcome::Rejected { reason } => {
293                    return Err(
294                        SubscriptionEnrollmentServiceError::HostChargeReservationRejected(reason),
295                    );
296                }
297            };
298
299        let account = self
300            .gateway_account(
301                command.billing_scope_id(),
302                command.gateway_configuration_id(),
303            )
304            .await?;
305        if prepared_reservation.is_none()
306            && let Some(scope) = self.active_cooldown(&account).await?
307        {
308            return Err(SubscriptionEnrollmentServiceError::GatewayMutationCooldown { scope });
309        }
310        let gateway = self
311            .resolver
312            .resolve(
313                command.billing_scope_id(),
314                account.account_id,
315                command.gateway_configuration_id(),
316                account.provider_key.clone(),
317            )
318            .await?;
319        if gateway.billing_scope_id() != command.billing_scope_id()
320            || gateway.gateway_account_id() != account.account_id
321            || gateway.gateway_configuration_id() != command.gateway_configuration_id()
322            || gateway.provider_key() != &account.provider_key
323        {
324            return Err(SubscriptionEnrollmentServiceError::ResolvedGatewayIdentityMismatch);
325        }
326        if prepared_reservation.as_ref().is_some_and(|reservation| {
327            reservation.identity().gateway_account_id() != gateway.gateway_account_id()
328                || reservation.request().gateway_order_id()
329                    != &gateway.mutation_reference_factory().for_attempt(
330                        PaymentAttemptKind::HostCharge,
331                        reservation.identity().attempt_id(),
332                    )
333        }) {
334            return Err(SubscriptionEnrollmentServiceError::InvalidState(
335                INVALID_SERVICE_STATE,
336            ));
337        }
338        match gateway.account_mode().await {
339            Ok(GatewayAccountMode::Live) => {}
340            Ok(GatewayAccountMode::Test) => {
341                return Err(SubscriptionEnrollmentServiceError::GatewayReadiness(
342                    GatewayError::Configuration(GatewayDiagnostic::new(LIVE_READINESS_FAILED_TEXT)),
343                ));
344            }
345            Err(GatewayError::RateLimited(_)) => {
346                self.extend_provider_cooldown(&account.provider_key).await?;
347                return Err(
348                    SubscriptionEnrollmentServiceError::GatewayMutationCooldown {
349                        scope: GatewayMutationCooldownScope::Provider,
350                    },
351                );
352            }
353            Err(error) => return Err(SubscriptionEnrollmentServiceError::GatewayReadiness(error)),
354        }
355
356        if prepared_reservation.is_none() {
357            self.admit_subscriber_mutation(
358                command.billing_scope_id(),
359                command.subscriber_id(),
360                EndUserMutationOperation::HostCharge,
361            )
362            .await?;
363        }
364
365        let candidate_id = prepared_reservation.as_ref().map_or_else(
366            || PaymentAttemptId::new(uuid::Uuid::now_v7()),
367            |reservation| reservation.identity().attempt_id(),
368        );
369        let mut reservation = match prepared_reservation {
370            Some(reservation) => reservation,
371            None => HostChargeReservation::from_command(&command, snapshot, &gateway, candidate_id)
372                .map_err(|_| {
373                    SubscriptionEnrollmentServiceError::InvalidState(INVALID_SERVICE_STATE)
374                })?,
375        };
376        let attempt = match self.reserve_host_charge(targets, &reservation).await? {
377            HostChargeReservationOutcome::Reserved(attempt)
378            | HostChargeReservationOutcome::Replay(attempt)
379                if attempt.status() == PaymentAttemptStatus::Pending
380                    && attempt.state().timestamps().submitted_at().is_none() =>
381            {
382                attempt
383            }
384            HostChargeReservationOutcome::Replay(attempt) => {
385                return Ok(HostChargePaymentResult::new(attempt));
386            }
387            HostChargeReservationOutcome::IdempotencyConflict => {
388                return Err(SubscriptionEnrollmentServiceError::IdempotencyConflict);
389            }
390            HostChargeReservationOutcome::Rejected { reason } => {
391                return Err(
392                    SubscriptionEnrollmentServiceError::HostChargeReservationRejected(reason),
393                );
394            }
395            HostChargeReservationOutcome::Reserved(_) => {
396                return Err(SubscriptionEnrollmentServiceError::InvalidState(
397                    INVALID_SERVICE_STATE,
398                ));
399            }
400        };
401        if attempt.identity().attempt_id() != candidate_id {
402            reservation = HostChargeReservation::from_attempt(&attempt).map_err(|_| {
403                SubscriptionEnrollmentServiceError::InvalidState(INVALID_SERVICE_STATE)
404            })?;
405            if reservation.identity().gateway_account_id() != gateway.gateway_account_id()
406                || reservation.request().gateway_order_id()
407                    != &gateway.mutation_reference_factory().for_attempt(
408                        PaymentAttemptKind::HostCharge,
409                        reservation.identity().attempt_id(),
410                    )
411            {
412                return Err(SubscriptionEnrollmentServiceError::InvalidState(
413                    INVALID_SERVICE_STATE,
414                ));
415            }
416        }
417
418        if let Some(scope) = self.active_cooldown(&account).await? {
419            return self
420                .resolve_host_charge_cooldown(
421                    targets,
422                    &reservation,
423                    &account.provider_key,
424                    scope,
425                    HostChargeBeforeSubmissionResolution::prepared(),
426                )
427                .await;
428        }
429        match gateway.account_mode().await {
430            Ok(GatewayAccountMode::Live) => {}
431            Ok(GatewayAccountMode::Test) => {
432                return self
433                    .resolve_host_charge_readiness(
434                        targets,
435                        &reservation,
436                        GatewayDiagnostic::new(LIVE_READINESS_FAILED_TEXT),
437                        PaymentResolutionCode::GatewayLiveReadinessFailedBeforeSubmission,
438                        HostChargeBeforeSubmissionResolution::prepared(),
439                    )
440                    .await;
441            }
442            Err(GatewayError::RateLimited(detail)) => {
443                return self
444                    .resolve_host_charge_cooldown_with_detail(
445                        targets,
446                        &reservation,
447                        GatewayMutationCooldownScope::Provider,
448                        detail,
449                        HostChargeBeforeSubmissionResolution::prepared_provider_rate_limited(),
450                    )
451                    .await;
452            }
453            Err(_) => {
454                return self
455                    .resolve_host_charge_readiness(
456                        targets,
457                        &reservation,
458                        GatewayDiagnostic::new(LIVE_READINESS_FAILED_TEXT),
459                        PaymentResolutionCode::GatewayLiveReadinessFailedBeforeSubmission,
460                        HostChargeBeforeSubmissionResolution::prepared(),
461                    )
462                    .await;
463            }
464        }
465
466        let admission =
467            match admit_host_charge_submission(&self.pool, targets, &reservation).await? {
468                HostChargeAdmissionOutcome::Admitted(admission) => *admission,
469                HostChargeAdmissionOutcome::AlreadyAdmitted(attempt) => {
470                    return Ok(HostChargePaymentResult::new(attempt));
471                }
472                HostChargeAdmissionOutcome::Rejected { attempt, .. } => {
473                    return Ok(HostChargePaymentResult::new(attempt));
474                }
475            };
476        if let Some(scope) = self.active_cooldown(&account).await? {
477            return self
478                .resolve_host_charge_cooldown(
479                    targets,
480                    &reservation,
481                    &account.provider_key,
482                    scope,
483                    HostChargeBeforeSubmissionResolution::admitted_not_submitted(),
484                )
485                .await;
486        }
487        match submit_admitted_host_charge(
488            &self.pool,
489            self.coordinator.as_ref(),
490            targets,
491            admission,
492            &command,
493            &gateway,
494        )
495        .await?
496        {
497            HostChargeProviderResult::Payment(payment) => Ok(payment),
498            HostChargeProviderResult::NotSubmitted { payment, error } => {
499                if payment.attempt().state().resolution_code()
500                    == Some(crate::enrollment_application::not_submitted_resolution_code(&error))
501                {
502                    Err(SubscriptionEnrollmentServiceError::GatewayNotSubmitted(
503                        error,
504                    ))
505                } else {
506                    Ok(payment)
507                }
508            }
509        }
510    }
511
512    /// Runs one complete initial-subscription payment boundary.
513    ///
514    /// Matching replay and conflict are resolved before host admission. No
515    /// database transaction or lock is held across host admission, gateway
516    /// resolution, readiness I/O, or the one provider mutation.
517    pub async fn enroll(
518        &self,
519        command: EnrollSubscription,
520    ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentServiceError> {
521        match self.preflight(&command).await? {
522            SubscriptionEnrollmentPreflightOutcome::Continue => {}
523            SubscriptionEnrollmentPreflightOutcome::Replay(attempt) => {
524                return self.payment_result(*attempt).await;
525            }
526            SubscriptionEnrollmentPreflightOutcome::IdempotencyConflict => {
527                return Err(SubscriptionEnrollmentServiceError::IdempotencyConflict);
528            }
529        }
530
531        self.admit_subscriber_mutation(
532            command.billing_scope_id(),
533            command.subscriber_id(),
534            EndUserMutationOperation::SubscriptionInitial,
535        )
536        .await?;
537
538        let (account, gateway) = self
539            .resolve_active_gateway(
540                command.billing_scope_id(),
541                command.gateway_configuration_id(),
542            )
543            .await?;
544        let mut reservation = SubscriptionEnrollmentReservation::from_command(&command, &gateway)
545            .map_err(map_reservation_build_error)?;
546
547        let attempt = match self.reserve(&reservation).await? {
548            SubscriptionEnrollmentReservationOutcome::Reserved(attempt)
549            | SubscriptionEnrollmentReservationOutcome::Replay(attempt)
550                if attempt.status() == PaymentAttemptStatus::Pending
551                    && attempt.state().timestamps().submitted_at().is_none() =>
552            {
553                attempt
554            }
555            SubscriptionEnrollmentReservationOutcome::Replay(attempt) => {
556                return self.payment_result(attempt).await;
557            }
558            SubscriptionEnrollmentReservationOutcome::IdempotencyConflict => {
559                return Err(SubscriptionEnrollmentServiceError::IdempotencyConflict);
560            }
561            SubscriptionEnrollmentReservationOutcome::Rejected(reason) => {
562                return Err(SubscriptionEnrollmentServiceError::ReservationRejected(
563                    reason,
564                ));
565            }
566            SubscriptionEnrollmentReservationOutcome::Reserved(_) => {
567                return Err(SubscriptionEnrollmentServiceError::InvalidState(
568                    INVALID_SERVICE_STATE,
569                ));
570            }
571        };
572        if reservation.identity().attempt_id() != attempt.identity().attempt_id() {
573            reservation = SubscriptionEnrollmentReservation::from_command_for_attempt(
574                &command,
575                &gateway,
576                attempt.identity().attempt_id(),
577            )
578            .map_err(map_reservation_build_error)?;
579        }
580
581        if let Some(scope) = self.active_cooldown(&account).await? {
582            return self
583                .resolve_subscriber_readiness_failure(
584                    SubscriberInitiatedReservation::Initial(&reservation),
585                    SubscriberReadinessFailure::Cooldown(scope),
586                    OutcomeResolutionBoundary::Prepared,
587                )
588                .await;
589        }
590        if let Some(failure) = subscriber_gateway_readiness_failure(&gateway).await {
591            return self
592                .resolve_subscriber_readiness_failure(
593                    SubscriberInitiatedReservation::Initial(&reservation),
594                    failure,
595                    OutcomeResolutionBoundary::Prepared,
596                )
597                .await;
598        }
599
600        let admission = match admit_subscription_enrollment_submission(
601            &self.pool,
602            self.offers.as_ref(),
603            &reservation,
604        )
605        .await?
606        {
607            SubscriptionEnrollmentAdmissionOutcome::Admitted(admission) => *admission,
608            SubscriptionEnrollmentAdmissionOutcome::AlreadyAdmitted(attempt) => {
609                return self.payment_result(attempt).await;
610            }
611            SubscriptionEnrollmentAdmissionOutcome::Rejected { reason, .. } => {
612                return Err(SubscriptionEnrollmentServiceError::SubmissionRejected(
613                    reason,
614                ));
615            }
616        };
617
618        if let Some(scope) = self.active_cooldown(&account).await? {
619            return self
620                .resolve_subscriber_readiness_failure(
621                    SubscriberInitiatedReservation::Initial(&reservation),
622                    SubscriberReadinessFailure::Cooldown(scope),
623                    OutcomeResolutionBoundary::AdmittedNotSubmitted,
624                )
625                .await;
626        }
627        match submit_admitted_subscription_enrollment(
628            &self.pool,
629            self.coordinator.as_ref(),
630            admission,
631            &command,
632            &gateway,
633        )
634        .await?
635        {
636            SubscriptionEnrollmentProviderResult::Payment(payment) => Ok(payment),
637            SubscriptionEnrollmentProviderResult::NotSubmitted { payment, error } => {
638                preserve_concurrent_terminal_payment(payment, error)
639            }
640        }
641    }
642
643    /// Runs one complete automatic recurring-renewal boundary.
644    ///
645    /// Stale, future, canceled, paced, and contended work is a successful
646    /// no-op. The operation never invokes end-user admission or the live offer
647    /// store and never holds a database lock across provider I/O.
648    pub async fn renew(
649        &self,
650        command: ChargeRenewal,
651    ) -> Result<SubscriptionRenewalOutcome, SubscriptionEnrollmentServiceError> {
652        let Some(account) = self.renewal_gateway_account(command).await? else {
653            return Ok(SubscriptionRenewalOutcome::Noop);
654        };
655        if self
656            .active_cooldown(&account.as_gateway_snapshot())
657            .await?
658            .is_some()
659        {
660            return Ok(SubscriptionRenewalOutcome::Noop);
661        }
662        let gateway = self
663            .resolver
664            .resolve(
665                command.billing_scope_id(),
666                account.account_id,
667                account.configuration_id,
668                account.provider_key.clone(),
669            )
670            .await?;
671        if gateway.billing_scope_id() != command.billing_scope_id()
672            || gateway.gateway_account_id() != account.account_id
673            || gateway.gateway_configuration_id() != account.configuration_id
674            || gateway.provider_key() != &account.provider_key
675        {
676            return Err(SubscriptionEnrollmentServiceError::ResolvedGatewayIdentityMismatch);
677        }
678        match gateway.account_mode().await {
679            Ok(GatewayAccountMode::Live) => {}
680            Ok(GatewayAccountMode::Test) => {
681                return Err(SubscriptionEnrollmentServiceError::GatewayReadiness(
682                    GatewayError::Configuration(GatewayDiagnostic::new(LIVE_READINESS_FAILED_TEXT)),
683                ));
684            }
685            Err(GatewayError::RateLimited(_)) => {
686                self.extend_provider_cooldown(&account.provider_key).await?;
687                return Ok(SubscriptionRenewalOutcome::Noop);
688            }
689            Err(error) => return Err(SubscriptionEnrollmentServiceError::GatewayReadiness(error)),
690        }
691
692        let (reservation, attempt) = match self.reserve_renewal(command, &gateway).await? {
693            SubscriptionRenewalReservationOutcome::Reserved(reservation, attempt) => {
694                (*reservation, *attempt)
695            }
696            SubscriptionRenewalReservationOutcome::Rejected(
697                SubscriptionRenewalReservationRejection::PaymentMethodUpdateInProgress,
698            ) => {
699                return Err(
700                    SubscriptionEnrollmentServiceError::RenewalReservationRejected(
701                        SubscriptionRenewalReservationRejection::PaymentMethodUpdateInProgress,
702                    ),
703                );
704            }
705            SubscriptionRenewalReservationOutcome::Rejected(
706                SubscriptionRenewalReservationRejection::GatewayConfigurationChanged,
707            ) => {
708                return Err(SubscriptionEnrollmentServiceError::GatewayConfigurationChanged);
709            }
710            SubscriptionRenewalReservationOutcome::Rejected(_) => {
711                return Ok(SubscriptionRenewalOutcome::Noop);
712            }
713        };
714        if attempt.status() != PaymentAttemptStatus::Pending
715            || attempt.state().timestamps().submitted_at().is_some()
716            || attempt.identity() != reservation.identity()
717        {
718            return Err(SubscriptionEnrollmentServiceError::InvalidState(
719                INVALID_SERVICE_STATE,
720            ));
721        }
722        if let Some(scope) = self.active_cooldown(&account.as_gateway_snapshot()).await? {
723            self.resolve_renewal_cooldown(&reservation, scope, OutcomeResolutionBoundary::Prepared)
724                .await?;
725            return Ok(SubscriptionRenewalOutcome::Noop);
726        }
727        if !self
728            .renewal_readiness_open(&reservation, &gateway, OutcomeResolutionBoundary::Prepared)
729            .await?
730        {
731            return Ok(SubscriptionRenewalOutcome::Noop);
732        }
733
734        let admission = match admit_subscription_renewal_submission(&self.pool, &reservation).await
735        {
736            Err(error) if is_retryable_renewal_admission_error(&error) => {
737                self.resolve_renewal_readiness_failure(
738                    &reservation,
739                    GatewayDiagnostic::new(
740                        "subscription billing state could not be locked for final admission",
741                    ),
742                    PaymentResolutionCode::SubscriptionRenewalRetryStateChangedBeforeCharge,
743                    None,
744                    OutcomeResolutionBoundary::Prepared,
745                )
746                .await?;
747                return Ok(SubscriptionRenewalOutcome::Noop);
748            }
749            Err(error) => return Err(error.into()),
750            Ok(outcome) => match outcome {
751                SubscriptionRenewalAdmissionOutcome::Admitted(admission) => *admission,
752                SubscriptionRenewalAdmissionOutcome::AlreadyAdmitted(attempt) => {
753                    return self
754                        .payment_result(attempt)
755                        .await
756                        .map(Box::new)
757                        .map(SubscriptionRenewalOutcome::Payment);
758                }
759                SubscriptionRenewalAdmissionOutcome::Rejected { attempt, .. } => {
760                    return self
761                        .payment_result(attempt)
762                        .await
763                        .map(Box::new)
764                        .map(SubscriptionRenewalOutcome::Payment);
765                }
766            },
767        };
768        if let Some(scope) = self.active_cooldown(&account.as_gateway_snapshot()).await? {
769            self.resolve_renewal_cooldown(
770                &reservation,
771                scope,
772                OutcomeResolutionBoundary::AdmittedNotSubmitted,
773            )
774            .await?;
775            return Ok(SubscriptionRenewalOutcome::Noop);
776        }
777        match submit_admitted_subscription_renewal(
778            &self.pool,
779            self.coordinator.as_ref(),
780            admission,
781            &gateway,
782        )
783        .await?
784        {
785            SubscriptionRenewalProviderResult::Payment(payment) => {
786                Ok(SubscriptionRenewalOutcome::Payment(Box::new(payment)))
787            }
788            SubscriptionRenewalProviderResult::NotSubmitted { payment, error } => {
789                Ok(SubscriptionRenewalOutcome::NotSubmitted {
790                    payment: Box::new(payment),
791                    error,
792                })
793            }
794        }
795    }
796
797    /// Runs one complete subscriber-initiated recovery payment boundary.
798    ///
799    /// The command carries only the owner, requested plan/configuration, and
800    /// memory-only token/contact. Reservation derives the exact due period,
801    /// amount, subscription, and payment-state snapshot under lock.
802    pub async fn recover(
803        &self,
804        command: RecoverSubscriptionPayment,
805    ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentServiceError> {
806        match self.preflight_recovery(&command).await? {
807            SubscriptionRecoveryPreflightOutcome::Continue => {}
808            SubscriptionRecoveryPreflightOutcome::Replay(attempt) => {
809                return self.payment_result(*attempt).await;
810            }
811            SubscriptionRecoveryPreflightOutcome::IdempotencyConflict => {
812                return Err(SubscriptionEnrollmentServiceError::IdempotencyConflict);
813            }
814        }
815
816        self.admit_subscriber_mutation(
817            command.billing_scope_id(),
818            command.subscriber_id(),
819            EndUserMutationOperation::SubscriptionRecovery,
820        )
821        .await?;
822
823        let (account, gateway) = self
824            .resolve_active_gateway(
825                command.billing_scope_id(),
826                command.gateway_configuration_id(),
827            )
828            .await?;
829
830        let (reservation, attempt) = match self.reserve_recovery(&command, &gateway).await? {
831            SubscriptionRecoveryReservationOutcome::Reserved(reservation, attempt) => {
832                (*reservation, *attempt)
833            }
834            SubscriptionRecoveryReservationOutcome::Replay(attempt) => {
835                return self.payment_result(*attempt).await;
836            }
837            SubscriptionRecoveryReservationOutcome::IdempotencyConflict => {
838                return Err(SubscriptionEnrollmentServiceError::IdempotencyConflict);
839            }
840            SubscriptionRecoveryReservationOutcome::Rejected(reason) => {
841                return Err(
842                    SubscriptionEnrollmentServiceError::RecoveryReservationRejected(reason),
843                );
844            }
845        };
846        if attempt.status() != PaymentAttemptStatus::Pending
847            || attempt.state().timestamps().submitted_at().is_some()
848            || attempt.identity() != reservation.identity()
849        {
850            return Err(SubscriptionEnrollmentServiceError::InvalidState(
851                INVALID_SERVICE_STATE,
852            ));
853        }
854
855        if let Some(scope) = self.active_cooldown(&account).await? {
856            return self
857                .resolve_subscriber_readiness_failure(
858                    SubscriberInitiatedReservation::Recovery(&reservation),
859                    SubscriberReadinessFailure::Cooldown(scope),
860                    OutcomeResolutionBoundary::Prepared,
861                )
862                .await;
863        }
864        if let Some(failure) = subscriber_gateway_readiness_failure(&gateway).await {
865            return self
866                .resolve_subscriber_readiness_failure(
867                    SubscriberInitiatedReservation::Recovery(&reservation),
868                    failure,
869                    OutcomeResolutionBoundary::Prepared,
870                )
871                .await;
872        }
873
874        let admission =
875            match admit_subscription_recovery_submission(&self.pool, &reservation).await? {
876                SubscriptionRecoveryAdmissionOutcome::Admitted(admission) => *admission,
877                SubscriptionRecoveryAdmissionOutcome::AlreadyAdmitted(attempt) => {
878                    return self.payment_result(attempt).await;
879                }
880                SubscriptionRecoveryAdmissionOutcome::Rejected { attempt, .. } => {
881                    return self.payment_result(attempt).await;
882                }
883            };
884        if let Some(scope) = self.active_cooldown(&account).await? {
885            return self
886                .resolve_subscriber_readiness_failure(
887                    SubscriberInitiatedReservation::Recovery(&reservation),
888                    SubscriberReadinessFailure::Cooldown(scope),
889                    OutcomeResolutionBoundary::AdmittedNotSubmitted,
890                )
891                .await;
892        }
893        if let Some(failure) = subscriber_gateway_readiness_failure(&gateway).await {
894            return self
895                .resolve_subscriber_readiness_failure(
896                    SubscriberInitiatedReservation::Recovery(&reservation),
897                    failure,
898                    OutcomeResolutionBoundary::AdmittedNotSubmitted,
899                )
900                .await;
901        }
902        if let Some(scope) = self.active_cooldown(&account).await? {
903            return self
904                .resolve_subscriber_readiness_failure(
905                    SubscriberInitiatedReservation::Recovery(&reservation),
906                    SubscriberReadinessFailure::Cooldown(scope),
907                    OutcomeResolutionBoundary::AdmittedNotSubmitted,
908                )
909                .await;
910        }
911        match submit_admitted_subscription_recovery(
912            &self.pool,
913            self.coordinator.as_ref(),
914            admission,
915            &command,
916            &gateway,
917        )
918        .await?
919        {
920            SubscriptionRecoveryProviderResult::Payment(payment) => Ok(payment),
921            SubscriptionRecoveryProviderResult::NotSubmitted { payment, error } => {
922                preserve_concurrent_terminal_payment(payment, error)
923            }
924        }
925    }
926
927    /// Runs one complete stored payment-method replacement boundary.
928    pub async fn replace_payment_method(
929        &self,
930        command: ReplaceSubscriptionPaymentMethod,
931    ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentServiceError> {
932        match self.preflight_payment_method_replacement(&command).await? {
933            SubscriptionPaymentMethodReplacementPreflightOutcome::Continue => {}
934            SubscriptionPaymentMethodReplacementPreflightOutcome::Replay(attempt) => {
935                return self.payment_result(*attempt).await;
936            }
937            SubscriptionPaymentMethodReplacementPreflightOutcome::IdempotencyConflict => {
938                return Err(SubscriptionEnrollmentServiceError::IdempotencyConflict);
939            }
940        }
941        self.admit_subscriber_mutation(
942            command.billing_scope_id(),
943            command.subscriber_id(),
944            EndUserMutationOperation::SubscriptionPaymentMethodUpdate,
945        )
946        .await?;
947        let (account, gateway) = self
948            .resolve_active_gateway(
949                command.billing_scope_id(),
950                command.gateway_configuration_id(),
951            )
952            .await?;
953        let (reservation, attempt) = match self
954            .reserve_payment_method_replacement(&command, &gateway)
955            .await?
956        {
957            SubscriptionPaymentMethodReplacementReservationOutcome::Reserved(
958                reservation,
959                attempt,
960            ) => (*reservation, *attempt),
961            SubscriptionPaymentMethodReplacementReservationOutcome::Replay(attempt) => {
962                return self.payment_result(*attempt).await;
963            }
964            SubscriptionPaymentMethodReplacementReservationOutcome::IdempotencyConflict => {
965                return Err(SubscriptionEnrollmentServiceError::IdempotencyConflict);
966            }
967            SubscriptionPaymentMethodReplacementReservationOutcome::Rejected(reason) => {
968                return Err(
969                    SubscriptionEnrollmentServiceError::PaymentMethodReplacementReservationRejected(
970                        reason,
971                    ),
972                );
973            }
974        };
975        if attempt.status() != PaymentAttemptStatus::Pending
976            || attempt.state().timestamps().submitted_at().is_some()
977            || attempt.identity() != reservation.identity()
978        {
979            return Err(SubscriptionEnrollmentServiceError::InvalidState(
980                INVALID_SERVICE_STATE,
981            ));
982        }
983        if let Some(scope) = self.active_cooldown(&account).await? {
984            return self
985                .resolve_subscriber_readiness_failure(
986                    SubscriberInitiatedReservation::PaymentMethodReplacement(&reservation),
987                    SubscriberReadinessFailure::Cooldown(scope),
988                    OutcomeResolutionBoundary::Prepared,
989                )
990                .await;
991        }
992        if let Some(failure) = subscriber_gateway_readiness_failure(&gateway).await {
993            return self
994                .resolve_subscriber_readiness_failure(
995                    SubscriberInitiatedReservation::PaymentMethodReplacement(&reservation),
996                    failure,
997                    OutcomeResolutionBoundary::Prepared,
998                )
999                .await;
1000        }
1001        let admission =
1002            match admit_subscription_payment_method_replacement(&self.pool, &reservation).await? {
1003                SubscriptionPaymentMethodReplacementAdmissionOutcome::Admitted(admission) => {
1004                    *admission
1005                }
1006                SubscriptionPaymentMethodReplacementAdmissionOutcome::AlreadyAdmitted(attempt) => {
1007                    return self.payment_result(attempt).await;
1008                }
1009                SubscriptionPaymentMethodReplacementAdmissionOutcome::Rejected {
1010                    attempt, ..
1011                } => {
1012                    return self.payment_result(attempt).await;
1013                }
1014            };
1015        if let Some(scope) = self.active_cooldown(&account).await? {
1016            return self
1017                .resolve_subscriber_readiness_failure(
1018                    SubscriberInitiatedReservation::PaymentMethodReplacement(&reservation),
1019                    SubscriberReadinessFailure::Cooldown(scope),
1020                    OutcomeResolutionBoundary::AdmittedNotSubmitted,
1021                )
1022                .await;
1023        }
1024        if let Some(failure) = subscriber_gateway_readiness_failure(&gateway).await {
1025            return self
1026                .resolve_subscriber_readiness_failure(
1027                    SubscriberInitiatedReservation::PaymentMethodReplacement(&reservation),
1028                    failure,
1029                    OutcomeResolutionBoundary::AdmittedNotSubmitted,
1030                )
1031                .await;
1032        }
1033        if let Some(scope) = self.active_cooldown(&account).await? {
1034            return self
1035                .resolve_subscriber_readiness_failure(
1036                    SubscriberInitiatedReservation::PaymentMethodReplacement(&reservation),
1037                    SubscriberReadinessFailure::Cooldown(scope),
1038                    OutcomeResolutionBoundary::AdmittedNotSubmitted,
1039                )
1040                .await;
1041        }
1042        match submit_admitted_subscription_payment_method_replacement(
1043            &self.pool,
1044            self.coordinator.as_ref(),
1045            admission,
1046            &command,
1047            &gateway,
1048        )
1049        .await?
1050        {
1051            SubscriptionPaymentMethodReplacementProviderResult::Payment(payment) => Ok(payment),
1052            SubscriptionPaymentMethodReplacementProviderResult::NotSubmitted { payment, error } => {
1053                preserve_concurrent_terminal_payment(payment, error)
1054            }
1055        }
1056    }
1057
1058    /// Applies an already-observed provider outcome without another submission.
1059    ///
1060    /// Reconciliation enters the same application authority as foreground
1061    /// enrollment but reconstructs its secret-free reservation from the exact
1062    /// durable attempt and canonical gateway account.
1063    pub async fn apply_reconciled_outcome(
1064        &self,
1065        billing_scope_id: BillingScopeId,
1066        attempt_id: PaymentAttemptId,
1067        outcome: &GatewayPaymentOutcome,
1068    ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentServiceError> {
1069        let mut transaction = self.pool.begin().await?;
1070        let attempt = crate::find_payment_attempt_by_id_in_transaction(
1071            &mut transaction,
1072            billing_scope_id,
1073            attempt_id,
1074        )
1075        .await?
1076        .ok_or(SubscriptionEnrollmentServiceError::InvalidState(
1077            "reconciled subscription payment attempt was not found",
1078        ))?;
1079        transaction.commit().await?;
1080        match attempt.kind() {
1081            PaymentAttemptKind::SubscriptionInitial => {
1082                apply_reconciled_subscription_enrollment_gateway_outcome(
1083                    &self.pool,
1084                    self.coordinator.as_ref(),
1085                    billing_scope_id,
1086                    attempt_id,
1087                    outcome,
1088                )
1089                .await
1090            }
1091            PaymentAttemptKind::SubscriptionRecovery => {
1092                apply_reconciled_subscription_recovery_gateway_outcome(
1093                    &self.pool,
1094                    self.coordinator.as_ref(),
1095                    billing_scope_id,
1096                    attempt_id,
1097                    outcome,
1098                )
1099                .await
1100            }
1101            PaymentAttemptKind::SubscriptionRenewal => {
1102                apply_reconciled_subscription_renewal_gateway_outcome(
1103                    &self.pool,
1104                    self.coordinator.as_ref(),
1105                    billing_scope_id,
1106                    attempt_id,
1107                    outcome,
1108                )
1109                .await
1110            }
1111            PaymentAttemptKind::SubscriptionPaymentMethodUpdate => {
1112                apply_reconciled_subscription_payment_method_replacement_gateway_outcome(
1113                    &self.pool,
1114                    self.coordinator.as_ref(),
1115                    billing_scope_id,
1116                    attempt_id,
1117                    outcome,
1118                )
1119                .await
1120            }
1121            _ => Err(SubscriptionEnrollmentApplicationError::InvalidState(
1122                "attempt kind is not owned by the subscription billing service",
1123            )),
1124        }
1125        .map_err(Into::into)
1126    }
1127
1128    /// Applies an exact-query outcome to one host charge without resubmission.
1129    pub async fn apply_reconciled_host_charge_outcome(
1130        &self,
1131        billing_scope_id: BillingScopeId,
1132        attempt_id: PaymentAttemptId,
1133        outcome: &GatewayPaymentOutcome,
1134    ) -> Result<HostChargePaymentResult, SubscriptionEnrollmentServiceError> {
1135        let targets = self
1136            .host_charge_targets
1137            .as_deref()
1138            .ok_or(SubscriptionEnrollmentServiceError::HostChargeUnavailable)?;
1139        apply_reconciled_host_charge_gateway_outcome(
1140            &self.pool,
1141            self.coordinator.as_ref(),
1142            targets,
1143            billing_scope_id,
1144            attempt_id,
1145            outcome,
1146        )
1147        .await
1148        .map_err(Into::into)
1149    }
1150
1151    async fn preflight(
1152        &self,
1153        command: &EnrollSubscription,
1154    ) -> Result<SubscriptionEnrollmentPreflightOutcome, SubscriptionEnrollmentServiceError> {
1155        let mut transaction = self.pool.begin().await?;
1156        let outcome =
1157            preflight_subscription_enrollment_in_transaction(&mut transaction, command).await?;
1158        transaction.commit().await?;
1159        Ok(outcome)
1160    }
1161
1162    async fn preflight_host_charge(
1163        &self,
1164        targets: &dyn HostChargeTargetStore,
1165        command: &ChargeHostTarget,
1166    ) -> Result<HostChargePreflightOutcome, SubscriptionEnrollmentServiceError> {
1167        let mut transaction = self.pool.begin().await?;
1168        let outcome =
1169            preflight_host_charge_in_transaction(&mut transaction, targets, command).await?;
1170        transaction.commit().await?;
1171        Ok(outcome)
1172    }
1173
1174    async fn reserve_host_charge(
1175        &self,
1176        targets: &dyn HostChargeTargetStore,
1177        reservation: &HostChargeReservation,
1178    ) -> Result<HostChargeReservationOutcome, SubscriptionEnrollmentServiceError> {
1179        let mut transaction = self.pool.begin().await?;
1180        let outcome =
1181            reserve_host_charge_in_transaction(&mut transaction, targets, reservation).await?;
1182        transaction.commit().await?;
1183        Ok(outcome)
1184    }
1185
1186    async fn resolve_host_charge_readiness(
1187        &self,
1188        targets: &dyn HostChargeTargetStore,
1189        reservation: &HostChargeReservation,
1190        detail: GatewayDiagnostic,
1191        code: PaymentResolutionCode,
1192        resolution: HostChargeBeforeSubmissionResolution,
1193    ) -> Result<HostChargePaymentResult, SubscriptionEnrollmentServiceError> {
1194        resolve_host_charge_before_submission(
1195            &self.pool,
1196            targets,
1197            reservation,
1198            detail,
1199            code,
1200            resolution,
1201        )
1202        .await
1203        .map_err(Into::into)
1204    }
1205
1206    async fn resolve_host_charge_cooldown(
1207        &self,
1208        targets: &dyn HostChargeTargetStore,
1209        reservation: &HostChargeReservation,
1210        provider_key: &GatewayProviderKey,
1211        scope: GatewayMutationCooldownScope,
1212        resolution: HostChargeBeforeSubmissionResolution,
1213    ) -> Result<HostChargePaymentResult, SubscriptionEnrollmentServiceError> {
1214        let provider_name = provider_key.as_str().to_ascii_uppercase();
1215        let detail = match scope {
1216            GatewayMutationCooldownScope::Account => GatewayDiagnostic::new(&format!(
1217                "{provider_name} account mutation cooldown is active."
1218            )),
1219            GatewayMutationCooldownScope::Provider => GatewayDiagnostic::new(&format!(
1220                "{provider_name} system provider cooldown is active."
1221            )),
1222        };
1223        self.resolve_host_charge_cooldown_with_detail(
1224            targets,
1225            reservation,
1226            scope,
1227            detail,
1228            resolution,
1229        )
1230        .await
1231    }
1232
1233    async fn resolve_host_charge_cooldown_with_detail(
1234        &self,
1235        targets: &dyn HostChargeTargetStore,
1236        reservation: &HostChargeReservation,
1237        scope: GatewayMutationCooldownScope,
1238        detail: GatewayDiagnostic,
1239        resolution: HostChargeBeforeSubmissionResolution,
1240    ) -> Result<HostChargePaymentResult, SubscriptionEnrollmentServiceError> {
1241        let code = match scope {
1242            GatewayMutationCooldownScope::Account => {
1243                PaymentResolutionCode::GatewayAccountMutationCooldownBeforeSubmission
1244            }
1245            GatewayMutationCooldownScope::Provider => {
1246                PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission
1247            }
1248        };
1249        let payment = self
1250            .resolve_host_charge_readiness(targets, reservation, detail, code, resolution)
1251            .await?;
1252        if payment.attempt().state().resolution_code() == Some(code) {
1253            Err(SubscriptionEnrollmentServiceError::GatewayMutationCooldown { scope })
1254        } else {
1255            Ok(payment)
1256        }
1257    }
1258
1259    async fn preflight_recovery(
1260        &self,
1261        command: &RecoverSubscriptionPayment,
1262    ) -> Result<SubscriptionRecoveryPreflightOutcome, SubscriptionEnrollmentServiceError> {
1263        let mut transaction = self.pool.begin().await?;
1264        let outcome =
1265            preflight_subscription_recovery_in_transaction(&mut transaction, command).await?;
1266        transaction.commit().await?;
1267        Ok(outcome)
1268    }
1269
1270    async fn preflight_payment_method_replacement(
1271        &self,
1272        command: &ReplaceSubscriptionPaymentMethod,
1273    ) -> Result<
1274        SubscriptionPaymentMethodReplacementPreflightOutcome,
1275        SubscriptionEnrollmentServiceError,
1276    > {
1277        let mut transaction = self.pool.begin().await?;
1278        let outcome = preflight_subscription_payment_method_replacement_in_transaction(
1279            &mut transaction,
1280            command,
1281        )
1282        .await?;
1283        transaction.commit().await?;
1284        Ok(outcome)
1285    }
1286
1287    async fn reserve(
1288        &self,
1289        reservation: &SubscriptionEnrollmentReservation,
1290    ) -> Result<SubscriptionEnrollmentReservationOutcome, SubscriptionEnrollmentServiceError> {
1291        let mut transaction = self.pool.begin().await?;
1292        let outcome = reserve_subscription_enrollment_in_transaction(
1293            &mut transaction,
1294            self.offers.as_ref(),
1295            reservation,
1296        )
1297        .await?;
1298        transaction.commit().await?;
1299        Ok(outcome)
1300    }
1301
1302    async fn reserve_recovery(
1303        &self,
1304        command: &RecoverSubscriptionPayment,
1305        gateway: &syrup_rail::ResolvedGateway,
1306    ) -> Result<SubscriptionRecoveryReservationOutcome, SubscriptionEnrollmentServiceError> {
1307        let mut transaction = self.pool.begin().await?;
1308        let outcome =
1309            reserve_subscription_recovery_in_transaction(&mut transaction, command, gateway)
1310                .await?;
1311        transaction.commit().await?;
1312        Ok(outcome)
1313    }
1314
1315    async fn reserve_renewal(
1316        &self,
1317        command: ChargeRenewal,
1318        gateway: &syrup_rail::ResolvedGateway,
1319    ) -> Result<SubscriptionRenewalReservationOutcome, SubscriptionEnrollmentServiceError> {
1320        let mut transaction = self.pool.begin().await?;
1321        let outcome =
1322            reserve_subscription_renewal_in_transaction(&mut transaction, command, gateway).await?;
1323        transaction.commit().await?;
1324        Ok(outcome)
1325    }
1326
1327    async fn reserve_payment_method_replacement(
1328        &self,
1329        command: &ReplaceSubscriptionPaymentMethod,
1330        gateway: &syrup_rail::ResolvedGateway,
1331    ) -> Result<
1332        SubscriptionPaymentMethodReplacementReservationOutcome,
1333        SubscriptionEnrollmentServiceError,
1334    > {
1335        let mut transaction = self.pool.begin().await?;
1336        let outcome = reserve_subscription_payment_method_replacement_in_transaction(
1337            &mut transaction,
1338            command,
1339            gateway,
1340        )
1341        .await?;
1342        transaction.commit().await?;
1343        Ok(outcome)
1344    }
1345
1346    async fn payment_result(
1347        &self,
1348        attempt: PaymentAttempt,
1349    ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentServiceError> {
1350        let mut transaction = self.pool.begin().await?;
1351        let result = payment_result_for_attempt(&mut transaction, attempt).await?;
1352        transaction.commit().await?;
1353        Ok(result)
1354    }
1355
1356    async fn admit_subscriber_mutation(
1357        &self,
1358        billing_scope_id: BillingScopeId,
1359        subscriber_id: syrup_rail::SubscriberId,
1360        operation: EndUserMutationOperation,
1361    ) -> Result<(), SubscriptionEnrollmentServiceError> {
1362        let result = self
1363            .admission
1364            .admit(EndUserMutationCommand::new(
1365                billing_scope_id,
1366                subscriber_id,
1367                operation,
1368            ))
1369            .await;
1370        map_subscriber_mutation_admission(result)
1371    }
1372
1373    /// Resolves the canonical account after the caller's operation-specific
1374    /// preflight and admission phases. The three subscriber-initiated paths
1375    /// share the same cooldown and exact resolver-identity contract.
1376    async fn resolve_active_gateway(
1377        &self,
1378        billing_scope_id: BillingScopeId,
1379        gateway_configuration_id: syrup_rail::GatewayConfigurationId,
1380    ) -> Result<
1381        (GatewayAccountSnapshot, syrup_rail::ResolvedGateway),
1382        SubscriptionEnrollmentServiceError,
1383    > {
1384        let account = self
1385            .gateway_account(billing_scope_id, gateway_configuration_id)
1386            .await?;
1387        if let Some(scope) = self.active_cooldown(&account).await? {
1388            return Err(SubscriptionEnrollmentServiceError::GatewayMutationCooldown { scope });
1389        }
1390        let expected = ExpectedGatewayIdentity::for_account(
1391            billing_scope_id,
1392            gateway_configuration_id,
1393            &account,
1394        );
1395        let gateway = self
1396            .resolver
1397            .resolve(
1398                expected.billing_scope_id,
1399                expected.gateway_account_id,
1400                expected.gateway_configuration_id,
1401                expected.provider_key.clone(),
1402            )
1403            .await?;
1404        if !expected.matches(&gateway) {
1405            return Err(SubscriptionEnrollmentServiceError::ResolvedGatewayIdentityMismatch);
1406        }
1407        Ok((account, gateway))
1408    }
1409
1410    async fn gateway_account(
1411        &self,
1412        billing_scope_id: BillingScopeId,
1413        gateway_configuration_id: syrup_rail::GatewayConfigurationId,
1414    ) -> Result<GatewayAccountSnapshot, SubscriptionEnrollmentServiceError> {
1415        let row = sqlx::query_as::<_, (uuid::Uuid, String)>(
1416            r#"
1417            SELECT id, provider_key
1418            FROM billing_gateway_accounts
1419            WHERE billing_scope_id = $1 AND gateway_configuration_id = $2
1420            "#,
1421        )
1422        .bind(billing_scope_id.as_uuid())
1423        .bind(gateway_configuration_id.as_uuid())
1424        .fetch_optional(&self.pool)
1425        .await?
1426        .ok_or(SubscriptionEnrollmentServiceError::GatewayConfigurationChanged)?;
1427        let provider_key = GatewayProviderKey::new(&row.1)
1428            .map_err(|_| SubscriptionEnrollmentServiceError::InvalidState(INVALID_SERVICE_STATE))?;
1429        Ok(GatewayAccountSnapshot {
1430            account_id: GatewayAccountId::new(row.0),
1431            provider_key,
1432        })
1433    }
1434
1435    async fn active_cooldown(
1436        &self,
1437        account: &GatewayAccountSnapshot,
1438    ) -> Result<Option<GatewayMutationCooldownScope>, SubscriptionEnrollmentServiceError> {
1439        let row = sqlx::query_as::<_, (bool, bool)>(
1440            r#"
1441            SELECT
1442                COALESCE(accounts.mutation_rate_limited_until > clock_timestamp(), false),
1443                provider.rate_limited_until > clock_timestamp()
1444            FROM billing_gateway_accounts AS accounts
1445            INNER JOIN billing_gateway_provider_rate_limits AS provider
1446                ON provider.provider_key = accounts.provider_key
1447            WHERE accounts.id = $1 AND accounts.provider_key = $2
1448            "#,
1449        )
1450        .bind(account.account_id.as_uuid())
1451        .bind(account.provider_key.as_str())
1452        .fetch_optional(&self.pool)
1453        .await?
1454        .ok_or(SubscriptionEnrollmentServiceError::GatewayConfigurationChanged)?;
1455        Ok(if row.1 {
1456            Some(GatewayMutationCooldownScope::Provider)
1457        } else if row.0 {
1458            Some(GatewayMutationCooldownScope::Account)
1459        } else {
1460            None
1461        })
1462    }
1463
1464    async fn resolve_subscriber_readiness_failure(
1465        &self,
1466        reservation: SubscriberInitiatedReservation<'_>,
1467        failure: SubscriberReadinessFailure,
1468        boundary: OutcomeResolutionBoundary,
1469    ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentServiceError> {
1470        let code = failure.resolution_code();
1471        let cooldown = failure.cooldown();
1472        let cooldown_error_scope = failure.cooldown_error_scope();
1473        let detail = failure.into_detail();
1474        let evidence = ProcessorEvidence::new(
1475            None,
1476            None,
1477            None,
1478            None,
1479            Some(detail),
1480            Some(GatewayDiagnostic::new("failed")),
1481            GatewayPaymentDescriptor::default(),
1482        );
1483        let payment = reservation
1484            .resolve_non_approved(&self.pool, &evidence, code, cooldown, boundary)
1485            .await
1486            .map_err(SubscriptionEnrollmentServiceError::from)?;
1487        if let Some(scope) = cooldown_error_scope
1488            && payment.attempt().state().resolution_code() == Some(code)
1489        {
1490            return Err(SubscriptionEnrollmentServiceError::GatewayMutationCooldown { scope });
1491        }
1492        Ok(payment)
1493    }
1494
1495    async fn renewal_gateway_account(
1496        &self,
1497        command: ChargeRenewal,
1498    ) -> Result<Option<RenewalGatewayAccountSnapshot>, SubscriptionEnrollmentServiceError> {
1499        let mut transaction = self.pool.begin().await?;
1500        let row = sqlx::query_as::<_, (uuid::Uuid, uuid::Uuid, String)>(
1501            r#"
1502            SELECT accounts.id, accounts.gateway_configuration_id, accounts.provider_key
1503            FROM billing_subscriptions AS subscriptions
1504            JOIN billing_gateway_accounts AS accounts
1505                ON accounts.billing_scope_id = subscriptions.billing_scope_id
1506                AND accounts.id = subscriptions.gateway_account_id
1507            WHERE subscriptions.billing_scope_id = $1 AND subscriptions.id = $2
1508                AND subscriptions.status IN ('active', 'past_due')
1509                AND subscriptions.next_renewal_at = $3
1510                AND subscriptions.next_renewal_at <= clock_timestamp()
1511            "#,
1512        )
1513        .bind(command.billing_scope_id().as_uuid())
1514        .bind(command.subscription_id().as_uuid())
1515        .bind(command.period_start_at())
1516        .fetch_optional(&mut *transaction)
1517        .await?;
1518        let Some((account_id, configuration_id, provider_key)) = row else {
1519            transaction.commit().await?;
1520            return Ok(None);
1521        };
1522        let attempt_state = crate::renewal_attempt_state(
1523            &mut transaction,
1524            command.subscription_id(),
1525            *command.period_start_at(),
1526            None,
1527        )
1528        .await
1529        .map_err(|error| match error {
1530            crate::RenewalStoreError::Sql(error) => SubscriptionEnrollmentServiceError::Sql(error),
1531            crate::RenewalStoreError::MissingProviderCooldown => {
1532                SubscriptionEnrollmentServiceError::InvalidState(INVALID_SERVICE_STATE)
1533            }
1534        })?;
1535        let now = sqlx::query_scalar("SELECT clock_timestamp()")
1536            .fetch_one(&mut *transaction)
1537            .await?;
1538        let has_payment_method_update: bool = sqlx::query_scalar(
1539            r#"
1540            SELECT EXISTS (
1541                SELECT 1 FROM billing_payment_attempts
1542                WHERE subscription_id = $1
1543                    AND attempt_kind = 'subscription_payment_method_update'
1544                    AND status IN ('pending', 'unknown', 'review_required')
1545                    AND NOT (
1546                        status = 'pending' AND submitted_at IS NULL
1547                        AND created_at <= clock_timestamp() - interval '3 minutes'
1548                    )
1549            )
1550            "#,
1551        )
1552        .bind(command.subscription_id().as_uuid())
1553        .fetch_one(&mut *transaction)
1554        .await?;
1555        transaction.commit().await?;
1556        if attempt_state.blocks_automatic_retry(now) || has_payment_method_update {
1557            if has_payment_method_update {
1558                return Err(
1559                    SubscriptionEnrollmentServiceError::RenewalReservationRejected(
1560                        SubscriptionRenewalReservationRejection::PaymentMethodUpdateInProgress,
1561                    ),
1562                );
1563            }
1564            return Ok(None);
1565        }
1566        Some((account_id, configuration_id, provider_key))
1567            .map(|(account_id, configuration_id, provider_key)| {
1568                Ok(RenewalGatewayAccountSnapshot {
1569                    account_id: GatewayAccountId::new(account_id),
1570                    configuration_id: syrup_rail::GatewayConfigurationId::new(configuration_id),
1571                    provider_key: GatewayProviderKey::new(provider_key).map_err(|_| {
1572                        SubscriptionEnrollmentServiceError::InvalidState(INVALID_SERVICE_STATE)
1573                    })?,
1574                })
1575            })
1576            .transpose()
1577    }
1578
1579    async fn extend_provider_cooldown(
1580        &self,
1581        provider_key: &GatewayProviderKey,
1582    ) -> Result<(), SubscriptionEnrollmentServiceError> {
1583        let result = sqlx::query(
1584            r#"
1585            UPDATE billing_gateway_provider_rate_limits
1586            SET rate_limited_until = GREATEST(
1587                    rate_limited_until,
1588                    clock_timestamp() + make_interval(secs => $2)
1589                )
1590            WHERE provider_key = $1
1591            "#,
1592        )
1593        .bind(provider_key.as_str())
1594        .bind(syrup_rail::RENEWAL_PROVIDER_RATE_LIMIT_RETRY_AFTER_SECONDS)
1595        .execute(&self.pool)
1596        .await?;
1597        if result.rows_affected() != 1 {
1598            return Err(SubscriptionEnrollmentServiceError::InvalidState(
1599                INVALID_SERVICE_STATE,
1600            ));
1601        }
1602        Ok(())
1603    }
1604
1605    async fn resolve_renewal_cooldown(
1606        &self,
1607        reservation: &SubscriptionRenewalReservation,
1608        scope: GatewayMutationCooldownScope,
1609        boundary: OutcomeResolutionBoundary,
1610    ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentServiceError> {
1611        let (message, code) = match scope {
1612            GatewayMutationCooldownScope::Account => (
1613                "gateway account mutation cooldown is active",
1614                PaymentResolutionCode::GatewayAccountMutationCooldownBeforeSubmission,
1615            ),
1616            GatewayMutationCooldownScope::Provider => (
1617                "gateway provider cooldown is active",
1618                PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission,
1619            ),
1620        };
1621        self.resolve_renewal_readiness_failure(
1622            reservation,
1623            GatewayDiagnostic::new(message),
1624            code,
1625            None,
1626            boundary,
1627        )
1628        .await
1629    }
1630
1631    async fn renewal_readiness_open(
1632        &self,
1633        reservation: &SubscriptionRenewalReservation,
1634        gateway: &syrup_rail::ResolvedGateway,
1635        boundary: OutcomeResolutionBoundary,
1636    ) -> Result<bool, SubscriptionEnrollmentServiceError> {
1637        match gateway.account_mode().await {
1638            Ok(GatewayAccountMode::Live) => Ok(true),
1639            Ok(GatewayAccountMode::Test) => {
1640                self.resolve_renewal_readiness_failure(
1641                    reservation,
1642                    GatewayDiagnostic::new(LIVE_READINESS_FAILED_TEXT),
1643                    PaymentResolutionCode::GatewayLiveReadinessFailedBeforeSubmission,
1644                    None,
1645                    boundary,
1646                )
1647                .await?;
1648                Ok(false)
1649            }
1650            Err(GatewayError::RateLimited(detail)) => {
1651                self.resolve_renewal_readiness_failure(
1652                    reservation,
1653                    detail,
1654                    PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission,
1655                    Some(RateLimitCooldown::Provider),
1656                    boundary,
1657                )
1658                .await?;
1659                Ok(false)
1660            }
1661            Err(_) => {
1662                self.resolve_renewal_readiness_failure(
1663                    reservation,
1664                    GatewayDiagnostic::new(LIVE_READINESS_FAILED_TEXT),
1665                    PaymentResolutionCode::GatewayLiveReadinessFailedBeforeSubmission,
1666                    None,
1667                    boundary,
1668                )
1669                .await?;
1670                Ok(false)
1671            }
1672        }
1673    }
1674
1675    async fn resolve_renewal_readiness_failure(
1676        &self,
1677        reservation: &SubscriptionRenewalReservation,
1678        detail: GatewayDiagnostic,
1679        code: PaymentResolutionCode,
1680        cooldown: Option<RateLimitCooldown>,
1681        boundary: OutcomeResolutionBoundary,
1682    ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentServiceError> {
1683        let condition = (code != PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission)
1684            .then(|| GatewayDiagnostic::new("failed"));
1685        let evidence = ProcessorEvidence::new(
1686            None,
1687            None,
1688            None,
1689            None,
1690            Some(detail),
1691            condition,
1692            GatewayPaymentDescriptor::default(),
1693        );
1694        resolve_renewal_non_approved_outcome(
1695            self.coordinator.as_ref(),
1696            reservation,
1697            &evidence,
1698            PaymentAttemptStatus::Failed,
1699            Some(code),
1700            cooldown,
1701            boundary,
1702        )
1703        .await
1704        .map_err(SubscriptionEnrollmentServiceError::from)
1705    }
1706}
1707
1708/// The subscriber-initiated reservation families share readiness resolution,
1709/// while keeping their operation-specific durable application functions
1710/// explicit.
1711enum SubscriberInitiatedReservation<'a> {
1712    Initial(&'a SubscriptionEnrollmentReservation),
1713    Recovery(&'a SubscriptionRecoveryReservation),
1714    PaymentMethodReplacement(&'a SubscriptionPaymentMethodReplacement),
1715}
1716
1717impl SubscriberInitiatedReservation<'_> {
1718    async fn resolve_non_approved(
1719        self,
1720        pool: &PgPool,
1721        evidence: &ProcessorEvidence,
1722        code: PaymentResolutionCode,
1723        cooldown: Option<RateLimitCooldown>,
1724        boundary: OutcomeResolutionBoundary,
1725    ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentApplicationError> {
1726        match self {
1727            Self::Initial(reservation) => {
1728                resolve_non_approved_outcome(
1729                    pool,
1730                    reservation,
1731                    evidence,
1732                    PaymentAttemptStatus::Failed,
1733                    Some(code),
1734                    cooldown,
1735                    boundary,
1736                )
1737                .await
1738            }
1739            Self::Recovery(reservation) => {
1740                resolve_recovery_non_approved_outcome(
1741                    pool,
1742                    reservation,
1743                    evidence,
1744                    PaymentAttemptStatus::Failed,
1745                    Some(code),
1746                    cooldown,
1747                    boundary,
1748                )
1749                .await
1750            }
1751            Self::PaymentMethodReplacement(reservation) => {
1752                resolve_payment_method_replacement_non_approved_outcome(
1753                    pool,
1754                    reservation,
1755                    evidence,
1756                    PaymentAttemptStatus::Failed,
1757                    Some(code),
1758                    cooldown,
1759                    boundary,
1760                )
1761                .await
1762            }
1763        }
1764    }
1765}
1766
1767/// A closed representation of the only pre-submission readiness failures
1768/// shared by subscriber-initiated mutations.
1769enum SubscriberReadinessFailure {
1770    Cooldown(GatewayMutationCooldownScope),
1771    ProviderRateLimited(GatewayDiagnostic),
1772    LiveModeUnavailable,
1773}
1774
1775impl SubscriberReadinessFailure {
1776    fn into_detail(self) -> GatewayDiagnostic {
1777        match self {
1778            Self::Cooldown(GatewayMutationCooldownScope::Account) => {
1779                GatewayDiagnostic::new("gateway account mutation cooldown is active")
1780            }
1781            Self::Cooldown(GatewayMutationCooldownScope::Provider) => {
1782                GatewayDiagnostic::new("gateway provider cooldown is active")
1783            }
1784            Self::ProviderRateLimited(detail) => detail,
1785            Self::LiveModeUnavailable => GatewayDiagnostic::new(LIVE_READINESS_FAILED_TEXT),
1786        }
1787    }
1788
1789    const fn resolution_code(&self) -> PaymentResolutionCode {
1790        match self {
1791            Self::Cooldown(GatewayMutationCooldownScope::Account) => {
1792                PaymentResolutionCode::GatewayAccountMutationCooldownBeforeSubmission
1793            }
1794            Self::Cooldown(GatewayMutationCooldownScope::Provider)
1795            | Self::ProviderRateLimited(_) => {
1796                PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission
1797            }
1798            Self::LiveModeUnavailable => {
1799                PaymentResolutionCode::GatewayLiveReadinessFailedBeforeSubmission
1800            }
1801        }
1802    }
1803
1804    const fn cooldown(&self) -> Option<RateLimitCooldown> {
1805        match self {
1806            Self::ProviderRateLimited(_) => Some(RateLimitCooldown::Provider),
1807            Self::Cooldown(_) | Self::LiveModeUnavailable => None,
1808        }
1809    }
1810
1811    const fn cooldown_error_scope(&self) -> Option<GatewayMutationCooldownScope> {
1812        match self {
1813            Self::Cooldown(scope) => Some(*scope),
1814            Self::ProviderRateLimited(_) => Some(GatewayMutationCooldownScope::Provider),
1815            Self::LiveModeUnavailable => None,
1816        }
1817    }
1818}
1819
1820fn map_subscriber_mutation_admission(
1821    result: EndUserMutationAdmissionResult,
1822) -> Result<(), SubscriptionEnrollmentServiceError> {
1823    match result {
1824        EndUserMutationAdmissionResult::Allowed => Ok(()),
1825        EndUserMutationAdmissionResult::Denied { retry_after } => {
1826            Err(SubscriptionEnrollmentServiceError::AdmissionDenied {
1827                retry_after: retry_after.get(),
1828            })
1829        }
1830        EndUserMutationAdmissionResult::Timeout => {
1831            Err(SubscriptionEnrollmentServiceError::AdmissionTimeout)
1832        }
1833        EndUserMutationAdmissionResult::Unavailable => {
1834            Err(SubscriptionEnrollmentServiceError::AdmissionUnavailable)
1835        }
1836    }
1837}
1838
1839async fn subscriber_gateway_readiness_failure(
1840    gateway: &syrup_rail::ResolvedGateway,
1841) -> Option<SubscriberReadinessFailure> {
1842    match gateway.account_mode().await {
1843        Ok(GatewayAccountMode::Live) => None,
1844        Ok(GatewayAccountMode::Test) => Some(SubscriberReadinessFailure::LiveModeUnavailable),
1845        Err(GatewayError::RateLimited(detail)) => {
1846            Some(SubscriberReadinessFailure::ProviderRateLimited(detail))
1847        }
1848        Err(_) => Some(SubscriberReadinessFailure::LiveModeUnavailable),
1849    }
1850}
1851
1852fn preserve_concurrent_terminal_payment(
1853    payment: SubscriptionEnrollmentPaymentResult,
1854    error: GatewayNotSubmittedError,
1855) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentServiceError> {
1856    if payment.attempt().state().resolution_code()
1857        == Some(crate::enrollment_application::not_submitted_resolution_code(&error))
1858    {
1859        Err(SubscriptionEnrollmentServiceError::GatewayNotSubmitted(
1860            error,
1861        ))
1862    } else {
1863        Ok(payment)
1864    }
1865}
1866
1867fn is_retryable_renewal_admission_error(error: &SubscriptionEnrollmentApplicationError) -> bool {
1868    let sqlstate = match error {
1869        SubscriptionEnrollmentApplicationError::Sql(sqlx::Error::Database(error)) => error.code(),
1870        SubscriptionEnrollmentApplicationError::Attempt(PaymentAttemptStoreError::Sql(
1871            sqlx::Error::Database(error),
1872        )) => error.code(),
1873        _ => None,
1874    };
1875    matches!(
1876        sqlstate.as_deref(),
1877        Some("40001" | "40P01" | "55P03" | "57014")
1878    )
1879}
1880
1881struct GatewayAccountSnapshot {
1882    account_id: GatewayAccountId,
1883    provider_key: GatewayProviderKey,
1884}
1885
1886/// Canonical identity the resolver must return for subscriber-initiated
1887/// gateway mutations.
1888struct ExpectedGatewayIdentity<'a> {
1889    billing_scope_id: BillingScopeId,
1890    gateway_account_id: GatewayAccountId,
1891    gateway_configuration_id: syrup_rail::GatewayConfigurationId,
1892    provider_key: &'a GatewayProviderKey,
1893}
1894
1895impl<'a> ExpectedGatewayIdentity<'a> {
1896    const fn for_account(
1897        billing_scope_id: BillingScopeId,
1898        gateway_configuration_id: syrup_rail::GatewayConfigurationId,
1899        account: &'a GatewayAccountSnapshot,
1900    ) -> Self {
1901        Self {
1902            billing_scope_id,
1903            gateway_account_id: account.account_id,
1904            gateway_configuration_id,
1905            provider_key: &account.provider_key,
1906        }
1907    }
1908
1909    fn matches(&self, gateway: &syrup_rail::ResolvedGateway) -> bool {
1910        self.matches_components(
1911            gateway.billing_scope_id(),
1912            gateway.gateway_account_id(),
1913            gateway.gateway_configuration_id(),
1914            gateway.provider_key(),
1915        )
1916    }
1917
1918    fn matches_components(
1919        &self,
1920        billing_scope_id: BillingScopeId,
1921        gateway_account_id: GatewayAccountId,
1922        gateway_configuration_id: syrup_rail::GatewayConfigurationId,
1923        provider_key: &GatewayProviderKey,
1924    ) -> bool {
1925        billing_scope_id == self.billing_scope_id
1926            && gateway_account_id == self.gateway_account_id
1927            && gateway_configuration_id == self.gateway_configuration_id
1928            && provider_key == self.provider_key
1929    }
1930}
1931
1932struct RenewalGatewayAccountSnapshot {
1933    account_id: GatewayAccountId,
1934    configuration_id: syrup_rail::GatewayConfigurationId,
1935    provider_key: GatewayProviderKey,
1936}
1937
1938impl RenewalGatewayAccountSnapshot {
1939    fn as_gateway_snapshot(&self) -> GatewayAccountSnapshot {
1940        GatewayAccountSnapshot {
1941            account_id: self.account_id,
1942            provider_key: self.provider_key.clone(),
1943        }
1944    }
1945}
1946
1947const fn map_reservation_build_error(
1948    error: SubscriptionEnrollmentReservationBuildError,
1949) -> SubscriptionEnrollmentServiceError {
1950    match error {
1951        SubscriptionEnrollmentReservationBuildError::GatewayIdentityMismatch => {
1952            SubscriptionEnrollmentServiceError::ResolvedGatewayIdentityMismatch
1953        }
1954        SubscriptionEnrollmentReservationBuildError::AttemptKindMismatch
1955        | SubscriptionEnrollmentReservationBuildError::InvalidCharge => {
1956            SubscriptionEnrollmentServiceError::InvalidState(INVALID_SERVICE_STATE)
1957        }
1958    }
1959}
1960
1961#[cfg(test)]
1962mod tests {
1963    use super::*;
1964
1965    #[test]
1966    fn subscriber_admission_mapping_preserves_each_error_variant() {
1967        assert!(map_subscriber_mutation_admission(EndUserMutationAdmissionResult::Allowed).is_ok());
1968        let retry_after = std::time::Duration::from_secs(7);
1969        assert!(matches!(
1970            map_subscriber_mutation_admission(EndUserMutationAdmissionResult::Denied {
1971                retry_after: syrup_rail::EndUserMutationRetryAfter::new(retry_after)
1972                    .expect("positive retry-after"),
1973            }),
1974            Err(SubscriptionEnrollmentServiceError::AdmissionDenied {
1975                retry_after: actual
1976            }) if actual == retry_after
1977        ));
1978        assert!(matches!(
1979            map_subscriber_mutation_admission(EndUserMutationAdmissionResult::Timeout),
1980            Err(SubscriptionEnrollmentServiceError::AdmissionTimeout)
1981        ));
1982        assert!(matches!(
1983            map_subscriber_mutation_admission(EndUserMutationAdmissionResult::Unavailable),
1984            Err(SubscriptionEnrollmentServiceError::AdmissionUnavailable)
1985        ));
1986    }
1987
1988    #[test]
1989    fn subscriber_readiness_failure_preserves_codes_cooldowns_and_diagnostics() {
1990        for (scope, code, detail) in [
1991            (
1992                GatewayMutationCooldownScope::Account,
1993                PaymentResolutionCode::GatewayAccountMutationCooldownBeforeSubmission,
1994                "gateway account mutation cooldown is active",
1995            ),
1996            (
1997                GatewayMutationCooldownScope::Provider,
1998                PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission,
1999                "gateway provider cooldown is active",
2000            ),
2001        ] {
2002            let failure = SubscriberReadinessFailure::Cooldown(scope);
2003            assert_eq!(failure.resolution_code(), code);
2004            assert!(failure.cooldown().is_none());
2005            assert_eq!(failure.cooldown_error_scope(), Some(scope));
2006            assert_eq!(failure.into_detail().expose(), detail);
2007        }
2008
2009        let provider = SubscriberReadinessFailure::ProviderRateLimited(GatewayDiagnostic::new(
2010            "provider asked to retry later",
2011        ));
2012        assert_eq!(
2013            provider.resolution_code(),
2014            PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission
2015        );
2016        assert!(matches!(
2017            provider.cooldown(),
2018            Some(RateLimitCooldown::Provider)
2019        ));
2020        assert_eq!(
2021            provider.cooldown_error_scope(),
2022            Some(GatewayMutationCooldownScope::Provider)
2023        );
2024        assert_eq!(
2025            provider.into_detail().expose(),
2026            "provider asked to retry later"
2027        );
2028
2029        let readiness = SubscriberReadinessFailure::LiveModeUnavailable;
2030        assert_eq!(
2031            readiness.resolution_code(),
2032            PaymentResolutionCode::GatewayLiveReadinessFailedBeforeSubmission
2033        );
2034        assert!(readiness.cooldown().is_none());
2035        assert!(readiness.cooldown_error_scope().is_none());
2036        assert_eq!(readiness.into_detail().expose(), LIVE_READINESS_FAILED_TEXT);
2037    }
2038
2039    #[test]
2040    fn expected_gateway_identity_requires_every_resolved_component() {
2041        let provider_key = GatewayProviderKey::new("nmi").expect("valid provider key");
2042        let account = GatewayAccountSnapshot {
2043            account_id: GatewayAccountId::new(uuid::Uuid::from_u128(1)),
2044            provider_key: provider_key.clone(),
2045        };
2046        let billing_scope_id = BillingScopeId::new(uuid::Uuid::from_u128(2));
2047        let gateway_configuration_id =
2048            syrup_rail::GatewayConfigurationId::new(uuid::Uuid::from_u128(3));
2049        let expected = ExpectedGatewayIdentity::for_account(
2050            billing_scope_id,
2051            gateway_configuration_id,
2052            &account,
2053        );
2054
2055        assert!(expected.matches_components(
2056            billing_scope_id,
2057            account.account_id,
2058            gateway_configuration_id,
2059            &provider_key,
2060        ));
2061        assert!(!expected.matches_components(
2062            BillingScopeId::new(uuid::Uuid::from_u128(4)),
2063            account.account_id,
2064            gateway_configuration_id,
2065            &provider_key,
2066        ));
2067        assert!(!expected.matches_components(
2068            billing_scope_id,
2069            GatewayAccountId::new(uuid::Uuid::from_u128(5)),
2070            gateway_configuration_id,
2071            &provider_key,
2072        ));
2073        assert!(!expected.matches_components(
2074            billing_scope_id,
2075            account.account_id,
2076            syrup_rail::GatewayConfigurationId::new(uuid::Uuid::from_u128(6)),
2077            &provider_key,
2078        ));
2079        assert!(!expected.matches_components(
2080            billing_scope_id,
2081            account.account_id,
2082            gateway_configuration_id,
2083            &GatewayProviderKey::new("other_gateway").expect("valid provider key"),
2084        ));
2085    }
2086}