1use std::{fmt, time::Duration};
2
3use chrono::{DateTime, Utc};
4use sqlx::{PgConnection, PgPool, Row};
5use syrup_rail::{
6 BillingEvent, BillingScopeId, GatewayAccountMode, GatewayDiagnostic, GatewayError,
7 GatewayNotSubmittedError, GatewayOrderId, GatewayProviderKey, PaymentAttempt,
8 PaymentAttemptIdentity, PaymentAttemptKind, PaymentAttemptRequest, PaymentAttemptStatus,
9 PaymentMethodId, PaymentResolutionCode, PlanKey, ProcessorChargeProgression, ProcessorEvidence,
10 SubscriberId, Subscription, SubscriptionEnrollmentPaymentResult,
11 SubscriptionEnrollmentPaymentResultBuildError, SubscriptionEnrollmentReservation,
12 SubscriptionId, SubscriptionPaymentMethodReplacement, SubscriptionRecoveryReservation,
13 SubscriptionRenewalReservation,
14};
15use thiserror::Error;
16use uuid::Uuid;
17
18use crate::{
19 BillingTransaction, BillingTransactionError,
20 attempts::{
21 AttemptApproval, AttemptResolutionStatus, AttemptTransition, PaymentAttemptStoreError,
22 find_payment_attempt_by_id_on_connection, lock_payment_attempt_by_id_on_connection,
23 persist_attempt_transition,
24 },
25 processor_charges::{
26 LockFreeApprovedEvidenceOutcome, LockFreeApprovedEvidenceTerms, observe_processor_charge,
27 },
28 renewal_failure::RenewalFailureStoreError,
29 subscription_persistence::{
30 SubscriptionPersistenceCodecError, subscription_from_row as decode_subscription_row,
31 },
32};
33
34mod initial;
35mod payment_method_replacement;
36mod recovery;
37mod renewal;
38
39pub(crate) use initial::resolve_non_approved_outcome;
40pub use initial::{
41 AdmittedSubscriptionEnrollment, SubscriptionEnrollmentAdmissionOutcome,
42 SubscriptionEnrollmentProviderResult, admit_subscription_enrollment_submission,
43 apply_reconciled_subscription_enrollment_gateway_outcome,
44 apply_subscription_enrollment_gateway_outcome, submit_admitted_subscription_enrollment,
45};
46pub(crate) use payment_method_replacement::resolve_payment_method_replacement_non_approved_outcome;
47pub use payment_method_replacement::{
48 AdmittedSubscriptionPaymentMethodReplacement,
49 SubscriptionPaymentMethodReplacementAdmissionOutcome,
50 SubscriptionPaymentMethodReplacementProviderResult,
51 admit_subscription_payment_method_replacement,
52 apply_reconciled_subscription_payment_method_replacement_gateway_outcome,
53 apply_subscription_payment_method_replacement_gateway_outcome,
54 submit_admitted_subscription_payment_method_replacement,
55};
56pub(crate) use recovery::resolve_recovery_non_approved_outcome;
57pub use recovery::{
58 AdmittedSubscriptionRecovery, SubscriptionRecoveryAdmissionOutcome,
59 SubscriptionRecoveryProviderResult, admit_subscription_recovery_submission,
60 apply_reconciled_subscription_recovery_gateway_outcome,
61 apply_subscription_recovery_gateway_outcome, submit_admitted_subscription_recovery,
62};
63pub(crate) use renewal::resolve_renewal_non_approved_outcome;
64pub use renewal::{
65 AdmittedSubscriptionRenewal, SubscriptionRenewalAdmissionOutcome,
66 SubscriptionRenewalProviderResult, admit_subscription_renewal_submission,
67 apply_reconciled_subscription_renewal_gateway_outcome,
68 apply_subscription_renewal_gateway_outcome, submit_admitted_subscription_renewal,
69};
70
71const BILLING_LOCK_TIMEOUT: Duration = Duration::from_millis(250);
72const BILLING_ROW_LOCK_TIMEOUT: &str = "250ms";
73const BILLING_OPERATION_TIMEOUT: &str = "5s";
74const APPROVED_EVIDENCE_WRITE_ATTEMPTS: usize = 3;
75const APPROVED_EVIDENCE_RETRY_DELAY: Duration = Duration::from_millis(50);
76const APPROVED_APPLICATION_ATTEMPTS: usize = 3;
77const INVALID_APPLICATION_STATE: &str = "canonical initial-enrollment application state is invalid";
78const CURRENT_SUBSCRIPTION_CONFLICT_TEXT: &str =
79 "Approved subscription enrollment conflicts with a current subscription.";
80const CURRENT_GRANT_CONFLICT_TEXT: &str =
81 "Approved subscription enrollment conflicts with an active subscription grant.";
82const INCOMPLETE_APPROVAL_TEXT: &str =
83 "Approved subscription enrollment is missing required processor identity.";
84const APPROVED_STORAGE_FAILURE_TEXT: &str =
85 "Approved subscription enrollment could not be applied; manual review is required.";
86const TERMINAL_APPROVAL_RACE_TEXT: &str =
87 "Approved processor evidence arrived after the enrollment attempt became terminal.";
88const RECOVERY_INCOMPLETE_APPROVAL_TEXT: &str =
89 "Approved subscription recovery is missing required processor identity.";
90const RECOVERY_APPROVED_STORAGE_FAILURE_TEXT: &str =
91 "Approved subscription recovery could not be applied; manual review is required.";
92const RECOVERY_STALE_STATE_TEXT: &str = "Approved subscription recovery could not update billing state because the subscription changed.";
93const RENEWAL_INCOMPLETE_APPROVAL_TEXT: &str =
94 "Approved subscription renewal is missing required processor identity.";
95const RENEWAL_APPROVED_STORAGE_FAILURE_TEXT: &str =
96 "Approved subscription renewal could not be applied; manual review is required.";
97const RENEWAL_STALE_STATE_TEXT: &str = "Approved subscription renewal could not update billing state because the subscription changed.";
98const PAYMENT_METHOD_REPLACEMENT_INCOMPLETE_APPROVAL_TEXT: &str =
99 "Approved payment method replacement is missing required processor identity.";
100const PAYMENT_METHOD_REPLACEMENT_STORAGE_FAILURE_TEXT: &str =
101 "Approved payment method replacement could not be applied; manual review is required.";
102const PAYMENT_METHOD_REPLACEMENT_STALE_STATE_TEXT: &str =
103 "Approved payment method replacement could not attach because the subscription changed.";
104
105#[derive(Error)]
106pub enum SubscriptionEnrollmentApplicationError {
107 #[error("subscription enrollment application storage failed")]
108 Sql(#[from] sqlx::Error),
109 #[error("subscription enrollment attempt storage failed")]
110 Attempt(#[from] PaymentAttemptStoreError),
111 #[error("host billing transaction failed")]
112 Transaction(#[from] BillingTransactionError),
113 #[error("host billing event append failed")]
114 Event(#[from] crate::BillingEventWriteError),
115 #[error("approved subscription enrollment could not be durably applied or parked")]
116 ApprovedEvidenceNotDurable,
117 #[error("admitted subscription enrollment does not match the submission command or gateway")]
118 SubmissionIdentityMismatch,
119 #[error("{0}")]
120 InvalidState(&'static str),
121}
122
123impl From<crate::processor_charges::ProcessorChargeStoreError>
124 for SubscriptionEnrollmentApplicationError
125{
126 fn from(error: crate::processor_charges::ProcessorChargeStoreError) -> Self {
127 match error {
128 crate::processor_charges::ProcessorChargeStoreError::Sql(error) => Self::Sql(error),
129 crate::processor_charges::ProcessorChargeStoreError::Attempt(error) => {
130 Self::Attempt(error)
131 }
132 crate::processor_charges::ProcessorChargeStoreError::InvalidState(message) => {
133 Self::InvalidState(message)
134 }
135 }
136 }
137}
138
139impl From<RenewalFailureStoreError> for SubscriptionEnrollmentApplicationError {
140 fn from(error: RenewalFailureStoreError) -> Self {
141 match error {
142 RenewalFailureStoreError::Sql(error) => Self::Sql(error),
143 RenewalFailureStoreError::Attempt(error) => Self::Attempt(error),
144 RenewalFailureStoreError::InvalidState(message) => Self::InvalidState(message),
145 }
146 }
147}
148
149impl From<SubscriptionEnrollmentPaymentResultBuildError>
150 for SubscriptionEnrollmentApplicationError
151{
152 fn from(_: SubscriptionEnrollmentPaymentResultBuildError) -> Self {
153 Self::InvalidState(INVALID_APPLICATION_STATE)
154 }
155}
156
157fn map_subscription_persistence_error(
158 error: SubscriptionPersistenceCodecError,
159) -> SubscriptionEnrollmentApplicationError {
160 match error {
161 SubscriptionPersistenceCodecError::RowRead(error) => {
162 SubscriptionEnrollmentApplicationError::Sql(error)
163 }
164 SubscriptionPersistenceCodecError::InvalidState => {
165 SubscriptionEnrollmentApplicationError::InvalidState(INVALID_APPLICATION_STATE)
166 }
167 }
168}
169
170pub(crate) fn map_attempt_transition_error(
171 error: PaymentAttemptStoreError,
172) -> SubscriptionEnrollmentApplicationError {
173 match error {
174 PaymentAttemptStoreError::Sql(error) => SubscriptionEnrollmentApplicationError::Sql(error),
175 PaymentAttemptStoreError::InvalidState(_) => {
176 SubscriptionEnrollmentApplicationError::InvalidState(INVALID_APPLICATION_STATE)
177 }
178 }
179}
180
181impl fmt::Debug for SubscriptionEnrollmentApplicationError {
182 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183 match self {
184 Self::Sql(_) => formatter.write_str("SubscriptionEnrollmentApplicationError::Sql"),
185 Self::Attempt(_) => {
186 formatter.write_str("SubscriptionEnrollmentApplicationError::Attempt")
187 }
188 Self::Transaction(_) => {
189 formatter.write_str("SubscriptionEnrollmentApplicationError::Transaction")
190 }
191 Self::Event(_) => formatter.write_str("SubscriptionEnrollmentApplicationError::Event"),
192 Self::ApprovedEvidenceNotDurable => formatter
193 .write_str("SubscriptionEnrollmentApplicationError::ApprovedEvidenceNotDurable"),
194 Self::SubmissionIdentityMismatch => formatter
195 .write_str("SubscriptionEnrollmentApplicationError::SubmissionIdentityMismatch"),
196 Self::InvalidState(detail) => formatter
197 .debug_tuple("SubscriptionEnrollmentApplicationError::InvalidState")
198 .field(detail)
199 .finish(),
200 }
201 }
202}
203
204async fn finalize_approved_application(
205 mut transaction: Box<dyn BillingTransaction>,
206 application: Result<
207 (SubscriptionEnrollmentPaymentResult, Option<BillingEvent>),
208 SubscriptionEnrollmentApplicationError,
209 >,
210) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentApplicationError> {
211 let (result, event) = match application {
212 Ok(application) => application,
213 Err(error) => {
214 let _ = transaction.rollback().await;
215 return Err(error);
216 }
217 };
218 if let Some(event) = event.as_ref()
219 && let Err(error) = transaction.append_event(event).await
220 {
221 let _ = transaction.rollback().await;
222 return Err(error.into());
223 }
224 transaction.commit().await?;
225 Ok(result)
226}
227
228async fn lock_expected_reservation_attempt(
229 connection: &mut PgConnection,
230 reservation: OutcomeReservation<'_>,
231) -> Result<PaymentAttempt, SubscriptionEnrollmentApplicationError> {
232 let identity = reservation.identity();
233 let attempt = lock_payment_attempt_by_id_on_connection(
234 connection,
235 identity.billing_scope_id(),
236 identity.attempt_id(),
237 )
238 .await?
239 .ok_or(SubscriptionEnrollmentApplicationError::InvalidState(
240 INVALID_APPLICATION_STATE,
241 ))?;
242 if !reservation.matches_attempt(&attempt) {
243 return Err(SubscriptionEnrollmentApplicationError::InvalidState(
244 INVALID_APPLICATION_STATE,
245 ));
246 }
247 Ok(attempt)
248}
249
250async fn recovery_subscription_matches(
251 connection: &mut PgConnection,
252 reservation: &SubscriptionRecoveryReservation,
253) -> Result<bool, SubscriptionEnrollmentApplicationError> {
254 let identity = reservation.identity();
255 let expected = reservation.expected_state();
256 let row = sqlx::query(
257 r#"
258 SELECT status, payment_method_id, initial_transaction_id, next_renewal_at,
259 required_gateway_account_mode
260 FROM billing_subscriptions
261 WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
262 AND gateway_account_id = $4 AND plan_key = $5
263 FOR UPDATE
264 "#,
265 )
266 .bind(reservation.subscription_id().as_uuid())
267 .bind(identity.billing_scope_id().as_uuid())
268 .bind(identity.subscriber_id().as_uuid())
269 .bind(identity.gateway_account_id().as_uuid())
270 .bind(reservation.plan_key().as_str())
271 .fetch_optional(&mut *connection)
272 .await?;
273 let Some(row) = row else {
274 return Ok(false);
275 };
276 let status = row.try_get::<String, _>("status")?;
277 let payment_method_id: Uuid = row.try_get("payment_method_id")?;
278 let initial_transaction_id: String = row.try_get("initial_transaction_id")?;
279 let next_renewal_at: DateTime<Utc> = row.try_get("next_renewal_at")?;
280 Ok(status == expected.status().as_str()
281 && matches!(status.as_str(), "active" | "past_due")
282 && payment_method_id == expected.payment_method_id().into_uuid()
283 && syrup_rail::canonical_gateway_transaction_ids_equal(
284 &initial_transaction_id,
285 expected.initial_transaction_id().expose(),
286 )
287 && row.try_get::<String, _>("required_gateway_account_mode")?
288 == identity.required_gateway_account_mode().as_str()
289 && next_renewal_at == *reservation.period().start_at())
290}
291
292async fn renewal_subscription_matches(
293 connection: &mut PgConnection,
294 reservation: &SubscriptionRenewalReservation,
295) -> Result<bool, SubscriptionEnrollmentApplicationError> {
296 let identity = reservation.identity();
297 let expected = reservation.expected_state();
298 let row = sqlx::query(
299 r#"
300 SELECT status, payment_method_id, initial_transaction_id,
301 amount_cents, currency, next_renewal_at, required_gateway_account_mode
302 FROM billing_subscriptions
303 WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
304 AND gateway_account_id = $4 AND plan_key = $5
305 FOR UPDATE
306 "#,
307 )
308 .bind(reservation.subscription_id().as_uuid())
309 .bind(identity.billing_scope_id().as_uuid())
310 .bind(identity.subscriber_id().as_uuid())
311 .bind(identity.gateway_account_id().as_uuid())
312 .bind(reservation.plan_key().as_str())
313 .fetch_optional(&mut *connection)
314 .await?;
315 let Some(row) = row else {
316 return Ok(false);
317 };
318 let status = row.try_get::<String, _>("status")?;
319 let initial_transaction_id: String = row.try_get("initial_transaction_id")?;
320 Ok(status == expected.status().as_str()
321 && matches!(status.as_str(), "active" | "past_due")
322 && row.try_get::<Uuid, _>("payment_method_id")? == expected.payment_method_id().into_uuid()
323 && syrup_rail::canonical_gateway_transaction_ids_equal(
324 &initial_transaction_id,
325 expected.initial_transaction_id().expose(),
326 )
327 && row.try_get::<i32, _>("amount_cents")? == reservation.request().amount().cents()
328 && row.try_get::<String, _>("currency")?
329 == reservation.request().amount().currency().as_str()
330 && row.try_get::<String, _>("required_gateway_account_mode")?
331 == identity.required_gateway_account_mode().as_str()
332 && row.try_get::<DateTime<Utc>, _>("next_renewal_at")? == *reservation.period().start_at())
333}
334
335async fn disable_payment_method_if_unreferenced(
336 connection: &mut PgConnection,
337 payment_method_id: PaymentMethodId,
338) -> Result<(), sqlx::Error> {
339 sqlx::query(
340 r#"
341 UPDATE billing_payment_methods AS methods
342 SET status = 'disabled', updated_at = clock_timestamp()
343 WHERE methods.id = $1 AND methods.status = 'active'
344 AND NOT EXISTS (
345 SELECT 1 FROM billing_subscriptions AS subscriptions
346 WHERE subscriptions.payment_method_id = methods.id
347 AND subscriptions.status IN ('active', 'past_due')
348 )
349 "#,
350 )
351 .bind(payment_method_id.as_uuid())
352 .execute(connection)
353 .await?;
354 Ok(())
355}
356
357async fn advance_subscription_discount_after_successful_charge(
358 connection: &mut PgConnection,
359 subscription_id: SubscriptionId,
360 plan_key: &PlanKey,
361) -> Result<(), SubscriptionEnrollmentApplicationError> {
362 let row = sqlx::query(
363 r#"
364 SELECT duration, status, periods_total, periods_applied, base_amount_cents
365 FROM billing_subscription_discounts
366 WHERE subscription_id = $1 AND plan_key = $2
367 FOR UPDATE
368 "#,
369 )
370 .bind(subscription_id.as_uuid())
371 .bind(plan_key.as_str())
372 .fetch_optional(&mut *connection)
373 .await?;
374 let Some(row) = row else {
375 return Ok(());
376 };
377 let duration: String = row.try_get("duration")?;
378 let status: String = row.try_get("status")?;
379 if status == "completed" {
380 return Ok(());
381 }
382 let periods_applied: i32 = row.try_get("periods_applied")?;
383 if duration == "indefinite" {
384 match periods_applied {
385 0 => {
386 sqlx::query(
387 r#"
388 UPDATE billing_subscription_discounts
389 SET periods_applied = 1
390 WHERE subscription_id = $1 AND plan_key = $2
391 AND status = 'active' AND periods_applied = 0
392 "#,
393 )
394 .bind(subscription_id.as_uuid())
395 .bind(plan_key.as_str())
396 .execute(&mut *connection)
397 .await?;
398 return Ok(());
399 }
400 1 => return Ok(()),
401 _ => {
402 return Err(SubscriptionEnrollmentApplicationError::InvalidState(
403 INVALID_APPLICATION_STATE,
404 ));
405 }
406 }
407 }
408 if duration != "limited_months" {
409 return Err(SubscriptionEnrollmentApplicationError::InvalidState(
410 INVALID_APPLICATION_STATE,
411 ));
412 }
413 let periods_total: i32 = row.try_get("periods_total")?;
414 if periods_applied >= periods_total {
415 return Err(SubscriptionEnrollmentApplicationError::InvalidState(
416 INVALID_APPLICATION_STATE,
417 ));
418 }
419 let next_periods_applied = periods_applied + 1;
420 let completed = next_periods_applied == periods_total;
421 sqlx::query(
422 r#"
423 UPDATE billing_subscription_discounts
424 SET periods_applied = $3,
425 status = CASE WHEN $4 THEN 'completed' ELSE 'active' END,
426 completed_at = CASE WHEN $4 THEN clock_timestamp() ELSE NULL END
427 WHERE subscription_id = $1 AND plan_key = $2
428 "#,
429 )
430 .bind(subscription_id.as_uuid())
431 .bind(plan_key.as_str())
432 .bind(next_periods_applied)
433 .bind(completed)
434 .execute(&mut *connection)
435 .await?;
436 if completed {
437 let base_amount_cents: i32 = row.try_get("base_amount_cents")?;
438 sqlx::query(
439 "UPDATE billing_subscriptions SET amount_cents = $2, updated_at = clock_timestamp() WHERE id = $1",
440 )
441 .bind(subscription_id.as_uuid())
442 .bind(base_amount_cents)
443 .execute(&mut *connection)
444 .await?;
445 }
446 Ok(())
447}
448
449#[derive(Clone, Copy, Debug, Eq, PartialEq)]
450pub(crate) enum OutcomeResolutionBoundary {
451 Prepared,
452 AdmittedNotSubmitted,
453 Submitted,
454}
455
456#[derive(Clone, Copy, Debug, Eq, PartialEq)]
457pub(crate) enum RateLimitCooldown {
458 Account,
459 Provider,
460}
461
462#[derive(Clone, Copy, Debug, Eq, PartialEq)]
463pub(crate) enum RateLimitCooldownPersistence {
464 Applied,
465 IdentityChanged,
466 MissingProviderCooldown,
467}
468
469#[derive(Clone, Copy, Debug, Eq, PartialEq)]
470enum RateLimitCooldownCommitDisposition {
471 Applied,
472 IdentityNotDurable,
473 IdentityChanged,
474 MissingProviderCooldown,
475}
476
477#[derive(Clone, Copy)]
478pub(crate) enum RateLimitCooldownOperation {
479 Subscription,
480 HostCharge,
481}
482
483impl RateLimitCooldownOperation {
484 const fn identity_not_durable_message(self) -> &'static str {
485 match self {
486 Self::Subscription => {
487 "skipped cooldown for a gateway identity that is no longer authoritative"
488 }
489 Self::HostCharge => {
490 "skipped host-charge cooldown for a gateway identity that is no longer authoritative"
491 }
492 }
493 }
494
495 const fn identity_changed_message(self) -> &'static str {
496 match self {
497 Self::Subscription => "skipped cooldown after the gateway account identity changed",
498 Self::HostCharge => {
499 "skipped host-charge cooldown after the gateway account identity changed"
500 }
501 }
502 }
503
504 const fn missing_provider_message(self) -> &'static str {
505 match self {
506 Self::Subscription => {
507 "provider-scoped cooldown storage is missing; leaving the canonical attempt unresolved"
508 }
509 Self::HostCharge => {
510 "provider-scoped cooldown storage is missing; leaving the canonical host-charge attempt unresolved"
511 }
512 }
513 }
514}
515
516pub(crate) enum RateLimitCooldownCommitError {
517 Sql(sqlx::Error),
518 MissingProviderCooldown,
519}
520
521impl From<sqlx::Error> for RateLimitCooldownCommitError {
522 fn from(error: sqlx::Error) -> Self {
523 Self::Sql(error)
524 }
525}
526
527#[derive(Clone, Copy, Debug, Eq, PartialEq)]
528enum ReservationOperation {
529 Initial,
530 Recovery,
531 Renewal,
532 PaymentMethodReplacement,
533}
534
535impl ReservationOperation {
536 const fn expected_kind(self) -> PaymentAttemptKind {
537 match self {
538 Self::Initial => PaymentAttemptKind::SubscriptionInitial,
539 Self::Recovery => PaymentAttemptKind::SubscriptionRecovery,
540 Self::Renewal => PaymentAttemptKind::SubscriptionRenewal,
541 Self::PaymentMethodReplacement => PaymentAttemptKind::SubscriptionPaymentMethodUpdate,
542 }
543 }
544
545 const fn preserves_review_required_for_unknown(self) -> bool {
546 matches!(self, Self::PaymentMethodReplacement)
547 }
548}
549
550#[derive(Clone, Copy)]
554enum OutcomeReservation<'a> {
555 Initial(&'a SubscriptionEnrollmentReservation),
556 Recovery(&'a SubscriptionRecoveryReservation),
557 Renewal(&'a SubscriptionRenewalReservation),
558 PaymentMethodReplacement(&'a SubscriptionPaymentMethodReplacement),
559}
560
561impl<'a> OutcomeReservation<'a> {
562 const fn operation(self) -> ReservationOperation {
563 match self {
564 Self::Initial(_) => ReservationOperation::Initial,
565 Self::Recovery(_) => ReservationOperation::Recovery,
566 Self::Renewal(_) => ReservationOperation::Renewal,
567 Self::PaymentMethodReplacement(_) => ReservationOperation::PaymentMethodReplacement,
568 }
569 }
570
571 const fn identity(self) -> PaymentAttemptIdentity {
572 match self {
573 Self::Initial(reservation) => reservation.identity(),
574 Self::Recovery(reservation) => reservation.identity(),
575 Self::Renewal(reservation) => reservation.identity(),
576 Self::PaymentMethodReplacement(reservation) => reservation.identity(),
577 }
578 }
579
580 const fn plan_key(self) -> &'a PlanKey {
581 match self {
582 Self::Initial(reservation) => reservation.plan_key(),
583 Self::Recovery(reservation) => reservation.plan_key(),
584 Self::Renewal(reservation) => reservation.plan_key(),
585 Self::PaymentMethodReplacement(reservation) => reservation.plan_key(),
586 }
587 }
588
589 const fn provider_key(self) -> &'a GatewayProviderKey {
590 match self {
591 Self::Initial(reservation) => reservation.provider_key(),
592 Self::Recovery(reservation) => reservation.provider_key(),
593 Self::Renewal(reservation) => reservation.provider_key(),
594 Self::PaymentMethodReplacement(reservation) => reservation.provider_key(),
595 }
596 }
597
598 const fn expected_kind(self) -> PaymentAttemptKind {
599 self.operation().expected_kind()
600 }
601
602 const fn prepared_attempt_replay(self) -> PreparedAttemptReplay {
603 match self {
604 Self::Initial(_) | Self::Recovery(_) | Self::PaymentMethodReplacement(_) => {
605 PreparedAttemptReplay::Supported
606 }
607 Self::Renewal(_) => PreparedAttemptReplay::Unsupported,
608 }
609 }
610
611 fn expected_attempt(self) -> ReservationAttemptExpectation<'a> {
612 match self {
613 Self::Initial(reservation) => ReservationAttemptExpectation::Initial {
614 identity: reservation.identity(),
615 plan_key: reservation.plan_key(),
616 gateway_order_id: reservation.gateway_order_id(),
617 },
618 Self::Recovery(reservation) => ReservationAttemptExpectation::Exact {
619 identity: reservation.identity(),
620 kind: self.expected_kind(),
621 request: reservation.request(),
622 },
623 Self::Renewal(reservation) => ReservationAttemptExpectation::Exact {
624 identity: reservation.identity(),
625 kind: self.expected_kind(),
626 request: reservation.request(),
627 },
628 Self::PaymentMethodReplacement(reservation) => ReservationAttemptExpectation::Exact {
629 identity: reservation.identity(),
630 kind: self.expected_kind(),
631 request: reservation.request(),
632 },
633 }
634 }
635
636 fn matches_attempt(self, attempt: &PaymentAttempt) -> bool {
637 self.expected_attempt().matches(attempt)
638 }
639}
640
641enum ReservationAttemptExpectation<'a> {
645 Initial {
646 identity: PaymentAttemptIdentity,
647 plan_key: &'a PlanKey,
648 gateway_order_id: &'a GatewayOrderId,
649 },
650 Exact {
651 identity: PaymentAttemptIdentity,
652 kind: PaymentAttemptKind,
653 request: &'a PaymentAttemptRequest,
654 },
655}
656
657impl ReservationAttemptExpectation<'_> {
658 const fn expected_kind(&self) -> PaymentAttemptKind {
659 match self {
660 Self::Initial { .. } => PaymentAttemptKind::SubscriptionInitial,
661 Self::Exact { kind, .. } => *kind,
662 }
663 }
664
665 fn matches(&self, attempt: &PaymentAttempt) -> bool {
666 match self {
667 Self::Initial {
668 identity,
669 plan_key,
670 gateway_order_id,
671 } => {
672 attempt.identity() == *identity
673 && attempt.kind() == self.expected_kind()
674 && attempt.request().target().plan_key() == Some(*plan_key)
675 && attempt.request().gateway_order_id() == *gateway_order_id
676 }
677 Self::Exact {
678 identity,
679 kind,
680 request,
681 } => {
682 attempt.identity() == *identity
683 && attempt.kind() == *kind
684 && attempt.request() == *request
685 }
686 }
687 }
688}
689
690#[derive(Clone, Copy, Debug, Eq, PartialEq)]
691enum OutcomeResolutionKind {
692 NonApproved,
693 Unknown,
694}
695
696#[derive(Clone, Copy)]
699pub(crate) struct OutcomeResolutionCommand {
700 kind: OutcomeResolutionKind,
701 status: AttemptResolutionStatus,
702 resolution_code: Option<PaymentResolutionCode>,
703 cooldown: Option<RateLimitCooldown>,
704 boundary: OutcomeResolutionBoundary,
705}
706
707impl OutcomeResolutionCommand {
708 pub(crate) const fn non_approved(
709 status: AttemptResolutionStatus,
710 resolution_code: Option<PaymentResolutionCode>,
711 cooldown: Option<RateLimitCooldown>,
712 boundary: OutcomeResolutionBoundary,
713 ) -> Self {
714 Self {
715 kind: OutcomeResolutionKind::NonApproved,
716 status,
717 resolution_code,
718 cooldown,
719 boundary,
720 }
721 }
722
723 const fn unknown(cooldown: Option<RateLimitCooldown>) -> Self {
724 Self {
725 kind: OutcomeResolutionKind::Unknown,
726 status: AttemptResolutionStatus::Unknown,
727 resolution_code: None,
728 cooldown,
729 boundary: OutcomeResolutionBoundary::Submitted,
730 }
731 }
732
733 const fn may_resolve(self, status: PaymentAttemptStatus, submitted: bool) -> bool {
734 status.is_resolvable()
735 && match self.boundary {
736 OutcomeResolutionBoundary::Prepared => !submitted,
737 OutcomeResolutionBoundary::AdmittedNotSubmitted => submitted,
738 OutcomeResolutionBoundary::Submitted => true,
739 }
740 }
741
742 fn resolved_status(
743 self,
744 operation: ReservationOperation,
745 current: PaymentAttemptStatus,
746 ) -> AttemptResolutionStatus {
747 if self.kind == OutcomeResolutionKind::Unknown
748 && operation.preserves_review_required_for_unknown()
749 && current == PaymentAttemptStatus::ReviewRequired
750 {
751 AttemptResolutionStatus::ReviewRequired
752 } else {
753 self.status
754 }
755 }
756
757 fn clears_submitted_at(self) -> bool {
758 self.boundary == OutcomeResolutionBoundary::AdmittedNotSubmitted
759 }
760
761 fn records_pending_evidence(self, status: AttemptResolutionStatus) -> bool {
762 self.kind == OutcomeResolutionKind::Unknown
763 && status != AttemptResolutionStatus::ReviewRequired
764 }
765
766 fn marks_renewal_past_due(self, status: AttemptResolutionStatus) -> bool {
767 self.boundary == OutcomeResolutionBoundary::Submitted
768 && matches!(
769 status,
770 AttemptResolutionStatus::Declined | AttemptResolutionStatus::Failed
771 )
772 }
773}
774
775pub(crate) struct OutcomeApplication {
776 payment: SubscriptionEnrollmentPaymentResult,
777 applied: bool,
778 prepared_attempt_replay: PreparedAttemptReplay,
779}
780
781impl OutcomeApplication {
782 pub(crate) fn into_payment(self) -> SubscriptionEnrollmentPaymentResult {
783 self.payment
784 }
785
786 fn should_surface_not_submitted(&self, policy: GatewayNotSubmittedPolicy) -> bool {
787 should_surface_not_submitted_application(
788 self.applied,
789 self.payment.attempt(),
790 policy,
791 self.prepared_attempt_replay,
792 )
793 }
794}
795
796#[derive(Clone, Copy, Debug, Eq, PartialEq)]
797pub(crate) enum PreparedAttemptReplay {
798 Supported,
799 Unsupported,
800}
801
802pub(crate) fn should_surface_not_submitted_application(
803 applied: bool,
804 attempt: &PaymentAttempt,
805 policy: GatewayNotSubmittedPolicy,
806 prepared_attempt_replay: PreparedAttemptReplay,
807) -> bool {
808 applied
809 || (prepared_attempt_replay == PreparedAttemptReplay::Supported
810 && policy.restores_prepared_attempt_when_supported()
811 && attempt.status() == PaymentAttemptStatus::Pending
812 && attempt.state().timestamps().submitted_at().is_none())
813}
814
815async fn resolve_pool_outcome(
816 pool: &PgPool,
817 reservation: OutcomeReservation<'_>,
818 evidence: &ProcessorEvidence,
819 resolution: OutcomeResolutionCommand,
820) -> Result<OutcomeApplication, SubscriptionEnrollmentApplicationError> {
821 let identity = reservation.identity();
822 let prepared_attempt_replay = reservation.prepared_attempt_replay();
823 if let Some(cooldown) = resolution.cooldown {
824 commit_rate_limit_cooldown(pool, reservation, cooldown).await?;
825 }
826 let mut transaction = pool.begin().await?;
827 set_application_timeouts(&mut transaction).await?;
828 lock_subscription_aggregate(
829 &mut transaction,
830 identity.subscriber_id(),
831 reservation.plan_key(),
832 )
833 .await?;
834 let attempt = lock_expected_reservation_attempt(&mut transaction, reservation).await?;
835 let applied = resolution.may_resolve(
836 attempt.status(),
837 attempt.state().timestamps().submitted_at().is_some(),
838 );
839 if applied {
840 let status = resolution.resolved_status(reservation.operation(), attempt.status());
841 persist_attempt_transition(
842 &mut transaction,
843 &attempt,
844 evidence,
845 AttemptTransition::Resolved {
846 status,
847 resolution_code: resolution.resolution_code,
848 },
849 )
850 .await
851 .map_err(map_attempt_transition_error)?;
852 if resolution.clears_submitted_at() {
853 clear_resolved_attempt_submission(&mut transaction, &attempt).await?;
854 }
855 if resolution.records_pending_evidence(status) && evidence.indicates_approved_payment() {
856 observe_processor_charge(
857 &mut transaction,
858 &attempt,
859 evidence,
860 ProcessorChargeProgression::Pending,
861 )
862 .await?;
863 }
864 }
865 let result = payment_result_for_reservation_attempt(&mut transaction, reservation).await?;
866 transaction.commit().await?;
867 Ok(OutcomeApplication {
868 payment: result,
869 applied,
870 prepared_attempt_replay,
871 })
872}
873
874async fn restore_admitted_attempt_for_retry(
878 pool: &PgPool,
879 reservation: OutcomeReservation<'_>,
880) -> Result<OutcomeApplication, SubscriptionEnrollmentApplicationError> {
881 let identity = reservation.identity();
885 let mut transaction = pool.begin().await?;
886 set_application_timeouts(&mut transaction).await?;
887 lock_subscription_aggregate(
888 &mut transaction,
889 identity.subscriber_id(),
890 reservation.plan_key(),
891 )
892 .await?;
893 let attempt = lock_expected_reservation_attempt(&mut transaction, reservation).await?;
894 let restored = attempt.status() == PaymentAttemptStatus::Pending
895 && attempt.state().timestamps().submitted_at().is_some();
896 if restored {
897 restore_prepared_attempt_submission(&mut transaction, &attempt).await?;
898 }
899 let result = payment_result_for_reservation_attempt(&mut transaction, reservation).await?;
900 transaction.commit().await?;
901 if restored {
902 tracing::warn!(
903 target: "syrup_rail::gateway_control_plane",
904 attempt_id = %identity.attempt_id().as_uuid(),
905 attempt_kind = attempt.kind().as_str(),
906 required_gateway_account_mode = identity.required_gateway_account_mode().as_str(),
907 "restored admitted payment attempt after pre-submission control-plane failure"
908 );
909 }
910 Ok(OutcomeApplication {
911 payment: result,
912 applied: restored,
913 prepared_attempt_replay: reservation.prepared_attempt_replay(),
914 })
915}
916
917async fn apply_resumable_not_submitted_policy(
918 pool: &PgPool,
919 reservation: OutcomeReservation<'_>,
920 evidence: &ProcessorEvidence,
921 policy: GatewayNotSubmittedPolicy,
922) -> Result<OutcomeApplication, SubscriptionEnrollmentApplicationError> {
923 if policy.restores_prepared_attempt_when_supported()
924 && reservation.prepared_attempt_replay() == PreparedAttemptReplay::Supported
925 {
926 return restore_admitted_attempt_for_retry(pool, reservation).await;
927 }
928 resolve_pool_outcome(
929 pool,
930 reservation,
931 evidence,
932 OutcomeResolutionCommand::non_approved(
933 AttemptResolutionStatus::Failed,
934 Some(policy.resolution_code()),
935 policy.cooldown(),
936 OutcomeResolutionBoundary::AdmittedNotSubmitted,
937 ),
938 )
939 .await
940}
941
942async fn clear_resolved_attempt_submission(
947 connection: &mut PgConnection,
948 attempt: &PaymentAttempt,
949) -> Result<(), SubscriptionEnrollmentApplicationError> {
950 let result = sqlx::query(
951 "UPDATE billing_payment_attempts SET submitted_at = NULL, updated_at = clock_timestamp() WHERE id = $1",
952 )
953 .bind(attempt.identity().attempt_id().as_uuid())
954 .execute(connection)
955 .await?;
956 if result.rows_affected() != 1 {
957 return Err(SubscriptionEnrollmentApplicationError::InvalidState(
958 INVALID_APPLICATION_STATE,
959 ));
960 }
961 Ok(())
962}
963
964pub(crate) async fn restore_prepared_attempt_submission(
969 connection: &mut PgConnection,
970 attempt: &PaymentAttempt,
971) -> Result<(), SubscriptionEnrollmentApplicationError> {
972 let result = sqlx::query(
973 "UPDATE billing_payment_attempts SET submitted_at = NULL, updated_at = clock_timestamp() \
974 WHERE id = $1 AND status = 'pending' AND submitted_at IS NOT NULL",
975 )
976 .bind(attempt.identity().attempt_id().as_uuid())
977 .execute(connection)
978 .await?;
979 if result.rows_affected() != 1 {
980 return Err(SubscriptionEnrollmentApplicationError::InvalidState(
981 INVALID_APPLICATION_STATE,
982 ));
983 }
984 Ok(())
985}
986
987async fn payment_result_for_reservation_attempt(
988 connection: &mut PgConnection,
989 reservation: OutcomeReservation<'_>,
990) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentApplicationError> {
991 let identity = reservation.identity();
992 let attempt = find_payment_attempt_by_id_on_connection(
993 connection,
994 identity.billing_scope_id(),
995 identity.attempt_id(),
996 )
997 .await?
998 .ok_or(SubscriptionEnrollmentApplicationError::InvalidState(
999 INVALID_APPLICATION_STATE,
1000 ))?;
1001 payment_result_for_attempt(connection, attempt).await
1002}
1003
1004async fn commit_rate_limit_cooldown(
1005 pool: &PgPool,
1006 reservation: OutcomeReservation<'_>,
1007 cooldown: RateLimitCooldown,
1008) -> Result<(), SubscriptionEnrollmentApplicationError> {
1009 match commit_rate_limit_cooldown_for_operation(
1015 pool,
1016 reservation.identity(),
1017 reservation.provider_key(),
1018 cooldown,
1019 RateLimitCooldownOperation::Subscription,
1020 )
1021 .await
1022 {
1023 Ok(()) => Ok(()),
1024 Err(RateLimitCooldownCommitError::Sql(error)) => Err(error.into()),
1025 Err(RateLimitCooldownCommitError::MissingProviderCooldown) => Err(
1026 SubscriptionEnrollmentApplicationError::InvalidState(INVALID_APPLICATION_STATE),
1027 ),
1028 }
1029}
1030
1031async fn commit_rate_limit_cooldown_for_identity(
1032 pool: &PgPool,
1033 identity: PaymentAttemptIdentity,
1034 provider_key: &GatewayProviderKey,
1035 cooldown: RateLimitCooldown,
1036) -> Result<RateLimitCooldownCommitDisposition, sqlx::Error> {
1037 let mut transaction = pool.begin().await?;
1038 set_application_timeouts(&mut transaction).await?;
1039 if !rate_limit_cooldown_identity_is_durable(&mut transaction, identity).await? {
1040 transaction.rollback().await?;
1041 return Ok(RateLimitCooldownCommitDisposition::IdentityNotDurable);
1042 }
1043 match persist_rate_limit_cooldown(&mut transaction, identity, provider_key, cooldown).await? {
1044 RateLimitCooldownPersistence::Applied => {
1045 transaction.commit().await?;
1046 Ok(RateLimitCooldownCommitDisposition::Applied)
1047 }
1048 RateLimitCooldownPersistence::IdentityChanged => {
1049 transaction.rollback().await?;
1050 Ok(RateLimitCooldownCommitDisposition::IdentityChanged)
1051 }
1052 RateLimitCooldownPersistence::MissingProviderCooldown => {
1053 transaction.rollback().await?;
1054 Ok(RateLimitCooldownCommitDisposition::MissingProviderCooldown)
1055 }
1056 }
1057}
1058
1059pub(crate) async fn commit_rate_limit_cooldown_for_operation(
1060 pool: &PgPool,
1061 identity: PaymentAttemptIdentity,
1062 provider_key: &GatewayProviderKey,
1063 cooldown: RateLimitCooldown,
1064 operation: RateLimitCooldownOperation,
1065) -> Result<(), RateLimitCooldownCommitError> {
1066 match commit_rate_limit_cooldown_for_identity(pool, identity, provider_key, cooldown).await? {
1067 RateLimitCooldownCommitDisposition::Applied => Ok(()),
1068 RateLimitCooldownCommitDisposition::IdentityNotDurable => {
1069 tracing::warn!(
1070 target: "syrup_rail::gateway_cooldown",
1071 billing_scope_id = %identity.billing_scope_id().as_uuid(),
1072 gateway_account_id = %identity.gateway_account_id().as_uuid(),
1073 provider_key = provider_key.as_str(),
1074 ?cooldown,
1075 "{}",
1076 operation.identity_not_durable_message()
1077 );
1078 Ok(())
1079 }
1080 RateLimitCooldownCommitDisposition::IdentityChanged => {
1081 tracing::warn!(
1082 target: "syrup_rail::gateway_cooldown",
1083 billing_scope_id = %identity.billing_scope_id().as_uuid(),
1084 gateway_account_id = %identity.gateway_account_id().as_uuid(),
1085 provider_key = provider_key.as_str(),
1086 ?cooldown,
1087 "{}",
1088 operation.identity_changed_message()
1089 );
1090 Ok(())
1091 }
1092 RateLimitCooldownCommitDisposition::MissingProviderCooldown => {
1093 tracing::error!(
1094 target: "syrup_rail::gateway_cooldown",
1095 billing_scope_id = %identity.billing_scope_id().as_uuid(),
1096 gateway_account_id = %identity.gateway_account_id().as_uuid(),
1097 provider_key = provider_key.as_str(),
1098 ?cooldown,
1099 "{}",
1100 operation.missing_provider_message()
1101 );
1102 Err(RateLimitCooldownCommitError::MissingProviderCooldown)
1103 }
1104 }
1105}
1106
1107pub(crate) async fn rate_limit_cooldown_identity_is_durable(
1108 connection: &mut PgConnection,
1109 identity: PaymentAttemptIdentity,
1110) -> Result<bool, sqlx::Error> {
1111 sqlx::query_scalar(
1112 r#"
1113 SELECT EXISTS (
1114 SELECT 1
1115 FROM billing_payment_attempts
1116 WHERE id = $1
1117 AND billing_scope_id = $2
1118 AND subscriber_id = $3
1119 AND gateway_account_id = $4
1120 AND gateway_configuration_id = $5
1121 AND required_gateway_account_mode = $6
1122 )
1123 "#,
1124 )
1125 .bind(identity.attempt_id().as_uuid())
1126 .bind(identity.billing_scope_id().as_uuid())
1127 .bind(identity.subscriber_id().as_uuid())
1128 .bind(identity.gateway_account_id().as_uuid())
1129 .bind(identity.gateway_configuration_id().as_uuid())
1130 .bind(identity.required_gateway_account_mode().as_str())
1131 .fetch_one(&mut *connection)
1132 .await
1133}
1134
1135pub(crate) async fn persist_rate_limit_cooldown(
1136 connection: &mut PgConnection,
1137 identity: PaymentAttemptIdentity,
1138 provider_key: &GatewayProviderKey,
1139 cooldown: RateLimitCooldown,
1140) -> Result<RateLimitCooldownPersistence, sqlx::Error> {
1141 if cooldown == RateLimitCooldown::Provider {
1142 return persist_bound_provider_rate_limit_cooldown(
1143 connection,
1144 identity.billing_scope_id(),
1145 identity.gateway_account_id(),
1146 provider_key,
1147 )
1148 .await;
1149 }
1150 let result = sqlx::query(
1151 r#"
1152 UPDATE billing_gateway_accounts
1153 SET mutation_rate_limited_until = GREATEST(
1154 COALESCE(mutation_rate_limited_until, '-infinity'::timestamptz),
1155 clock_timestamp() + make_interval(secs => $4)
1156 )
1157 WHERE id = $1 AND billing_scope_id = $2
1158 AND gateway_configuration_id = $3
1159 "#,
1160 )
1161 .bind(identity.gateway_account_id().as_uuid())
1162 .bind(identity.billing_scope_id().as_uuid())
1163 .bind(identity.gateway_configuration_id().as_uuid())
1164 .bind(syrup_rail::GATEWAY_MUTATION_RATE_LIMIT_RETRY_AFTER_SECONDS)
1165 .execute(&mut *connection)
1166 .await?;
1167 Ok(if result.rows_affected() == 1 {
1168 RateLimitCooldownPersistence::Applied
1169 } else {
1170 RateLimitCooldownPersistence::IdentityChanged
1171 })
1172}
1173
1174pub(crate) async fn persist_bound_provider_rate_limit_cooldown(
1175 connection: &mut PgConnection,
1176 billing_scope_id: syrup_rail::BillingScopeId,
1177 gateway_account_id: syrup_rail::GatewayAccountId,
1178 provider_key: &GatewayProviderKey,
1179) -> Result<RateLimitCooldownPersistence, sqlx::Error> {
1180 let current_provider = sqlx::query_scalar::<_, String>(
1181 r#"
1182 SELECT provider_key
1183 FROM billing_gateway_accounts
1184 WHERE id = $1 AND billing_scope_id = $2
1185 FOR UPDATE
1186 "#,
1187 )
1188 .bind(gateway_account_id.as_uuid())
1189 .bind(billing_scope_id.as_uuid())
1190 .fetch_optional(&mut *connection)
1191 .await?;
1192 if current_provider.as_deref() != Some(provider_key.as_str()) {
1193 return Ok(RateLimitCooldownPersistence::IdentityChanged);
1194 }
1195 let result = sqlx::query(
1196 r#"
1197 UPDATE billing_gateway_provider_rate_limits AS provider_limits
1198 SET rate_limited_until = GREATEST(
1199 provider_limits.rate_limited_until,
1200 clock_timestamp() + make_interval(secs => $4)
1201 )
1202 FROM billing_gateway_accounts AS accounts
1203 WHERE provider_limits.provider_key = $1
1204 AND accounts.provider_key = provider_limits.provider_key
1205 AND accounts.id = $2
1206 AND accounts.billing_scope_id = $3
1207 "#,
1208 )
1209 .bind(provider_key.as_str())
1210 .bind(gateway_account_id.as_uuid())
1211 .bind(billing_scope_id.as_uuid())
1212 .bind(syrup_rail::GATEWAY_MUTATION_RATE_LIMIT_RETRY_AFTER_SECONDS)
1213 .execute(&mut *connection)
1214 .await?;
1215 Ok(if result.rows_affected() == 1 {
1216 RateLimitCooldownPersistence::Applied
1217 } else {
1218 RateLimitCooldownPersistence::MissingProviderCooldown
1219 })
1220}
1221
1222pub(crate) fn mutation_error_evidence(detail: &GatewayDiagnostic) -> ProcessorEvidence {
1223 ProcessorEvidence::new(
1224 None,
1225 None,
1226 None,
1227 None,
1228 Some(detail.clone()),
1229 None,
1230 syrup_rail::GatewayPaymentDescriptor::default(),
1231 )
1232}
1233
1234#[derive(Clone, Copy)]
1245pub(crate) struct GatewayNotSubmittedPolicy {
1246 resolution_code: PaymentResolutionCode,
1247 retry_safety: GatewayNotSubmittedRetrySafety,
1248 cooldown: Option<RateLimitCooldown>,
1249}
1250
1251#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1252enum GatewayNotSubmittedRetrySafety {
1253 Terminal,
1254 RestorePreparedWhenSupported,
1255}
1256
1257impl GatewayNotSubmittedPolicy {
1258 pub(crate) const fn for_error(error: &GatewayNotSubmittedError) -> Self {
1259 match error {
1260 GatewayNotSubmittedError::RequestRejected(_) => {
1261 Self::terminal(PaymentResolutionCode::GatewayRequestRejectedBeforeSubmission)
1262 }
1263 GatewayNotSubmittedError::Malformed(_) => {
1264 Self::terminal(PaymentResolutionCode::GatewayMalformedBeforeSubmission)
1265 }
1266 GatewayNotSubmittedError::Configuration(_) => {
1267 Self::terminal(PaymentResolutionCode::GatewayConfigurationBeforeSubmission)
1268 }
1269 GatewayNotSubmittedError::NotTransmitted(_) => {
1270 Self::retryable(PaymentResolutionCode::GatewayUnavailableBeforeSubmission)
1271 }
1272 GatewayNotSubmittedError::RateLimited(_) => Self::throttled(
1278 PaymentResolutionCode::GatewayAccountRateLimitedBeforeSubmission,
1279 RateLimitCooldown::Account,
1280 ),
1281 GatewayNotSubmittedError::AccountModeMismatch { required, .. } => {
1282 Self::for_account_mode_mismatch(*required)
1283 }
1284 GatewayNotSubmittedError::AccountModeVerification(error) => {
1285 Self::for_readiness_error(error)
1286 }
1287 }
1288 }
1289
1290 pub(crate) const fn for_readiness_error(error: &GatewayError) -> Self {
1291 match error {
1292 GatewayError::RequestRejected(_) => {
1293 Self::terminal(PaymentResolutionCode::GatewayRequestRejectedBeforeSubmission)
1294 }
1295 GatewayError::Malformed(_) => {
1296 Self::terminal(PaymentResolutionCode::GatewayMalformedBeforeSubmission)
1297 }
1298 GatewayError::Configuration(_) => {
1299 Self::terminal(PaymentResolutionCode::GatewayConfigurationBeforeSubmission)
1300 }
1301 GatewayError::Unavailable(_) => {
1302 Self::retryable(PaymentResolutionCode::GatewayUnavailableBeforeSubmission)
1303 }
1304 GatewayError::RateLimited(_) => Self::throttled(
1309 PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission,
1310 RateLimitCooldown::Provider,
1311 ),
1312 }
1313 }
1314
1315 pub(crate) const fn for_account_mode_mismatch(required: GatewayAccountMode) -> Self {
1316 Self::terminal(match required {
1317 GatewayAccountMode::Live => {
1318 PaymentResolutionCode::GatewayLiveReadinessFailedBeforeSubmission
1319 }
1320 GatewayAccountMode::Test => {
1321 PaymentResolutionCode::GatewayTestReadinessFailedBeforeSubmission
1322 }
1323 })
1324 }
1325
1326 const fn terminal(resolution_code: PaymentResolutionCode) -> Self {
1327 Self {
1328 resolution_code,
1329 retry_safety: GatewayNotSubmittedRetrySafety::Terminal,
1330 cooldown: None,
1331 }
1332 }
1333
1334 const fn throttled(
1335 resolution_code: PaymentResolutionCode,
1336 cooldown: RateLimitCooldown,
1337 ) -> Self {
1338 Self {
1339 resolution_code,
1340 retry_safety: GatewayNotSubmittedRetrySafety::Terminal,
1341 cooldown: Some(cooldown),
1342 }
1343 }
1344
1345 const fn retryable(resolution_code: PaymentResolutionCode) -> Self {
1346 Self {
1347 resolution_code,
1348 retry_safety: GatewayNotSubmittedRetrySafety::RestorePreparedWhenSupported,
1349 cooldown: None,
1350 }
1351 }
1352
1353 pub(crate) const fn resolution_code(self) -> PaymentResolutionCode {
1354 self.resolution_code
1355 }
1356
1357 pub(crate) const fn cooldown(self) -> Option<RateLimitCooldown> {
1358 self.cooldown
1359 }
1360
1361 pub(crate) const fn restores_prepared_attempt_when_supported(self) -> bool {
1365 matches!(
1366 self.retry_safety,
1367 GatewayNotSubmittedRetrySafety::RestorePreparedWhenSupported
1368 )
1369 }
1370}
1371
1372async fn persist_approved_evidence_without_attempt_lock(
1373 pool: &PgPool,
1374 terms: LockFreeApprovedEvidenceTerms<'_>,
1375 evidence: &ProcessorEvidence,
1376) -> Result<(), SubscriptionEnrollmentApplicationError> {
1377 match crate::processor_charges::persist_approved_evidence_without_attempt_lock(
1378 pool, terms, evidence,
1379 )
1380 .await?
1381 {
1382 LockFreeApprovedEvidenceOutcome::Persisted
1383 | LockFreeApprovedEvidenceOutcome::ExactReplay
1384 | LockFreeApprovedEvidenceOutcome::OwnedByOtherAttempt => Ok(()),
1385 LockFreeApprovedEvidenceOutcome::NotDurable => {
1386 Err(SubscriptionEnrollmentApplicationError::ApprovedEvidenceNotDurable)
1387 }
1388 }
1389}
1390
1391fn is_retryable_evidence_error(error: &SubscriptionEnrollmentApplicationError) -> bool {
1392 let sqlstate = match error {
1393 SubscriptionEnrollmentApplicationError::Sql(sqlx::Error::Database(error)) => error.code(),
1394 SubscriptionEnrollmentApplicationError::Attempt(PaymentAttemptStoreError::Sql(
1395 sqlx::Error::Database(error),
1396 )) => error.code(),
1397 _ => None,
1398 };
1399 matches!(
1400 sqlstate.as_deref(),
1401 Some("40001" | "40P01" | "55P03" | "57014")
1402 )
1403}
1404
1405pub(crate) async fn set_application_timeouts(
1406 connection: &mut PgConnection,
1407) -> Result<(), sqlx::Error> {
1408 sqlx::query(
1409 "SELECT set_config('lock_timeout', $1, true), set_config('statement_timeout', $2, true)",
1410 )
1411 .bind(BILLING_ROW_LOCK_TIMEOUT)
1412 .bind(BILLING_OPERATION_TIMEOUT)
1413 .execute(connection)
1414 .await?;
1415 Ok(())
1416}
1417
1418async fn lock_payment_method_domain(
1419 connection: &mut PgConnection,
1420 subscriber_id: SubscriberId,
1421 gateway_account_id: &Uuid,
1422) -> Result<(), sqlx::Error> {
1423 sqlx::query(
1424 "SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':' || $2::uuid::text, 0))",
1425 )
1426 .bind(gateway_account_id)
1427 .bind(subscriber_id.as_uuid())
1428 .execute(connection)
1429 .await?;
1430 Ok(())
1431}
1432
1433async fn lock_subscription_aggregate(
1434 connection: &mut PgConnection,
1435 subscriber_id: SubscriberId,
1436 plan_key: &PlanKey,
1437) -> Result<(), sqlx::Error> {
1438 sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':' || $2, 0))")
1439 .bind(subscriber_id.as_uuid())
1440 .bind(plan_key.as_str())
1441 .execute(connection)
1442 .await?;
1443 Ok(())
1444}
1445
1446async fn upsert_payment_method(
1447 connection: &mut PgConnection,
1448 attempt: &PaymentAttempt,
1449 evidence: &ProcessorEvidence,
1450) -> Result<PaymentMethodId, SubscriptionEnrollmentApplicationError> {
1451 let identity = attempt.identity();
1452 let reference = evidence.payment_method_reference().ok_or(
1453 SubscriptionEnrollmentApplicationError::InvalidState(INVALID_APPLICATION_STATE),
1454 )?;
1455 let descriptor = evidence.descriptor();
1456 let row_id: Uuid = sqlx::query_scalar(
1457 r#"
1458 INSERT INTO billing_payment_methods (
1459 id, billing_scope_id, subscriber_id, gateway_account_id,
1460 gateway_payment_method_reference, status, payment_type, card_brand,
1461 card_last4, card_exp_month, card_exp_year, billing_name, billing_email
1462 ) VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8, $9, $10, $11, $12)
1463 ON CONFLICT (gateway_account_id, subscriber_id, gateway_payment_method_reference)
1464 DO UPDATE SET status = 'active', payment_type = EXCLUDED.payment_type,
1465 card_brand = EXCLUDED.card_brand, card_last4 = EXCLUDED.card_last4,
1466 card_exp_month = EXCLUDED.card_exp_month,
1467 card_exp_year = EXCLUDED.card_exp_year,
1468 billing_name = EXCLUDED.billing_name,
1469 billing_email = EXCLUDED.billing_email,
1470 updated_at = clock_timestamp()
1471 RETURNING id
1472 "#,
1473 )
1474 .bind(Uuid::now_v7())
1475 .bind(identity.billing_scope_id().as_uuid())
1476 .bind(identity.subscriber_id().as_uuid())
1477 .bind(identity.gateway_account_id().as_uuid())
1478 .bind(reference.expose())
1479 .bind(descriptor.payment_type().map(GatewayDiagnostic::expose))
1480 .bind(descriptor.card_brand().map(GatewayDiagnostic::expose))
1481 .bind(descriptor.card_last_four().map(|value| value.expose()))
1482 .bind(descriptor.card_exp_month())
1483 .bind(descriptor.card_exp_year())
1484 .bind(attempt.request().billing_contact().name())
1485 .bind(attempt.request().billing_contact().email())
1486 .fetch_one(connection)
1487 .await?;
1488 Ok(PaymentMethodId::new(row_id))
1489}
1490
1491async fn mark_attempt_approved(
1492 connection: &mut PgConnection,
1493 attempt: &PaymentAttempt,
1494 evidence: &ProcessorEvidence,
1495 subscription_id: SubscriptionId,
1496 method_id: PaymentMethodId,
1497) -> Result<(), SubscriptionEnrollmentApplicationError> {
1498 persist_attempt_transition(
1499 connection,
1500 attempt,
1501 evidence,
1502 AttemptTransition::Approved(AttemptApproval::Subscription {
1503 subscription_id,
1504 payment_method_id: method_id,
1505 }),
1506 )
1507 .await
1508 .map_err(map_attempt_transition_error)
1509}
1510
1511pub(crate) async fn park_locked_attempt(
1512 connection: &mut PgConnection,
1513 attempt: &PaymentAttempt,
1514 evidence: &ProcessorEvidence,
1515 resolution_code: Option<PaymentResolutionCode>,
1516 message: &'static str,
1517) -> Result<PaymentAttempt, SubscriptionEnrollmentApplicationError> {
1518 persist_attempt_transition(
1519 connection,
1520 attempt,
1521 evidence,
1522 AttemptTransition::LateApprovalReview {
1523 resolution_code,
1524 message,
1525 },
1526 )
1527 .await
1528 .map_err(map_attempt_transition_error)?;
1529 find_payment_attempt_by_id_on_connection(
1530 connection,
1531 attempt.identity().billing_scope_id(),
1532 attempt.identity().attempt_id(),
1533 )
1534 .await?
1535 .ok_or(SubscriptionEnrollmentApplicationError::InvalidState(
1536 INVALID_APPLICATION_STATE,
1537 ))
1538}
1539
1540pub(crate) async fn payment_result_for_attempt(
1541 connection: &mut PgConnection,
1542 attempt: PaymentAttempt,
1543) -> Result<SubscriptionEnrollmentPaymentResult, SubscriptionEnrollmentApplicationError> {
1544 if attempt.status() == PaymentAttemptStatus::Approved {
1545 let subscription = load_applied_subscription(connection, &attempt)
1546 .await?
1547 .ok_or(SubscriptionEnrollmentApplicationError::InvalidState(
1548 INVALID_APPLICATION_STATE,
1549 ))?;
1550 Ok(SubscriptionEnrollmentPaymentResult::applied(
1551 attempt,
1552 subscription,
1553 )?)
1554 } else {
1555 Ok(SubscriptionEnrollmentPaymentResult::not_applied(attempt)?)
1556 }
1557}
1558
1559async fn load_applied_subscription(
1560 connection: &mut PgConnection,
1561 attempt: &PaymentAttempt,
1562) -> Result<Option<Subscription>, SubscriptionEnrollmentApplicationError> {
1563 let Some(subscription_id) = attempt.request().target().subscription_id() else {
1564 return Ok(None);
1565 };
1566 load_subscription(
1567 connection,
1568 attempt.identity().billing_scope_id(),
1569 subscription_id,
1570 )
1571 .await
1572}
1573
1574async fn load_subscription(
1575 connection: &mut PgConnection,
1576 billing_scope_id: BillingScopeId,
1577 subscription_id: SubscriptionId,
1578) -> Result<Option<Subscription>, SubscriptionEnrollmentApplicationError> {
1579 let row = sqlx::query(
1580 r#"
1581 SELECT id, plan_key, status, payment_method_id, amount_cents, currency,
1582 current_period_start_at, current_period_end_at, next_renewal_at,
1583 phase, recurring_period_kind, recurring_period_count,
1584 dunning_retry_delays_seconds, dunning_exhaustion, past_due_access,
1585 next_payment_attempt_at, required_gateway_account_mode
1586 FROM billing_subscriptions
1587 WHERE billing_scope_id = $1 AND id = $2
1588 "#,
1589 )
1590 .bind(billing_scope_id.as_uuid())
1591 .bind(subscription_id.as_uuid())
1592 .fetch_optional(connection)
1593 .await?;
1594 row.map(|row| decode_subscription_row(&row).map_err(map_subscription_persistence_error))
1595 .transpose()
1596}
1597
1598#[cfg(test)]
1599mod tests;