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 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 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 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 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 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 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 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 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
1708enum 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
1767enum 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
1886struct 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}