1#![warn(missing_docs)]
2
3use std::{fmt, sync::Arc, time::Duration};
4
5use sqlx::PgPool;
6use syrup_rail::{
7 BillingEventSubject, BillingScopeId, CancelSubscription, CancelSubscriptionOutcome,
8 ChargeHostTarget, ChargeRenewal, ClearSubscriptionDiscount, EndUserMutationAdmission,
9 EndUserMutationAdmissionResult, EndUserMutationCommand, EndUserMutationOperation,
10 EnrollSubscription, GatewayAccountId, GatewayAccountMode, GatewayDiagnostic, GatewayError,
11 GatewayNotSubmittedError, GatewayPaymentDescriptor, GatewayPaymentOutcome, GatewayProviderKey,
12 GatewayResolutionError, GatewayResolver, HostChargePaymentResult, HostChargeReservation,
13 HostChargeTargetRejection, PaymentAttempt, PaymentAttemptId, PaymentAttemptKind,
14 PaymentAttemptStatus, PaymentResolutionCode, ProcessorEvidence, RecoverSubscriptionPayment,
15 ReplaceSubscriptionPaymentMethod, SubscriptionDiscountClaim, SubscriptionDiscountClaimOutcome,
16 SubscriptionDiscountClearOutcome, SubscriptionEnrollmentPaymentResult,
17 SubscriptionEnrollmentPreflightOutcome, SubscriptionEnrollmentReservation,
18 SubscriptionEnrollmentReservationBuildError, SubscriptionEnrollmentReservationOutcome,
19 SubscriptionEnrollmentReservationRejection, SubscriptionEnrollmentSubmissionRejection,
20 SubscriptionPaymentMethodReplacement, SubscriptionPaymentMethodReplacementPreflightOutcome,
21 SubscriptionPaymentMethodReplacementRejection,
22 SubscriptionPaymentMethodReplacementReservationOutcome,
23 SubscriptionPaymentMethodReplacementSubmissionRejection, SubscriptionRecoveryPreflightOutcome,
24 SubscriptionRecoveryReservation, SubscriptionRecoveryReservationOutcome,
25 SubscriptionRecoveryReservationRejection, SubscriptionRecoverySubmissionRejection,
26 SubscriptionRenewalOutcome, SubscriptionRenewalReservation,
27 SubscriptionRenewalReservationOutcome, SubscriptionRenewalReservationRejection,
28};
29use thiserror::Error;
30
31use crate::enrollment_application::GatewayNotSubmittedPolicy;
32use crate::host_charge_application::{
33 HostChargeBeforeSubmissionResolution, resolve_host_charge_before_submission,
34};
35use crate::mode_verified_gateway::gateway_account_mode_mismatch_detail;
36use crate::{
37 BillingTransactionCoordinator, GatewayAccountModeVerificationError, HostChargeAdmissionOutcome,
38 HostChargeApplicationError, HostChargePreflightOutcome, HostChargeProviderResult,
39 HostChargeReservationOutcome, HostChargeStoreError, HostChargeTargetStore, ModeVerifiedGateway,
40 PaymentAttemptStoreError, SubscriptionEnrollmentAdmissionOutcome,
41 SubscriptionEnrollmentApplicationError, SubscriptionEnrollmentProviderResult,
42 SubscriptionOfferStore, SubscriptionPaymentMethodReplacementAdmissionOutcome,
43 SubscriptionPaymentMethodReplacementProviderResult, SubscriptionRecoveryAdmissionOutcome,
44 SubscriptionRecoveryProviderResult, SubscriptionRenewalAdmissionOutcome,
45 SubscriptionRenewalProviderResult, admit_host_charge_submission,
46 admit_subscription_enrollment_submission, admit_subscription_payment_method_replacement,
47 admit_subscription_recovery_submission, admit_subscription_renewal_submission,
48 apply_reconciled_host_charge_gateway_outcome,
49 apply_reconciled_subscription_enrollment_gateway_outcome,
50 apply_reconciled_subscription_payment_method_replacement_gateway_outcome,
51 apply_reconciled_subscription_recovery_gateway_outcome,
52 apply_reconciled_subscription_renewal_gateway_outcome,
53 attempts::{
54 AttemptReplayDisposition, AttemptResolutionStatus, LocalAttemptPolicy,
55 attempt_replay_disposition,
56 },
57 enrollment_application::{
58 OutcomeApplication, OutcomeResolutionBoundary, OutcomeResolutionCommand, RateLimitCooldown,
59 RateLimitCooldownPersistence, payment_result_for_attempt,
60 persist_bound_provider_rate_limit_cooldown, resolve_non_approved_outcome,
61 resolve_payment_method_replacement_non_approved_outcome,
62 resolve_recovery_non_approved_outcome, resolve_renewal_non_approved_outcome,
63 set_application_timeouts,
64 },
65 preflight_host_charge_in_transaction, preflight_subscription_enrollment_in_transaction,
66 preflight_subscription_payment_method_replacement_in_transaction,
67 preflight_subscription_recovery_in_transaction, reserve_host_charge_in_transaction,
68 reserve_subscription_enrollment_in_transaction,
69 reserve_subscription_payment_method_replacement_in_transaction,
70 reserve_subscription_recovery_in_transaction, reserve_subscription_renewal_in_transaction,
71 submit_admitted_host_charge, submit_admitted_subscription_enrollment,
72 submit_admitted_subscription_payment_method_replacement, submit_admitted_subscription_recovery,
73 submit_admitted_subscription_renewal, verify_gateway_account_mode,
74};
75
76mod enrollment;
77mod error_disposition;
78mod host_charge;
79mod payment_method_replacement;
80mod reconciliation;
81mod recovery;
82mod renewal;
83mod subscriber;
84mod subscriber_mutation;
85
86const INVALID_SERVICE_STATE: &str = "canonical subscription billing service state is invalid";
87
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub enum GatewayMutationCooldownScope {
91 Account,
93 Provider,
95}
96
97impl GatewayMutationCooldownScope {
98 const fn from_rate_limit_cooldown(cooldown: RateLimitCooldown) -> Self {
99 match cooldown {
100 RateLimitCooldown::Account => Self::Account,
101 RateLimitCooldown::Provider => Self::Provider,
102 }
103 }
104}
105
106#[non_exhaustive]
114#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
115pub enum SubscriptionBillingServiceErrorDisposition {
116 Conflict,
123 Rejected,
126 TemporarilyUnavailable,
129 Misconfigured,
132 Internal,
135}
136
137#[non_exhaustive]
143#[derive(Error)]
144pub enum SubscriptionBillingServiceError {
145 #[error("subscription billing storage failed")]
147 Sql(#[from] sqlx::Error),
148 #[error("subscription billing storage is temporarily unavailable")]
152 StorageTemporarilyUnavailable(#[source] sqlx::Error),
153 #[error("payment attempt storage failed")]
155 Attempt(#[from] PaymentAttemptStoreError),
156 #[error("subscription payment application failed")]
158 Application(#[from] SubscriptionEnrollmentApplicationError),
159 #[error("host charge application failed")]
161 HostChargeApplication(#[from] HostChargeApplicationError),
162 #[error("host charge storage failed")]
164 HostChargeStore(#[from] HostChargeStoreError),
165 #[error("subscription cancellation failed")]
167 Cancellation(#[source] crate::SubscriptionCancellationError),
168 #[error("subscription discount operation failed")]
170 Discount(#[source] crate::SubscriptionDiscountOperationError),
171 #[error("host billing transaction failed")]
173 BillingTransaction(#[from] crate::BillingTransactionError),
174 #[error("host billing event append failed")]
176 BillingEvent(#[from] crate::BillingEventWriteError),
177 #[error("host charge capability is not configured")]
179 HostChargeUnavailable,
180 #[error("the idempotency key belongs to a different payment request")]
182 IdempotencyConflict,
183 #[error("end-user mutation admission was denied")]
185 AdmissionDenied {
186 retry_after: Duration,
188 },
189 #[error("end-user mutation admission timed out")]
191 AdmissionTimeout,
192 #[error("end-user mutation admission is unavailable")]
194 AdmissionUnavailable,
195 #[error("gateway account or configuration changed")]
197 GatewayConfigurationChanged,
198 #[error("gateway resolution failed")]
200 GatewayResolution(#[from] GatewayResolutionError),
201 #[error("gateway resolver returned a different canonical identity")]
203 ResolvedGatewayIdentityMismatch,
204 #[error("gateway mutation cooldown is active")]
206 GatewayMutationCooldown {
207 scope: GatewayMutationCooldownScope,
209 },
210 #[error("subscription enrollment reservation was rejected")]
212 ReservationRejected(SubscriptionEnrollmentReservationRejection),
213 #[error("subscription enrollment submission was rejected")]
215 SubmissionRejected(SubscriptionEnrollmentSubmissionRejection),
216 #[error("host charge reservation was rejected")]
218 HostChargeReservationRejected(HostChargeTargetRejection),
219 #[error("host charge submission was rejected")]
221 HostChargeSubmissionRejected(HostChargeTargetRejection),
222 #[error("subscription recovery reservation was rejected")]
224 RecoveryReservationRejected(SubscriptionRecoveryReservationRejection),
225 #[error("subscription recovery submission was rejected")]
227 RecoverySubmissionRejected(SubscriptionRecoverySubmissionRejection),
228 #[error("subscription renewal reservation was rejected")]
230 RenewalReservationRejected(SubscriptionRenewalReservationRejection),
231 #[error("subscription payment method replacement reservation was rejected")]
233 PaymentMethodReplacementReservationRejected(SubscriptionPaymentMethodReplacementRejection),
234 #[error("subscription payment method replacement submission was rejected")]
236 PaymentMethodReplacementSubmissionRejected(
237 SubscriptionPaymentMethodReplacementSubmissionRejection,
238 ),
239 #[error("gateway mutation was not submitted")]
241 GatewayNotSubmitted(#[source] GatewayNotSubmittedError),
242 #[error("gateway readiness check failed")]
251 GatewayReadiness(#[source] GatewayError),
252 #[error("{0}")]
254 InvalidState(&'static str),
255}
256
257impl fmt::Debug for SubscriptionBillingServiceError {
258 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
259 match self {
260 Self::Sql(_) => formatter.write_str("SubscriptionBillingServiceError::Sql"),
261 Self::StorageTemporarilyUnavailable(_) => formatter
262 .write_str("SubscriptionBillingServiceError::StorageTemporarilyUnavailable"),
263 Self::Attempt(_) => formatter.write_str("SubscriptionBillingServiceError::Attempt"),
264 Self::Application(_) => {
265 formatter.write_str("SubscriptionBillingServiceError::Application")
266 }
267 Self::HostChargeApplication(_) => {
268 formatter.write_str("SubscriptionBillingServiceError::HostChargeApplication")
269 }
270 Self::HostChargeStore(_) => {
271 formatter.write_str("SubscriptionBillingServiceError::HostChargeStore")
272 }
273 Self::Cancellation(_) => {
274 formatter.write_str("SubscriptionBillingServiceError::Cancellation")
275 }
276 Self::Discount(_) => formatter.write_str("SubscriptionBillingServiceError::Discount"),
277 Self::BillingTransaction(_) => {
278 formatter.write_str("SubscriptionBillingServiceError::BillingTransaction")
279 }
280 Self::BillingEvent(_) => {
281 formatter.write_str("SubscriptionBillingServiceError::BillingEvent")
282 }
283 Self::HostChargeUnavailable => {
284 formatter.write_str("SubscriptionBillingServiceError::HostChargeUnavailable")
285 }
286 Self::IdempotencyConflict => {
287 formatter.write_str("SubscriptionBillingServiceError::IdempotencyConflict")
288 }
289 Self::AdmissionDenied { retry_after } => formatter
290 .debug_struct("SubscriptionBillingServiceError::AdmissionDenied")
291 .field("retry_after", retry_after)
292 .finish(),
293 Self::AdmissionTimeout => {
294 formatter.write_str("SubscriptionBillingServiceError::AdmissionTimeout")
295 }
296 Self::AdmissionUnavailable => {
297 formatter.write_str("SubscriptionBillingServiceError::AdmissionUnavailable")
298 }
299 Self::GatewayConfigurationChanged => {
300 formatter.write_str("SubscriptionBillingServiceError::GatewayConfigurationChanged")
301 }
302 Self::GatewayResolution(error) => formatter
303 .debug_tuple("SubscriptionBillingServiceError::GatewayResolution")
304 .field(error)
305 .finish(),
306 Self::ResolvedGatewayIdentityMismatch => formatter
307 .write_str("SubscriptionBillingServiceError::ResolvedGatewayIdentityMismatch"),
308 Self::GatewayMutationCooldown { scope } => formatter
309 .debug_struct("SubscriptionBillingServiceError::GatewayMutationCooldown")
310 .field("scope", scope)
311 .finish(),
312 Self::ReservationRejected(reason) => formatter
313 .debug_tuple("SubscriptionBillingServiceError::ReservationRejected")
314 .field(reason)
315 .finish(),
316 Self::SubmissionRejected(reason) => formatter
317 .debug_tuple("SubscriptionBillingServiceError::SubmissionRejected")
318 .field(reason)
319 .finish(),
320 Self::HostChargeReservationRejected(reason) => formatter
321 .debug_tuple("SubscriptionBillingServiceError::HostChargeReservationRejected")
322 .field(reason)
323 .finish(),
324 Self::HostChargeSubmissionRejected(reason) => formatter
325 .debug_tuple("SubscriptionBillingServiceError::HostChargeSubmissionRejected")
326 .field(reason)
327 .finish(),
328 Self::RecoveryReservationRejected(reason) => formatter
329 .debug_tuple("SubscriptionBillingServiceError::RecoveryReservationRejected")
330 .field(reason)
331 .finish(),
332 Self::RecoverySubmissionRejected(reason) => formatter
333 .debug_tuple("SubscriptionBillingServiceError::RecoverySubmissionRejected")
334 .field(reason)
335 .finish(),
336 Self::RenewalReservationRejected(reason) => formatter
337 .debug_tuple("SubscriptionBillingServiceError::RenewalReservationRejected")
338 .field(reason)
339 .finish(),
340 Self::PaymentMethodReplacementReservationRejected(reason) => formatter
341 .debug_tuple(
342 "SubscriptionBillingServiceError::PaymentMethodReplacementReservationRejected",
343 )
344 .field(reason)
345 .finish(),
346 Self::PaymentMethodReplacementSubmissionRejected(reason) => formatter
347 .debug_tuple(
348 "SubscriptionBillingServiceError::PaymentMethodReplacementSubmissionRejected",
349 )
350 .field(reason)
351 .finish(),
352 Self::GatewayNotSubmitted(error) => formatter
353 .debug_tuple("SubscriptionBillingServiceError::GatewayNotSubmitted")
354 .field(error)
355 .finish(),
356 Self::GatewayReadiness(error) => formatter
357 .debug_tuple("SubscriptionBillingServiceError::GatewayReadiness")
358 .field(error)
359 .finish(),
360 Self::InvalidState(detail) => formatter
361 .debug_tuple("SubscriptionBillingServiceError::InvalidState")
362 .field(detail)
363 .finish(),
364 }
365 }
366}
367
368impl From<crate::SubscriptionCancellationError> for SubscriptionBillingServiceError {
369 fn from(error: crate::SubscriptionCancellationError) -> Self {
370 match error {
371 crate::SubscriptionCancellationError::Sql(source)
372 if is_retryable_provider_free_transaction_error(&source) =>
373 {
374 Self::StorageTemporarilyUnavailable(source)
375 }
376 error => Self::Cancellation(error),
377 }
378 }
379}
380
381impl From<crate::SubscriptionDiscountOperationError> for SubscriptionBillingServiceError {
382 fn from(error: crate::SubscriptionDiscountOperationError) -> Self {
383 match error {
384 crate::SubscriptionDiscountOperationError::Sql(source)
385 if is_retryable_provider_free_transaction_error(&source) =>
386 {
387 Self::StorageTemporarilyUnavailable(source)
388 }
389 error => Self::Discount(error),
390 }
391 }
392}
393
394fn provider_free_transaction_error(error: sqlx::Error) -> SubscriptionBillingServiceError {
395 if is_retryable_provider_free_transaction_error(&error) {
396 SubscriptionBillingServiceError::StorageTemporarilyUnavailable(error)
397 } else {
398 SubscriptionBillingServiceError::Sql(error)
399 }
400}
401
402fn is_retryable_provider_free_transaction_error(error: &sqlx::Error) -> bool {
403 if matches!(error, sqlx::Error::PoolTimedOut) {
404 return true;
405 }
406 let sqlx::Error::Database(error) = error else {
407 return false;
408 };
409 error
410 .code()
411 .is_some_and(|code| is_retryable_provider_free_transaction_sqlstate(code.as_ref()))
412}
413
414fn is_retryable_provider_free_transaction_sqlstate(code: &str) -> bool {
415 matches!(code, "40001" | "40P01" | "55P03" | "57014")
416}
417
418#[derive(Clone)]
424pub struct SubscriptionBillingService {
425 pool: PgPool,
426 offers: Arc<dyn SubscriptionOfferStore>,
427 resolver: Arc<dyn GatewayResolver>,
428 admission: Arc<dyn EndUserMutationAdmission>,
429 coordinator: Arc<dyn BillingTransactionCoordinator>,
430 required_gateway_account_mode: GatewayAccountMode,
431 host_charge_targets: Option<Arc<dyn HostChargeTargetStore>>,
432}
433
434impl SubscriptionBillingService {
435 pub fn new(
437 pool: PgPool,
438 offers: Arc<dyn SubscriptionOfferStore>,
439 resolver: Arc<dyn GatewayResolver>,
440 admission: Arc<dyn EndUserMutationAdmission>,
441 coordinator: Arc<dyn BillingTransactionCoordinator>,
442 ) -> Self {
443 Self {
444 pool,
445 offers,
446 resolver,
447 admission,
448 coordinator,
449 required_gateway_account_mode: GatewayAccountMode::Live,
450 host_charge_targets: None,
451 }
452 }
453
454 pub fn with_required_gateway_account_mode(mut self, mode: GatewayAccountMode) -> Self {
468 self.required_gateway_account_mode = mode;
469 self
470 }
471
472 pub fn with_host_charge_targets(mut self, targets: Arc<dyn HostChargeTargetStore>) -> Self {
474 self.host_charge_targets = Some(targets);
475 self
476 }
477}
478
479enum SubscriberInitiatedReservation<'a> {
483 Initial(&'a SubscriptionEnrollmentReservation),
484 Recovery(&'a SubscriptionRecoveryReservation),
485 PaymentMethodReplacement(&'a SubscriptionPaymentMethodReplacement),
486}
487
488impl SubscriberInitiatedReservation<'_> {
489 async fn resolve_non_approved(
490 self,
491 pool: &PgPool,
492 evidence: &ProcessorEvidence,
493 code: PaymentResolutionCode,
494 cooldown: Option<RateLimitCooldown>,
495 boundary: OutcomeResolutionBoundary,
496 ) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentApplicationError> {
497 match self {
498 Self::Initial(reservation) => {
499 resolve_non_approved_outcome(
500 pool,
501 reservation,
502 evidence,
503 AttemptResolutionStatus::Failed,
504 Some(code),
505 cooldown,
506 boundary,
507 )
508 .await
509 }
510 Self::Recovery(reservation) => {
511 resolve_recovery_non_approved_outcome(
512 pool,
513 reservation,
514 evidence,
515 AttemptResolutionStatus::Failed,
516 Some(code),
517 cooldown,
518 boundary,
519 )
520 .await
521 }
522 Self::PaymentMethodReplacement(reservation) => {
523 resolve_payment_method_replacement_non_approved_outcome(
524 pool,
525 reservation,
526 evidence,
527 AttemptResolutionStatus::Failed,
528 Some(code),
529 cooldown,
530 boundary,
531 )
532 .await
533 }
534 }
535 }
536}
537
538enum SubscriberReadinessFailure {
541 Cooldown(GatewayMutationCooldownScope),
542 Gateway(GatewayError),
543 AccountMode(GatewayAccountMode),
544}
545
546#[derive(Clone, Copy, Debug, Eq, PartialEq)]
547struct SubscriberReadinessPolicy {
548 resolution_code: PaymentResolutionCode,
549 cooldown: Option<RateLimitCooldown>,
550 cooldown_error_scope: Option<GatewayMutationCooldownScope>,
551}
552
553impl SubscriberReadinessPolicy {
554 const fn resolution_code(self) -> PaymentResolutionCode {
555 self.resolution_code
556 }
557
558 const fn cooldown(self) -> Option<RateLimitCooldown> {
559 self.cooldown
560 }
561
562 const fn cooldown_error_scope(self) -> Option<GatewayMutationCooldownScope> {
563 self.cooldown_error_scope
564 }
565}
566
567impl SubscriberReadinessFailure {
568 fn gateway_error(&self) -> Option<GatewayError> {
569 let Self::Gateway(error) = self else {
570 return None;
571 };
572 Some(clone_gateway_error(error))
573 }
574
575 fn into_detail(self) -> GatewayDiagnostic {
576 match self {
577 Self::Cooldown(GatewayMutationCooldownScope::Account) => {
578 GatewayDiagnostic::new("gateway account mutation cooldown is active")
579 }
580 Self::Cooldown(GatewayMutationCooldownScope::Provider) => {
581 GatewayDiagnostic::new("gateway provider cooldown is active")
582 }
583 Self::Gateway(error) => error.detail().clone(),
584 Self::AccountMode(_) => gateway_account_mode_mismatch_detail(),
585 }
586 }
587
588 const fn policy(&self) -> SubscriberReadinessPolicy {
589 match self {
590 Self::Cooldown(GatewayMutationCooldownScope::Account) => SubscriberReadinessPolicy {
591 resolution_code:
592 PaymentResolutionCode::GatewayAccountMutationCooldownBeforeSubmission,
593 cooldown: None,
594 cooldown_error_scope: Some(GatewayMutationCooldownScope::Account),
595 },
596 Self::Cooldown(GatewayMutationCooldownScope::Provider) => SubscriberReadinessPolicy {
597 resolution_code: PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission,
598 cooldown: None,
599 cooldown_error_scope: Some(GatewayMutationCooldownScope::Provider),
600 },
601 Self::Gateway(error) => {
602 let policy = GatewayNotSubmittedPolicy::for_readiness_error(error);
603 let cooldown = policy.cooldown();
604 SubscriberReadinessPolicy {
605 resolution_code: policy.resolution_code(),
606 cooldown,
607 cooldown_error_scope: match cooldown {
608 Some(cooldown) => Some(
609 GatewayMutationCooldownScope::from_rate_limit_cooldown(cooldown),
610 ),
611 None => None,
612 },
613 }
614 }
615 Self::AccountMode(required) => {
616 let policy = GatewayNotSubmittedPolicy::for_account_mode_mismatch(*required);
617 SubscriberReadinessPolicy {
618 resolution_code: policy.resolution_code(),
619 cooldown: policy.cooldown(),
620 cooldown_error_scope: None,
621 }
622 }
623 }
624 }
625}
626
627fn clone_gateway_error(error: &GatewayError) -> GatewayError {
628 match error {
629 GatewayError::RequestRejected(detail) => GatewayError::RequestRejected(detail.clone()),
630 GatewayError::Malformed(detail) => GatewayError::Malformed(detail.clone()),
631 GatewayError::Configuration(detail) => GatewayError::Configuration(detail.clone()),
632 GatewayError::Unavailable(detail) => GatewayError::Unavailable(detail.clone()),
633 GatewayError::RateLimited(detail) => GatewayError::RateLimited(detail.clone()),
634 }
635}
636
637fn map_subscriber_mutation_admission(
638 result: EndUserMutationAdmissionResult,
639) -> Result<(), SubscriptionBillingServiceError> {
640 match result {
641 EndUserMutationAdmissionResult::Allowed => Ok(()),
642 EndUserMutationAdmissionResult::Denied { retry_after } => {
643 Err(SubscriptionBillingServiceError::AdmissionDenied {
644 retry_after: retry_after.get(),
645 })
646 }
647 EndUserMutationAdmissionResult::Timeout => {
648 Err(SubscriptionBillingServiceError::AdmissionTimeout)
649 }
650 EndUserMutationAdmissionResult::Unavailable => {
651 Err(SubscriptionBillingServiceError::AdmissionUnavailable)
652 }
653 }
654}
655
656async fn subscriber_gateway_readiness(
657 gateway: &syrup_rail::ResolvedGateway,
658 required_mode: GatewayAccountMode,
659) -> Result<ModeVerifiedGateway<'_>, SubscriberReadinessFailure> {
660 match verify_gateway_account_mode(gateway, required_mode).await {
661 Ok(verified) => Ok(verified),
662 Err(GatewayAccountModeVerificationError::AccountModeMismatch { required, .. }) => {
663 Err(SubscriberReadinessFailure::AccountMode(required))
664 }
665 Err(GatewayAccountModeVerificationError::Gateway(error)) => {
666 Err(SubscriberReadinessFailure::Gateway(error))
667 }
668 }
669}
670
671fn attempt_is_prepared(attempt: &PaymentAttempt) -> bool {
672 attempt_replay_disposition(attempt) == AttemptReplayDisposition::ResumePrepared
673}
674
675fn resolved_gateway_matches_attempt(
676 gateway: &syrup_rail::ResolvedGateway,
677 attempt: &PaymentAttempt,
678) -> bool {
679 let identity = attempt.identity();
680 gateway.billing_scope_id() == identity.billing_scope_id()
681 && gateway.gateway_account_id() == identity.gateway_account_id()
682 && gateway.gateway_configuration_id() == identity.gateway_configuration_id()
683}
684
685fn is_retryable_renewal_admission_error(error: &SubscriptionEnrollmentApplicationError) -> bool {
686 let sqlstate = match error {
687 SubscriptionEnrollmentApplicationError::Sql(sqlx::Error::Database(error)) => error.code(),
688 SubscriptionEnrollmentApplicationError::Attempt(PaymentAttemptStoreError::Sql(
689 sqlx::Error::Database(error),
690 )) => error.code(),
691 _ => None,
692 };
693 matches!(
694 sqlstate.as_deref(),
695 Some("40001" | "40P01" | "55P03" | "57014")
696 )
697}
698
699struct GatewayAccountSnapshot {
700 account_id: GatewayAccountId,
701 provider_key: GatewayProviderKey,
702}
703
704struct ExpectedGatewayIdentity<'a> {
707 billing_scope_id: BillingScopeId,
708 gateway_account_id: GatewayAccountId,
709 gateway_configuration_id: syrup_rail::GatewayConfigurationId,
710 provider_key: &'a GatewayProviderKey,
711}
712
713impl<'a> ExpectedGatewayIdentity<'a> {
714 const fn for_account(
715 billing_scope_id: BillingScopeId,
716 gateway_configuration_id: syrup_rail::GatewayConfigurationId,
717 account: &'a GatewayAccountSnapshot,
718 ) -> Self {
719 Self {
720 billing_scope_id,
721 gateway_account_id: account.account_id,
722 gateway_configuration_id,
723 provider_key: &account.provider_key,
724 }
725 }
726
727 fn matches(&self, gateway: &syrup_rail::ResolvedGateway) -> bool {
728 self.matches_components(
729 gateway.billing_scope_id(),
730 gateway.gateway_account_id(),
731 gateway.gateway_configuration_id(),
732 gateway.provider_key(),
733 )
734 }
735
736 fn matches_components(
737 &self,
738 billing_scope_id: BillingScopeId,
739 gateway_account_id: GatewayAccountId,
740 gateway_configuration_id: syrup_rail::GatewayConfigurationId,
741 provider_key: &GatewayProviderKey,
742 ) -> bool {
743 billing_scope_id == self.billing_scope_id
744 && gateway_account_id == self.gateway_account_id
745 && gateway_configuration_id == self.gateway_configuration_id
746 && provider_key == self.provider_key
747 }
748}
749
750struct RenewalGatewayAccountSnapshot {
751 account_id: GatewayAccountId,
752 configuration_id: syrup_rail::GatewayConfigurationId,
753 provider_key: GatewayProviderKey,
754}
755
756impl RenewalGatewayAccountSnapshot {
757 fn as_gateway_snapshot(&self) -> GatewayAccountSnapshot {
758 GatewayAccountSnapshot {
759 account_id: self.account_id,
760 provider_key: self.provider_key.clone(),
761 }
762 }
763}
764
765const fn map_reservation_build_error(
766 error: SubscriptionEnrollmentReservationBuildError,
767) -> SubscriptionBillingServiceError {
768 match error {
769 SubscriptionEnrollmentReservationBuildError::GatewayIdentityMismatch => {
770 SubscriptionBillingServiceError::ResolvedGatewayIdentityMismatch
771 }
772 SubscriptionEnrollmentReservationBuildError::AttemptKindMismatch
773 | SubscriptionEnrollmentReservationBuildError::InvalidCharge
774 | SubscriptionEnrollmentReservationBuildError::InvalidTerms => {
775 SubscriptionBillingServiceError::InvalidState(INVALID_SERVICE_STATE)
776 }
777 }
778}
779
780#[cfg(test)]
781mod tests;