1use std::fmt;
2
3use chrono::{DateTime, Utc};
4use sqlx::{PgConnection, Postgres, Row, Transaction, postgres::PgRow};
5use syrup_rail::{
6 BillingContactSnapshot, BillingPeriod, BillingScopeId, ChargeAmount, CumulativeRefundCents,
7 CurrencyCode, DiscountClaimId, DiscountCodeId, GatewayAccountId, GatewayConfigurationId,
8 GatewayDiagnostic, GatewayLifecycleState, GatewayOrderId, GatewayPaymentDescriptor,
9 GatewayPaymentMethodReference, GatewayTransactionId, HostChargeTargetId, IdempotencyKey,
10 LimitedDiscountMonths, Money, PaymentAttempt, PaymentAttemptFingerprint, PaymentAttemptId,
11 PaymentAttemptIdentity, PaymentAttemptKind, PaymentAttemptLifecycle, PaymentAttemptRequest,
12 PaymentAttemptState, PaymentAttemptStatus, PaymentAttemptTarget, PaymentAttemptTimestamps,
13 PaymentMethodId, PaymentMethodUpdateSnapshot, PaymentResolutionCode, PercentOffBasisPoints,
14 PlanKey, PositiveDiscountCents, ProcessorEvidence, SubscriberId, SubscriptionDiscountCode,
15 SubscriptionDiscountDuration, SubscriptionDiscountKind, SubscriptionDiscountSnapshot,
16 SubscriptionEnrollmentDiscountSnapshot, SubscriptionEnrollmentPreflightOutcome,
17 SubscriptionEnrollmentReservation, SubscriptionEnrollmentReservationOutcome,
18 SubscriptionEnrollmentReservationRejection, SubscriptionEnrollmentSubmissionOutcome,
19 SubscriptionEnrollmentSubmissionRejection, SubscriptionId, SubscriptionInitialApplication,
20 SubscriptionPaymentMethodReplacement, SubscriptionPaymentMethodReplacementPreflightOutcome,
21 SubscriptionPaymentMethodReplacementRejection,
22 SubscriptionPaymentMethodReplacementReservationOutcome,
23 SubscriptionPaymentMethodReplacementSubmissionOutcome,
24 SubscriptionPaymentMethodReplacementSubmissionRejection, SubscriptionPaymentStateSnapshot,
25 SubscriptionRecoveryPreflightOutcome, SubscriptionRecoveryReservation,
26 SubscriptionRecoveryReservationOutcome, SubscriptionRecoveryReservationRejection,
27 SubscriptionRecoverySubmissionOutcome, SubscriptionRecoverySubmissionRejection,
28 SubscriptionRenewalReservation, SubscriptionRenewalReservationOutcome,
29 SubscriptionRenewalReservationRejection, SubscriptionRenewalSubmissionOutcome,
30 SubscriptionRenewalSubmissionRejection, SubscriptionStatus,
31};
32use thiserror::Error;
33use uuid::Uuid;
34
35const INVALID_ATTEMPT_STATE: &str = "canonical payment attempt state is invalid";
36const BILLING_ROW_LOCK_TIMEOUT: &str = "250ms";
37const BILLING_OPERATION_TIMEOUT: &str = "5s";
38const INITIAL_PREPARED_STALE_AFTER_SECONDS: i64 = 30 * 60;
39const INITIAL_PREPARED_EXPIRED_TEXT: &str =
40 "Prepared checkout expired before processor submission.";
41const INITIAL_BILLING_STATE_CHANGED_TEXT: &str =
42 "Checkout was canceled before submission because billing state changed.";
43const INITIAL_TERMS_CHANGED_TEXT: &str =
44 "Checkout was canceled before submission because enrollment terms changed.";
45const INITIAL_CONFIGURATION_CHANGED_TEXT: &str =
46 "Checkout was canceled before submission because payment configuration changed.";
47const RECOVERY_STATE_CHANGED_TEXT: &str =
48 "Subscription recovery was canceled before submission because billing state changed.";
49const RECOVERY_CONFIGURATION_CHANGED_TEXT: &str =
50 "Subscription recovery was canceled before submission because payment configuration changed.";
51const RENEWAL_STATE_CHANGED_TEXT: &str =
52 "Subscription renewal was canceled before submission because billing state changed.";
53const RENEWAL_CONFIGURATION_CHANGED_TEXT: &str =
54 "Subscription renewal was canceled before submission because payment configuration changed.";
55const PAYMENT_METHOD_REPLACEMENT_STATE_CHANGED_TEXT: &str =
56 "Payment method replacement was canceled before submission because billing state changed.";
57const PAYMENT_METHOD_REPLACEMENT_CONFIGURATION_CHANGED_TEXT: &str = "Payment method replacement was canceled before submission because payment configuration changed.";
58const PAYMENT_METHOD_UPDATE_UNSUBMITTED_STALE_AFTER_SECONDS: i64 = 3 * 60;
59
60pub(crate) const PAYMENT_ATTEMPT_SELECT: &str = r#"
61 SELECT id, billing_scope_id, subscriber_id, plan_key,
62 host_charge_target_id, subscription_id, payment_method_id,
63 attempt_kind, status, idempotency_key, request_fingerprint,
64 amount_cents, currency, billing_period_start_at,
65 billing_period_end_at, gateway_account_id,
66 gateway_configuration_id, gateway_order_id,
67 gateway_transaction_id, gateway_payment_method_reference,
68 gateway_response, gateway_response_code, gateway_response_text,
69 gateway_condition, payment_type, card_brand, card_last4,
70 card_exp_month, card_exp_year, submitted_at, resolved_at,
71 created_at, updated_at, gateway_lifecycle_status,
72 gateway_lifecycle_action, gateway_lifecycle_at,
73 gateway_lifecycle_reconciled_at, refunded_amount_cents,
74 billing_name, billing_email, resolution_code, review_required_at,
75 payment_method_update_expected_payment_method_id,
76 payment_method_update_expected_initial_transaction_id,
77 subscription_expected_payment_method_id,
78 subscription_expected_initial_transaction_id,
79 subscription_expected_status,
80 subscription_initial_discount_claim_id,
81 subscription_initial_discount_code_id,
82 subscription_initial_discount_code_snapshot,
83 subscription_initial_discount_label_snapshot,
84 subscription_initial_discount_kind,
85 subscription_initial_discount_amount_off_cents,
86 subscription_initial_discount_percent_off_bps,
87 subscription_initial_discount_currency,
88 subscription_initial_discount_duration,
89 subscription_initial_discount_duration_months,
90 subscription_initial_discount_base_amount_cents,
91 subscription_initial_discount_discounted_amount_cents
92 FROM billing_payment_attempts
93"#;
94
95#[derive(Error)]
96pub enum PaymentAttemptStoreError {
97 #[error("payment attempt storage operation failed")]
98 Sql(#[from] sqlx::Error),
99 #[error("{0}")]
100 InvalidState(&'static str),
101}
102
103impl fmt::Debug for PaymentAttemptStoreError {
104 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 Self::Sql(_) => formatter.write_str("PaymentAttemptStoreError::Sql"),
107 Self::InvalidState(detail) => formatter
108 .debug_tuple("PaymentAttemptStoreError::InvalidState")
109 .field(detail)
110 .finish(),
111 }
112 }
113}
114
115pub async fn reserve_subscription_enrollment_in_transaction(
122 transaction: &mut Transaction<'_, Postgres>,
123 offers: &dyn crate::SubscriptionOfferStore,
124 reservation: &SubscriptionEnrollmentReservation,
125) -> Result<SubscriptionEnrollmentReservationOutcome, PaymentAttemptStoreError> {
126 set_enrollment_timeouts(transaction).await?;
127 let identity = reservation.identity();
128 lock_subscription_aggregate(
129 transaction,
130 identity.subscriber_id(),
131 reservation.plan_key(),
132 )
133 .await?;
134
135 if let Some(existing) = payment_attempt_by_idempotency(
136 transaction,
137 identity.billing_scope_id(),
138 identity.subscriber_id(),
139 reservation.idempotency_key(),
140 false,
141 )
142 .await?
143 {
144 if !replay_matches_reservation(&existing, reservation) {
145 return Ok(SubscriptionEnrollmentReservationOutcome::IdempotencyConflict);
146 }
147 if attempt_is_already_replayable(&existing) {
148 return Ok(SubscriptionEnrollmentReservationOutcome::Replay(existing));
149 }
150 if initial_attempt_is_stale(transaction, existing.identity().attempt_id()).await? {
151 lock_initial_attempt_rows(
152 transaction,
153 identity.billing_scope_id(),
154 identity.subscriber_id(),
155 reservation.plan_key(),
156 )
157 .await?;
158 lock_initial_charge_rows(
159 transaction,
160 identity.billing_scope_id(),
161 identity.subscriber_id(),
162 reservation.plan_key(),
163 )
164 .await?;
165 expire_stale_initial_attempts(
166 transaction,
167 identity.billing_scope_id(),
168 identity.subscriber_id(),
169 reservation.plan_key(),
170 )
171 .await?;
172 let expired = payment_attempt_by_idempotency(
173 transaction,
174 identity.billing_scope_id(),
175 identity.subscriber_id(),
176 reservation.idempotency_key(),
177 true,
178 )
179 .await?
180 .ok_or_else(invalid_state)?;
181 return Ok(SubscriptionEnrollmentReservationOutcome::Replay(expired));
182 }
183 }
184
185 let Some(offer) = offers
186 .lock_current_offer(
187 transaction,
188 identity.billing_scope_id(),
189 reservation.plan_key(),
190 )
191 .await?
192 else {
193 return Ok(SubscriptionEnrollmentReservationOutcome::Rejected(
194 SubscriptionEnrollmentReservationRejection::EnrollmentTermsChanged,
195 ));
196 };
197 if offer.plan_key() != reservation.plan_key() {
198 return Ok(SubscriptionEnrollmentReservationOutcome::Rejected(
199 SubscriptionEnrollmentReservationRejection::EnrollmentTermsChanged,
200 ));
201 }
202 let saved_claim = crate::saved_subscription_discount_claim_in_transaction(
203 transaction,
204 identity.billing_scope_id(),
205 identity.subscriber_id(),
206 reservation.plan_key(),
207 )
208 .await
209 .map_err(map_discount_error)?;
210
211 lock_initial_attempt_rows(
212 transaction,
213 identity.billing_scope_id(),
214 identity.subscriber_id(),
215 reservation.plan_key(),
216 )
217 .await?;
218 lock_initial_charge_rows(
219 transaction,
220 identity.billing_scope_id(),
221 identity.subscriber_id(),
222 reservation.plan_key(),
223 )
224 .await?;
225 expire_stale_initial_attempts(
226 transaction,
227 identity.billing_scope_id(),
228 identity.subscriber_id(),
229 reservation.plan_key(),
230 )
231 .await?;
232
233 if let Some(existing) = payment_attempt_by_idempotency(
234 transaction,
235 identity.billing_scope_id(),
236 identity.subscriber_id(),
237 reservation.idempotency_key(),
238 true,
239 )
240 .await?
241 && attempt_is_already_replayable(&existing)
242 {
243 return Ok(if replay_matches_reservation(&existing, reservation) {
244 SubscriptionEnrollmentReservationOutcome::Replay(existing)
245 } else {
246 SubscriptionEnrollmentReservationOutcome::IdempotencyConflict
247 });
248 }
249
250 if current_subscription_exists(
251 transaction,
252 identity.billing_scope_id(),
253 identity.subscriber_id(),
254 reservation.plan_key(),
255 )
256 .await?
257 {
258 return Ok(SubscriptionEnrollmentReservationOutcome::Rejected(
259 SubscriptionEnrollmentReservationRejection::CurrentSubscription,
260 ));
261 }
262 if active_grant_exists(
263 transaction,
264 identity.billing_scope_id(),
265 identity.subscriber_id(),
266 reservation.plan_key(),
267 )
268 .await?
269 {
270 return Ok(SubscriptionEnrollmentReservationOutcome::Rejected(
271 SubscriptionEnrollmentReservationRejection::ActiveGrant,
272 ));
273 }
274 if unresolved_initial_charge_exists(
275 transaction,
276 identity.billing_scope_id(),
277 identity.subscriber_id(),
278 reservation.plan_key(),
279 )
280 .await?
281 {
282 return Ok(SubscriptionEnrollmentReservationOutcome::Rejected(
283 SubscriptionEnrollmentReservationRejection::UnresolvedProcessorCharge,
284 ));
285 }
286 let Some(request) =
287 enrollment_request_from_locked_terms(reservation, &offer, saved_claim.as_ref())
288 else {
289 return Ok(SubscriptionEnrollmentReservationOutcome::Rejected(
290 SubscriptionEnrollmentReservationRejection::EnrollmentTermsChanged,
291 ));
292 };
293 if !gateway_identity_matches(transaction, reservation).await? {
294 return Ok(SubscriptionEnrollmentReservationOutcome::Rejected(
295 SubscriptionEnrollmentReservationRejection::GatewayConfigurationChanged,
296 ));
297 }
298
299 if let Some(existing) = payment_attempt_by_idempotency(
300 transaction,
301 identity.billing_scope_id(),
302 identity.subscriber_id(),
303 reservation.idempotency_key(),
304 true,
305 )
306 .await?
307 {
308 return Ok(
309 if pending_attempt_matches_request(&existing, identity, &request) {
310 SubscriptionEnrollmentReservationOutcome::Replay(existing)
311 } else {
312 SubscriptionEnrollmentReservationOutcome::IdempotencyConflict
313 },
314 );
315 }
316 if blocking_initial_attempt_exists(
317 transaction,
318 identity.billing_scope_id(),
319 identity.subscriber_id(),
320 reservation.plan_key(),
321 )
322 .await?
323 {
324 return Ok(SubscriptionEnrollmentReservationOutcome::Rejected(
325 SubscriptionEnrollmentReservationRejection::AttemptInProgress,
326 ));
327 }
328
329 let inserted = insert_initial_attempt(transaction, identity, &request).await?;
330 if inserted {
331 let attempt = payment_attempt_by_idempotency(
332 transaction,
333 identity.billing_scope_id(),
334 identity.subscriber_id(),
335 reservation.idempotency_key(),
336 true,
337 )
338 .await?
339 .ok_or_else(invalid_state)?;
340 return Ok(SubscriptionEnrollmentReservationOutcome::Reserved(attempt));
341 }
342 let existing = payment_attempt_by_idempotency(
343 transaction,
344 identity.billing_scope_id(),
345 identity.subscriber_id(),
346 reservation.idempotency_key(),
347 true,
348 )
349 .await?
350 .ok_or_else(invalid_state)?;
351 Ok(
352 if pending_attempt_matches_request(&existing, identity, &request) {
353 SubscriptionEnrollmentReservationOutcome::Replay(existing)
354 } else {
355 SubscriptionEnrollmentReservationOutcome::IdempotencyConflict
356 },
357 )
358}
359
360pub async fn preflight_subscription_enrollment_in_transaction(
367 transaction: &mut Transaction<'_, Postgres>,
368 command: &syrup_rail::EnrollSubscription,
369) -> Result<SubscriptionEnrollmentPreflightOutcome, PaymentAttemptStoreError> {
370 set_enrollment_timeouts(transaction).await?;
371 let Some(existing) = payment_attempt_by_idempotency(
372 transaction,
373 command.billing_scope_id(),
374 command.subscriber_id(),
375 command.idempotency_key(),
376 false,
377 )
378 .await?
379 else {
380 return Ok(SubscriptionEnrollmentPreflightOutcome::Continue);
381 };
382 if !replay_matches_command(&existing, command) {
383 return Ok(SubscriptionEnrollmentPreflightOutcome::IdempotencyConflict);
384 }
385 if attempt_is_already_replayable(&existing) {
386 return Ok(SubscriptionEnrollmentPreflightOutcome::Replay(Box::new(
387 existing,
388 )));
389 }
390
391 lock_subscription_aggregate(transaction, command.subscriber_id(), command.plan_key()).await?;
392 let existing = payment_attempt_by_idempotency(
393 transaction,
394 command.billing_scope_id(),
395 command.subscriber_id(),
396 command.idempotency_key(),
397 true,
398 )
399 .await?
400 .ok_or_else(invalid_state)?;
401 if !replay_matches_command(&existing, command) {
402 return Ok(SubscriptionEnrollmentPreflightOutcome::IdempotencyConflict);
403 }
404 if attempt_is_already_replayable(&existing) {
405 return Ok(SubscriptionEnrollmentPreflightOutcome::Replay(Box::new(
406 existing,
407 )));
408 }
409 if !initial_attempt_is_stale(transaction, existing.identity().attempt_id()).await? {
410 return Ok(SubscriptionEnrollmentPreflightOutcome::Continue);
411 }
412
413 lock_initial_attempt_rows(
414 transaction,
415 command.billing_scope_id(),
416 command.subscriber_id(),
417 command.plan_key(),
418 )
419 .await?;
420 lock_initial_charge_rows(
421 transaction,
422 command.billing_scope_id(),
423 command.subscriber_id(),
424 command.plan_key(),
425 )
426 .await?;
427 expire_stale_initial_attempts(
428 transaction,
429 command.billing_scope_id(),
430 command.subscriber_id(),
431 command.plan_key(),
432 )
433 .await?;
434 let expired = payment_attempt_by_idempotency(
435 transaction,
436 command.billing_scope_id(),
437 command.subscriber_id(),
438 command.idempotency_key(),
439 true,
440 )
441 .await?
442 .ok_or_else(invalid_state)?;
443 Ok(SubscriptionEnrollmentPreflightOutcome::Replay(Box::new(
444 expired,
445 )))
446}
447
448pub async fn admit_subscription_enrollment_submission_in_transaction(
453 transaction: &mut Transaction<'_, Postgres>,
454 offers: &dyn crate::SubscriptionOfferStore,
455 reservation: &SubscriptionEnrollmentReservation,
456) -> Result<SubscriptionEnrollmentSubmissionOutcome, PaymentAttemptStoreError> {
457 set_enrollment_timeouts(transaction).await?;
458 let identity = reservation.identity();
459 lock_subscription_aggregate(
460 transaction,
461 identity.subscriber_id(),
462 reservation.plan_key(),
463 )
464 .await?;
465
466 let Some(offer) = offers
467 .lock_current_offer(
468 transaction,
469 identity.billing_scope_id(),
470 reservation.plan_key(),
471 )
472 .await?
473 else {
474 return reject_prepared_initial(
475 transaction,
476 reservation,
477 SubscriptionEnrollmentSubmissionRejection::EnrollmentTermsChanged,
478 INITIAL_TERMS_CHANGED_TEXT,
479 )
480 .await;
481 };
482 let saved_claim = crate::saved_subscription_discount_claim_in_transaction(
483 transaction,
484 identity.billing_scope_id(),
485 identity.subscriber_id(),
486 reservation.plan_key(),
487 )
488 .await
489 .map_err(map_discount_error)?;
490 let attempt = payment_attempt_by_idempotency(
491 transaction,
492 identity.billing_scope_id(),
493 identity.subscriber_id(),
494 reservation.idempotency_key(),
495 true,
496 )
497 .await?
498 .ok_or_else(invalid_state)?;
499 if !attempt_identity_matches_requested_gateway(&attempt, identity)
500 || attempt.kind() != PaymentAttemptKind::SubscriptionInitial
501 || attempt.request().target().plan_key() != Some(reservation.plan_key())
502 {
503 return Err(invalid_state());
504 }
505 if attempt.state().timestamps().submitted_at().is_some()
506 || attempt.status() != PaymentAttemptStatus::Pending
507 {
508 return Ok(SubscriptionEnrollmentSubmissionOutcome::AlreadyAdmitted(
509 attempt,
510 ));
511 }
512
513 lock_initial_charge_rows(
514 transaction,
515 identity.billing_scope_id(),
516 identity.subscriber_id(),
517 reservation.plan_key(),
518 )
519 .await?;
520 if current_subscription_exists(
521 transaction,
522 identity.billing_scope_id(),
523 identity.subscriber_id(),
524 reservation.plan_key(),
525 )
526 .await?
527 || active_grant_exists(
528 transaction,
529 identity.billing_scope_id(),
530 identity.subscriber_id(),
531 reservation.plan_key(),
532 )
533 .await?
534 || unresolved_initial_charge_exists(
535 transaction,
536 identity.billing_scope_id(),
537 identity.subscriber_id(),
538 reservation.plan_key(),
539 )
540 .await?
541 {
542 return reject_locked_prepared_initial(
543 transaction,
544 attempt,
545 SubscriptionEnrollmentSubmissionRejection::BillingStateChanged,
546 INITIAL_BILLING_STATE_CHANGED_TEXT,
547 )
548 .await;
549 }
550 let Some(expected_request) =
551 enrollment_request_from_locked_terms(reservation, &offer, saved_claim.as_ref())
552 else {
553 return reject_locked_prepared_initial(
554 transaction,
555 attempt,
556 SubscriptionEnrollmentSubmissionRejection::EnrollmentTermsChanged,
557 INITIAL_TERMS_CHANGED_TEXT,
558 )
559 .await;
560 };
561 if !pending_attempt_matches_request(&attempt, identity, &expected_request) {
562 return reject_locked_prepared_initial(
563 transaction,
564 attempt,
565 SubscriptionEnrollmentSubmissionRejection::EnrollmentTermsChanged,
566 INITIAL_TERMS_CHANGED_TEXT,
567 )
568 .await;
569 }
570 if !gateway_identity_matches(transaction, reservation).await? {
571 return reject_locked_prepared_initial(
572 transaction,
573 attempt,
574 SubscriptionEnrollmentSubmissionRejection::GatewayConfigurationChanged,
575 INITIAL_CONFIGURATION_CHANGED_TEXT,
576 )
577 .await;
578 }
579
580 let attempt_id = attempt.identity().attempt_id();
581 sqlx::query(
582 r#"
583 UPDATE billing_payment_attempts
584 SET submitted_at = clock_timestamp(), updated_at = clock_timestamp()
585 WHERE id = $1 AND status = 'pending' AND submitted_at IS NULL
586 "#,
587 )
588 .bind(attempt_id.as_uuid())
589 .execute(&mut **transaction)
590 .await?;
591 let admitted = find_payment_attempt_by_id_in_transaction(
592 transaction,
593 identity.billing_scope_id(),
594 attempt_id,
595 )
596 .await?
597 .ok_or_else(invalid_state)?;
598 Ok(SubscriptionEnrollmentSubmissionOutcome::Admitted(admitted))
599}
600
601pub async fn reserve_subscription_renewal_in_transaction(
603 transaction: &mut Transaction<'_, Postgres>,
604 command: syrup_rail::ChargeRenewal,
605 gateway: &syrup_rail::ResolvedGateway,
606) -> Result<SubscriptionRenewalReservationOutcome, PaymentAttemptStoreError> {
607 set_enrollment_timeouts(transaction).await?;
608 let locator = sqlx::query_as::<_, (Uuid, String)>(
609 r#"
610 SELECT subscriber_id, plan_key
611 FROM billing_subscriptions
612 WHERE billing_scope_id = $1 AND id = $2
613 "#,
614 )
615 .bind(command.billing_scope_id().as_uuid())
616 .bind(command.subscription_id().as_uuid())
617 .fetch_optional(&mut **transaction)
618 .await?;
619 let Some((subscriber_id, plan_key)) = locator else {
620 return Ok(SubscriptionRenewalReservationOutcome::Rejected(
621 SubscriptionRenewalReservationRejection::SubscriptionNotFound,
622 ));
623 };
624 let subscriber_id = SubscriberId::new(subscriber_id);
625 let plan_key = PlanKey::new(plan_key).map_err(|_| invalid_state())?;
626 lock_subscription_aggregate(transaction, subscriber_id, &plan_key).await?;
627
628 let row = sqlx::query(
629 r#"
630 SELECT subscriber_id, plan_key, gateway_account_id, payment_method_id,
631 amount_cents, currency, next_renewal_at, initial_transaction_id, status,
632 next_renewal_at <= clock_timestamp() AS is_due
633 FROM billing_subscriptions
634 WHERE billing_scope_id = $1 AND id = $2
635 FOR SHARE
636 "#,
637 )
638 .bind(command.billing_scope_id().as_uuid())
639 .bind(command.subscription_id().as_uuid())
640 .fetch_optional(&mut **transaction)
641 .await?;
642 let Some(row) = row else {
643 return Ok(SubscriptionRenewalReservationOutcome::Rejected(
644 SubscriptionRenewalReservationRejection::SubscriptionNotFound,
645 ));
646 };
647 let status_value: String = row.try_get("status")?;
648 let period_start_at: DateTime<Utc> = row.try_get("next_renewal_at")?;
649 if !matches!(status_value.as_str(), "active" | "past_due")
650 || period_start_at != *command.period_start_at()
651 || !row.try_get::<bool, _>("is_due")?
652 {
653 return Ok(SubscriptionRenewalReservationOutcome::Rejected(
654 SubscriptionRenewalReservationRejection::PaymentNotDue,
655 ));
656 }
657 if row.try_get::<Uuid, _>("subscriber_id")? != subscriber_id.into_uuid()
658 || row.try_get::<String, _>("plan_key")? != plan_key.as_str()
659 {
660 return Err(invalid_state());
661 }
662
663 fail_stale_unsubmitted_payment_method_updates(transaction, command.subscription_id()).await?;
664 let gateway_account_id = GatewayAccountId::new(row.try_get("gateway_account_id")?);
665 if gateway_account_id != gateway.gateway_account_id()
666 || !gateway_identity_matches_renewal(transaction, command, gateway).await?
667 {
668 return Ok(SubscriptionRenewalReservationOutcome::Rejected(
669 SubscriptionRenewalReservationRejection::GatewayConfigurationChanged,
670 ));
671 }
672 if blocking_subscription_charge_attempt_exists(transaction, command.subscription_id()).await? {
673 return Ok(SubscriptionRenewalReservationOutcome::Rejected(
674 SubscriptionRenewalReservationRejection::AttemptInProgress,
675 ));
676 }
677 if blocking_payment_method_update_exists(transaction, command.subscription_id()).await? {
678 return Ok(SubscriptionRenewalReservationOutcome::Rejected(
679 SubscriptionRenewalReservationRejection::PaymentMethodUpdateInProgress,
680 ));
681 }
682 let attempt_state = crate::renewal_attempt_state(
683 transaction,
684 command.subscription_id(),
685 *command.period_start_at(),
686 None,
687 )
688 .await
689 .map_err(map_renewal_store_error)?;
690 let now: DateTime<Utc> = sqlx::query_scalar("SELECT clock_timestamp()")
691 .fetch_one(&mut **transaction)
692 .await?;
693 if attempt_state.blocks_automatic_retry(now) {
694 return Ok(SubscriptionRenewalReservationOutcome::Rejected(
695 SubscriptionRenewalReservationRejection::RetryBlocked,
696 ));
697 }
698
699 let currency_value: String = row.try_get("currency")?;
700 let currency = CurrencyCode::new(¤cy_value).map_err(|_| invalid_state())?;
701 let charge =
702 ChargeAmount::new(row.try_get("amount_cents")?, currency).map_err(|_| invalid_state())?;
703 let transaction_value: String = row.try_get("initial_transaction_id")?;
704 let initial_transaction_id =
705 GatewayTransactionId::new(transaction_value).map_err(|_| invalid_state())?;
706 let status = status_value
707 .parse::<SubscriptionStatus>()
708 .map_err(|_| invalid_state())?;
709 let period =
710 syrup_rail::next_monthly_billing_period(period_start_at).map_err(|_| invalid_state())?;
711 let reservation = SubscriptionRenewalReservation::from_locked_subscription(
712 command,
713 gateway,
714 PaymentAttemptId::new(Uuid::now_v7()),
715 subscriber_id,
716 plan_key,
717 PaymentMethodId::new(row.try_get("payment_method_id")?),
718 initial_transaction_id,
719 status,
720 period,
721 charge,
722 attempt_state.attempt_sequence_count,
723 )
724 .map_err(|_| invalid_state())?;
725 if !insert_renewal_attempt(transaction, &reservation).await? {
726 return Ok(SubscriptionRenewalReservationOutcome::Rejected(
727 SubscriptionRenewalReservationRejection::AttemptInProgress,
728 ));
729 }
730 let attempt = find_payment_attempt_by_id_in_transaction(
731 transaction,
732 reservation.identity().billing_scope_id(),
733 reservation.identity().attempt_id(),
734 )
735 .await?
736 .ok_or_else(invalid_state)?;
737 Ok(SubscriptionRenewalReservationOutcome::Reserved(
738 Box::new(reservation),
739 Box::new(attempt),
740 ))
741}
742
743pub async fn admit_subscription_renewal_submission_in_transaction(
745 transaction: &mut Transaction<'_, Postgres>,
746 reservation: &SubscriptionRenewalReservation,
747) -> Result<SubscriptionRenewalSubmissionOutcome, PaymentAttemptStoreError> {
748 set_enrollment_timeouts(transaction).await?;
749 let identity = reservation.identity();
750 lock_subscription_aggregate(
751 transaction,
752 identity.subscriber_id(),
753 reservation.plan_key(),
754 )
755 .await?;
756 fail_stale_unsubmitted_payment_method_updates(transaction, reservation.subscription_id())
757 .await?;
758 let attempt = find_payment_attempt_by_id_in_transaction(
759 transaction,
760 identity.billing_scope_id(),
761 identity.attempt_id(),
762 )
763 .await?
764 .ok_or_else(invalid_state)?;
765 if !renewal_attempt_belongs_to_reservation(&attempt, reservation) {
766 return Err(invalid_state());
767 }
768 if attempt.status() != PaymentAttemptStatus::Pending
769 || attempt.state().timestamps().submitted_at().is_some()
770 {
771 return Ok(SubscriptionRenewalSubmissionOutcome::AlreadyAdmitted(
772 attempt,
773 ));
774 }
775 let retry_state = crate::renewal_attempt_state(
776 transaction,
777 reservation.subscription_id(),
778 *reservation.period().start_at(),
779 Some(identity.attempt_id()),
780 )
781 .await
782 .map_err(map_renewal_store_error)?;
783 let now: DateTime<Utc> = sqlx::query_scalar("SELECT clock_timestamp()")
784 .fetch_one(&mut **transaction)
785 .await?;
786 let state_matches = attempt.request() == reservation.request()
787 && renewal_subscription_state_matches(transaction, reservation).await?
788 && !retry_state.blocks_automatic_retry(now)
789 && !blocking_subscription_charge_attempt_exists_except(
790 transaction,
791 reservation.subscription_id(),
792 identity.attempt_id(),
793 )
794 .await?
795 && !blocking_payment_method_update_exists(transaction, reservation.subscription_id())
796 .await?;
797 if !state_matches {
798 return reject_locked_renewal(
799 transaction,
800 attempt,
801 SubscriptionRenewalSubmissionRejection::BillingStateChanged,
802 RENEWAL_STATE_CHANGED_TEXT,
803 )
804 .await;
805 }
806 if !gateway_identity_matches_renewal_reservation(transaction, reservation).await? {
807 return reject_locked_renewal(
808 transaction,
809 attempt,
810 SubscriptionRenewalSubmissionRejection::GatewayConfigurationChanged,
811 RENEWAL_CONFIGURATION_CHANGED_TEXT,
812 )
813 .await;
814 }
815 sqlx::query(
816 "UPDATE billing_payment_attempts SET submitted_at = clock_timestamp(), updated_at = clock_timestamp() WHERE id = $1 AND status = 'pending' AND submitted_at IS NULL",
817 )
818 .bind(identity.attempt_id().as_uuid())
819 .execute(&mut **transaction)
820 .await?;
821 let admitted = find_payment_attempt_by_id_in_transaction(
822 transaction,
823 identity.billing_scope_id(),
824 identity.attempt_id(),
825 )
826 .await?
827 .ok_or_else(invalid_state)?;
828 Ok(SubscriptionRenewalSubmissionOutcome::Admitted(admitted))
829}
830
831pub async fn preflight_subscription_recovery_in_transaction(
837 transaction: &mut Transaction<'_, Postgres>,
838 command: &syrup_rail::RecoverSubscriptionPayment,
839) -> Result<SubscriptionRecoveryPreflightOutcome, PaymentAttemptStoreError> {
840 set_enrollment_timeouts(transaction).await?;
841 let Some(existing) = payment_attempt_by_idempotency(
842 transaction,
843 command.billing_scope_id(),
844 command.subscriber_id(),
845 command.idempotency_key(),
846 false,
847 )
848 .await?
849 else {
850 return Ok(SubscriptionRecoveryPreflightOutcome::Continue);
851 };
852 Ok(
853 if recovery_attempt_matches_command(&existing, command)
854 && recovery_attempt_matches_replay_context(transaction, &existing).await?
855 {
856 SubscriptionRecoveryPreflightOutcome::Replay(Box::new(existing))
857 } else {
858 SubscriptionRecoveryPreflightOutcome::IdempotencyConflict
859 },
860 )
861}
862
863pub async fn reserve_subscription_recovery_in_transaction(
866 transaction: &mut Transaction<'_, Postgres>,
867 command: &syrup_rail::RecoverSubscriptionPayment,
868 gateway: &syrup_rail::ResolvedGateway,
869) -> Result<SubscriptionRecoveryReservationOutcome, PaymentAttemptStoreError> {
870 set_enrollment_timeouts(transaction).await?;
871 lock_subscription_aggregate(transaction, command.subscriber_id(), command.plan_key()).await?;
872
873 if let Some(existing) = payment_attempt_by_idempotency(
874 transaction,
875 command.billing_scope_id(),
876 command.subscriber_id(),
877 command.idempotency_key(),
878 true,
879 )
880 .await?
881 {
882 return Ok(
883 if recovery_attempt_matches_command(&existing, command)
884 && recovery_attempt_matches_replay_context(transaction, &existing).await?
885 {
886 SubscriptionRecoveryReservationOutcome::Replay(Box::new(existing))
887 } else {
888 SubscriptionRecoveryReservationOutcome::IdempotencyConflict
889 },
890 );
891 }
892
893 let row = sqlx::query(
894 r#"
895 SELECT id, gateway_account_id, payment_method_id, amount_cents, currency,
896 next_renewal_at, initial_transaction_id, status,
897 next_renewal_at <= clock_timestamp() AS is_due
898 FROM billing_subscriptions
899 WHERE billing_scope_id = $1 AND subscriber_id = $2 AND plan_key = $3
900 AND status IN ('active', 'past_due')
901 ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, updated_at DESC, id DESC
902 LIMIT 1
903 FOR UPDATE
904 "#,
905 )
906 .bind(command.billing_scope_id().as_uuid())
907 .bind(command.subscriber_id().as_uuid())
908 .bind(command.plan_key().as_str())
909 .fetch_optional(&mut **transaction)
910 .await?;
911 let Some(row) = row else {
912 return Ok(SubscriptionRecoveryReservationOutcome::Rejected(
913 SubscriptionRecoveryReservationRejection::SubscriptionNotFound,
914 ));
915 };
916 if !row.try_get::<bool, _>("is_due")? {
917 return Ok(SubscriptionRecoveryReservationOutcome::Rejected(
918 SubscriptionRecoveryReservationRejection::PaymentNotDue,
919 ));
920 }
921
922 let subscription_id = SubscriptionId::new(row.try_get("id")?);
923 fail_stale_unsubmitted_payment_method_updates(transaction, subscription_id).await?;
924 let gateway_account_id = GatewayAccountId::new(row.try_get("gateway_account_id")?);
925 if gateway_account_id != gateway.gateway_account_id()
926 || !gateway_identity_matches_recovery(transaction, command, gateway).await?
927 {
928 return Ok(SubscriptionRecoveryReservationOutcome::Rejected(
929 SubscriptionRecoveryReservationRejection::GatewayConfigurationChanged,
930 ));
931 }
932 if blocking_subscription_charge_attempt_exists(transaction, subscription_id).await? {
933 return Ok(SubscriptionRecoveryReservationOutcome::Rejected(
934 SubscriptionRecoveryReservationRejection::AttemptInProgress,
935 ));
936 }
937 if blocking_payment_method_update_exists(transaction, subscription_id).await? {
938 return Ok(SubscriptionRecoveryReservationOutcome::Rejected(
939 SubscriptionRecoveryReservationRejection::PaymentMethodUpdateInProgress,
940 ));
941 }
942
943 let payment_method_id = PaymentMethodId::new(row.try_get("payment_method_id")?);
944 let period_start_at: DateTime<Utc> = row.try_get("next_renewal_at")?;
945 let period =
946 syrup_rail::next_monthly_billing_period(period_start_at).map_err(|_| invalid_state())?;
947 let currency_value: String = row.try_get("currency")?;
948 let currency = CurrencyCode::new(¤cy_value).map_err(|_| invalid_state())?;
949 let charge =
950 ChargeAmount::new(row.try_get("amount_cents")?, currency).map_err(|_| invalid_state())?;
951 let transaction_value: String = row.try_get("initial_transaction_id")?;
952 let initial_transaction_id =
953 GatewayTransactionId::new(transaction_value).map_err(|_| invalid_state())?;
954 let status = row
955 .try_get::<String, _>("status")?
956 .parse::<SubscriptionStatus>()
957 .map_err(|_| invalid_state())?;
958 let reservation = SubscriptionRecoveryReservation::from_locked_subscription(
959 command,
960 gateway,
961 command.attempt_id(),
962 subscription_id,
963 payment_method_id,
964 initial_transaction_id,
965 status,
966 period,
967 charge,
968 )
969 .map_err(|_| invalid_state())?;
970
971 let inserted = insert_recovery_attempt(transaction, &reservation).await?;
972 if inserted {
973 let attempt = payment_attempt_by_idempotency(
974 transaction,
975 command.billing_scope_id(),
976 command.subscriber_id(),
977 command.idempotency_key(),
978 true,
979 )
980 .await?
981 .ok_or_else(invalid_state)?;
982 return Ok(SubscriptionRecoveryReservationOutcome::Reserved(
983 Box::new(reservation),
984 Box::new(attempt),
985 ));
986 }
987
988 if let Some(existing) = payment_attempt_by_idempotency(
989 transaction,
990 command.billing_scope_id(),
991 command.subscriber_id(),
992 command.idempotency_key(),
993 true,
994 )
995 .await?
996 {
997 return Ok(
998 if recovery_attempt_matches_command(&existing, command)
999 && recovery_attempt_matches_replay_context(transaction, &existing).await?
1000 {
1001 SubscriptionRecoveryReservationOutcome::Replay(Box::new(existing))
1002 } else {
1003 SubscriptionRecoveryReservationOutcome::IdempotencyConflict
1004 },
1005 );
1006 }
1007 Ok(SubscriptionRecoveryReservationOutcome::Rejected(
1008 SubscriptionRecoveryReservationRejection::AttemptInProgress,
1009 ))
1010}
1011
1012pub async fn admit_subscription_recovery_submission_in_transaction(
1015 transaction: &mut Transaction<'_, Postgres>,
1016 reservation: &SubscriptionRecoveryReservation,
1017) -> Result<SubscriptionRecoverySubmissionOutcome, PaymentAttemptStoreError> {
1018 set_enrollment_timeouts(transaction).await?;
1019 let identity = reservation.identity();
1020 lock_subscription_aggregate(
1021 transaction,
1022 identity.subscriber_id(),
1023 reservation.plan_key(),
1024 )
1025 .await?;
1026 fail_stale_unsubmitted_payment_method_updates(transaction, reservation.subscription_id())
1027 .await?;
1028 let attempt = payment_attempt_by_idempotency(
1029 transaction,
1030 identity.billing_scope_id(),
1031 identity.subscriber_id(),
1032 reservation.request().idempotency_key(),
1033 true,
1034 )
1035 .await?
1036 .ok_or_else(invalid_state)?;
1037 if !recovery_attempt_belongs_to_reservation(&attempt, reservation) {
1038 return Err(invalid_state());
1039 }
1040 if attempt.status() != PaymentAttemptStatus::Pending
1041 || attempt.state().timestamps().submitted_at().is_some()
1042 {
1043 return Ok(SubscriptionRecoverySubmissionOutcome::AlreadyAdmitted(
1044 attempt,
1045 ));
1046 }
1047
1048 let state_matches = recovery_attempt_matches_reservation(&attempt, reservation)
1049 && recovery_subscription_state_matches(transaction, reservation).await?
1050 && !blocking_subscription_charge_attempt_exists_except(
1051 transaction,
1052 reservation.subscription_id(),
1053 identity.attempt_id(),
1054 )
1055 .await?
1056 && !blocking_payment_method_update_exists(transaction, reservation.subscription_id())
1057 .await?;
1058 if !state_matches {
1059 return reject_locked_recovery(
1060 transaction,
1061 attempt,
1062 SubscriptionRecoverySubmissionRejection::BillingStateChanged,
1063 RECOVERY_STATE_CHANGED_TEXT,
1064 )
1065 .await;
1066 }
1067 if !gateway_identity_matches_reservation(transaction, reservation).await? {
1068 return reject_locked_recovery(
1069 transaction,
1070 attempt,
1071 SubscriptionRecoverySubmissionRejection::GatewayConfigurationChanged,
1072 RECOVERY_CONFIGURATION_CHANGED_TEXT,
1073 )
1074 .await;
1075 }
1076
1077 sqlx::query(
1078 "UPDATE billing_payment_attempts SET submitted_at = clock_timestamp(), updated_at = clock_timestamp() WHERE id = $1 AND status = 'pending' AND submitted_at IS NULL",
1079 )
1080 .bind(identity.attempt_id().as_uuid())
1081 .execute(&mut **transaction)
1082 .await?;
1083 let admitted = find_payment_attempt_by_id_in_transaction(
1084 transaction,
1085 identity.billing_scope_id(),
1086 identity.attempt_id(),
1087 )
1088 .await?
1089 .ok_or_else(invalid_state)?;
1090 Ok(SubscriptionRecoverySubmissionOutcome::Admitted(admitted))
1091}
1092
1093pub async fn preflight_subscription_payment_method_replacement_in_transaction(
1095 transaction: &mut Transaction<'_, Postgres>,
1096 command: &syrup_rail::ReplaceSubscriptionPaymentMethod,
1097) -> Result<SubscriptionPaymentMethodReplacementPreflightOutcome, PaymentAttemptStoreError> {
1098 set_enrollment_timeouts(transaction).await?;
1099 let Some(existing) = payment_attempt_by_idempotency(
1100 transaction,
1101 command.billing_scope_id(),
1102 command.subscriber_id(),
1103 command.idempotency_key(),
1104 false,
1105 )
1106 .await?
1107 else {
1108 return Ok(SubscriptionPaymentMethodReplacementPreflightOutcome::Continue);
1109 };
1110 Ok(
1111 if payment_method_replacement_attempt_matches_command(&existing, command)
1112 && payment_method_replacement_attempt_matches_replay_context(transaction, &existing)
1113 .await?
1114 {
1115 SubscriptionPaymentMethodReplacementPreflightOutcome::Replay(Box::new(existing))
1116 } else {
1117 SubscriptionPaymentMethodReplacementPreflightOutcome::IdempotencyConflict
1118 },
1119 )
1120}
1121
1122pub async fn reserve_subscription_payment_method_replacement_in_transaction(
1125 transaction: &mut Transaction<'_, Postgres>,
1126 command: &syrup_rail::ReplaceSubscriptionPaymentMethod,
1127 gateway: &syrup_rail::ResolvedGateway,
1128) -> Result<SubscriptionPaymentMethodReplacementReservationOutcome, PaymentAttemptStoreError> {
1129 set_enrollment_timeouts(transaction).await?;
1130 lock_subscription_aggregate(transaction, command.subscriber_id(), command.plan_key()).await?;
1131 if let Some(existing) = payment_attempt_by_idempotency(
1132 transaction,
1133 command.billing_scope_id(),
1134 command.subscriber_id(),
1135 command.idempotency_key(),
1136 true,
1137 )
1138 .await?
1139 {
1140 return Ok(
1141 if payment_method_replacement_attempt_matches_command(&existing, command)
1142 && payment_method_replacement_attempt_matches_replay_context(transaction, &existing)
1143 .await?
1144 {
1145 SubscriptionPaymentMethodReplacementReservationOutcome::Replay(Box::new(existing))
1146 } else {
1147 SubscriptionPaymentMethodReplacementReservationOutcome::IdempotencyConflict
1148 },
1149 );
1150 }
1151
1152 let row = sqlx::query(
1153 r#"
1154 SELECT id, gateway_account_id, payment_method_id, initial_transaction_id,
1155 status, currency
1156 FROM billing_subscriptions
1157 WHERE billing_scope_id = $1 AND subscriber_id = $2 AND plan_key = $3
1158 ORDER BY updated_at DESC, id DESC
1159 LIMIT 1
1160 FOR UPDATE
1161 "#,
1162 )
1163 .bind(command.billing_scope_id().as_uuid())
1164 .bind(command.subscriber_id().as_uuid())
1165 .bind(command.plan_key().as_str())
1166 .fetch_optional(&mut **transaction)
1167 .await?;
1168 let Some(row) = row else {
1169 return Ok(
1170 SubscriptionPaymentMethodReplacementReservationOutcome::Rejected(
1171 SubscriptionPaymentMethodReplacementRejection::SubscriptionNotFound,
1172 ),
1173 );
1174 };
1175 let status: String = row.try_get("status")?;
1176 if !matches!(status.as_str(), "active" | "past_due") {
1177 return Ok(
1178 SubscriptionPaymentMethodReplacementReservationOutcome::Rejected(
1179 SubscriptionPaymentMethodReplacementRejection::SubscriptionIneligible,
1180 ),
1181 );
1182 }
1183 let subscription_id = SubscriptionId::new(row.try_get("id")?);
1184 fail_stale_unsubmitted_payment_method_updates(transaction, subscription_id).await?;
1185 if blocking_subscription_charge_attempt_exists_for_method_replacement(
1186 transaction,
1187 subscription_id,
1188 )
1189 .await?
1190 {
1191 return Ok(
1192 SubscriptionPaymentMethodReplacementReservationOutcome::Rejected(
1193 SubscriptionPaymentMethodReplacementRejection::ChargeAttemptInProgress,
1194 ),
1195 );
1196 }
1197 if blocking_payment_method_update_exists(transaction, subscription_id).await? {
1198 return Ok(
1199 SubscriptionPaymentMethodReplacementReservationOutcome::Rejected(
1200 SubscriptionPaymentMethodReplacementRejection::PaymentMethodUpdateInProgress,
1201 ),
1202 );
1203 }
1204 let gateway_account_id = GatewayAccountId::new(row.try_get("gateway_account_id")?);
1205 if gateway_account_id != gateway.gateway_account_id()
1206 || !gateway_identity_matches_payment_method_replacement(transaction, command, gateway)
1207 .await?
1208 {
1209 return Ok(
1210 SubscriptionPaymentMethodReplacementReservationOutcome::Rejected(
1211 SubscriptionPaymentMethodReplacementRejection::GatewayConfigurationChanged,
1212 ),
1213 );
1214 }
1215 let payment_method_id = PaymentMethodId::new(row.try_get("payment_method_id")?);
1216 let initial_transaction_id =
1217 GatewayTransactionId::new(row.try_get::<String, _>("initial_transaction_id")?)
1218 .map_err(|_| invalid_state())?;
1219 let currency =
1220 CurrencyCode::new(&row.try_get::<String, _>("currency")?).map_err(|_| invalid_state())?;
1221 let reservation = SubscriptionPaymentMethodReplacement::from_locked_subscription(
1222 command,
1223 gateway,
1224 subscription_id,
1225 payment_method_id,
1226 initial_transaction_id,
1227 currency,
1228 )
1229 .map_err(|_| invalid_state())?;
1230 let inserted = insert_payment_method_replacement_attempt(transaction, &reservation).await?;
1231 if inserted {
1232 let attempt = payment_attempt_by_idempotency(
1233 transaction,
1234 command.billing_scope_id(),
1235 command.subscriber_id(),
1236 command.idempotency_key(),
1237 true,
1238 )
1239 .await?
1240 .ok_or_else(invalid_state)?;
1241 return Ok(
1242 SubscriptionPaymentMethodReplacementReservationOutcome::Reserved(
1243 Box::new(reservation),
1244 Box::new(attempt),
1245 ),
1246 );
1247 }
1248 if let Some(existing) = payment_attempt_by_idempotency(
1249 transaction,
1250 command.billing_scope_id(),
1251 command.subscriber_id(),
1252 command.idempotency_key(),
1253 true,
1254 )
1255 .await?
1256 {
1257 return Ok(
1258 if payment_method_replacement_attempt_matches_command(&existing, command)
1259 && payment_method_replacement_attempt_matches_replay_context(transaction, &existing)
1260 .await?
1261 {
1262 SubscriptionPaymentMethodReplacementReservationOutcome::Replay(Box::new(existing))
1263 } else {
1264 SubscriptionPaymentMethodReplacementReservationOutcome::IdempotencyConflict
1265 },
1266 );
1267 }
1268 Ok(
1269 SubscriptionPaymentMethodReplacementReservationOutcome::Rejected(
1270 SubscriptionPaymentMethodReplacementRejection::PaymentMethodUpdateInProgress,
1271 ),
1272 )
1273}
1274
1275pub async fn admit_subscription_payment_method_replacement_in_transaction(
1278 transaction: &mut Transaction<'_, Postgres>,
1279 reservation: &SubscriptionPaymentMethodReplacement,
1280) -> Result<SubscriptionPaymentMethodReplacementSubmissionOutcome, PaymentAttemptStoreError> {
1281 set_enrollment_timeouts(transaction).await?;
1282 let identity = reservation.identity();
1283 lock_subscription_aggregate(
1284 transaction,
1285 identity.subscriber_id(),
1286 reservation.plan_key(),
1287 )
1288 .await?;
1289 fail_stale_unsubmitted_payment_method_updates(transaction, reservation.subscription_id())
1290 .await?;
1291 let attempt = payment_attempt_by_idempotency(
1292 transaction,
1293 identity.billing_scope_id(),
1294 identity.subscriber_id(),
1295 reservation.request().idempotency_key(),
1296 true,
1297 )
1298 .await?
1299 .ok_or_else(invalid_state)?;
1300 if !payment_method_replacement_attempt_belongs_to_reservation(&attempt, reservation) {
1301 return Err(invalid_state());
1302 }
1303 if attempt.status() != PaymentAttemptStatus::Pending
1304 || attempt.state().timestamps().submitted_at().is_some()
1305 {
1306 return Ok(SubscriptionPaymentMethodReplacementSubmissionOutcome::AlreadyAdmitted(attempt));
1307 }
1308 let state_matches = attempt.request() == reservation.request()
1309 && payment_method_replacement_subscription_state_matches(transaction, reservation).await?
1310 && !blocking_subscription_charge_attempt_exists_for_method_replacement(
1311 transaction,
1312 reservation.subscription_id(),
1313 )
1314 .await?
1315 && !blocking_payment_method_update_exists_except(
1316 transaction,
1317 reservation.subscription_id(),
1318 identity.attempt_id(),
1319 )
1320 .await?;
1321 if !state_matches {
1322 return reject_locked_payment_method_replacement(
1323 transaction,
1324 attempt,
1325 SubscriptionPaymentMethodReplacementSubmissionRejection::BillingStateChanged,
1326 PAYMENT_METHOD_REPLACEMENT_STATE_CHANGED_TEXT,
1327 )
1328 .await;
1329 }
1330 if !gateway_identity_matches_payment_method_replacement_reservation(transaction, reservation)
1331 .await?
1332 {
1333 return reject_locked_payment_method_replacement(
1334 transaction,
1335 attempt,
1336 SubscriptionPaymentMethodReplacementSubmissionRejection::GatewayConfigurationChanged,
1337 PAYMENT_METHOD_REPLACEMENT_CONFIGURATION_CHANGED_TEXT,
1338 )
1339 .await;
1340 }
1341 sqlx::query(
1342 "UPDATE billing_payment_attempts SET submitted_at = clock_timestamp(), updated_at = clock_timestamp() WHERE id = $1 AND status = 'pending' AND submitted_at IS NULL",
1343 )
1344 .bind(identity.attempt_id().as_uuid())
1345 .execute(&mut **transaction)
1346 .await?;
1347 let admitted = find_payment_attempt_by_id_in_transaction(
1348 transaction,
1349 identity.billing_scope_id(),
1350 identity.attempt_id(),
1351 )
1352 .await?
1353 .ok_or_else(invalid_state)?;
1354 Ok(SubscriptionPaymentMethodReplacementSubmissionOutcome::Admitted(admitted))
1355}
1356
1357pub async fn find_payment_attempt_by_id_in_transaction(
1359 transaction: &mut Transaction<'_, Postgres>,
1360 billing_scope_id: BillingScopeId,
1361 attempt_id: PaymentAttemptId,
1362) -> Result<Option<PaymentAttempt>, PaymentAttemptStoreError> {
1363 let query = format!("{PAYMENT_ATTEMPT_SELECT} WHERE billing_scope_id = $1 AND id = $2");
1364 let row = sqlx::query(&query)
1365 .bind(billing_scope_id.as_uuid())
1366 .bind(attempt_id.as_uuid())
1367 .fetch_optional(&mut **transaction)
1368 .await?;
1369 row.as_ref().map(payment_attempt_from_row).transpose()
1370}
1371
1372pub async fn lock_payment_attempt_by_idempotency_in_transaction(
1374 transaction: &mut Transaction<'_, Postgres>,
1375 billing_scope_id: BillingScopeId,
1376 subscriber_id: SubscriberId,
1377 idempotency_key: &IdempotencyKey,
1378) -> Result<Option<PaymentAttempt>, PaymentAttemptStoreError> {
1379 let query = format!(
1380 "{PAYMENT_ATTEMPT_SELECT} \
1381 WHERE billing_scope_id = $1 AND subscriber_id = $2 AND idempotency_key = $3 \
1382 FOR UPDATE"
1383 );
1384 let row = sqlx::query(&query)
1385 .bind(billing_scope_id.as_uuid())
1386 .bind(subscriber_id.as_uuid())
1387 .bind(idempotency_key.expose())
1388 .fetch_optional(&mut **transaction)
1389 .await?;
1390 row.as_ref().map(payment_attempt_from_row).transpose()
1391}
1392
1393pub(crate) async fn lock_payment_attempt_by_id_on_connection(
1394 connection: &mut PgConnection,
1395 billing_scope_id: BillingScopeId,
1396 attempt_id: PaymentAttemptId,
1397) -> Result<Option<PaymentAttempt>, PaymentAttemptStoreError> {
1398 let query =
1399 format!("{PAYMENT_ATTEMPT_SELECT} WHERE billing_scope_id = $1 AND id = $2 FOR UPDATE");
1400 let row = sqlx::query(&query)
1401 .bind(billing_scope_id.as_uuid())
1402 .bind(attempt_id.as_uuid())
1403 .fetch_optional(&mut *connection)
1404 .await?;
1405 row.as_ref().map(payment_attempt_from_row).transpose()
1406}
1407
1408pub(crate) async fn find_payment_attempt_by_id_on_connection(
1409 connection: &mut PgConnection,
1410 billing_scope_id: BillingScopeId,
1411 attempt_id: PaymentAttemptId,
1412) -> Result<Option<PaymentAttempt>, PaymentAttemptStoreError> {
1413 let query = format!("{PAYMENT_ATTEMPT_SELECT} WHERE billing_scope_id = $1 AND id = $2");
1414 let row = sqlx::query(&query)
1415 .bind(billing_scope_id.as_uuid())
1416 .bind(attempt_id.as_uuid())
1417 .fetch_optional(&mut *connection)
1418 .await?;
1419 row.as_ref().map(payment_attempt_from_row).transpose()
1420}
1421
1422pub(crate) async fn set_enrollment_timeouts(
1423 connection: &mut PgConnection,
1424) -> Result<(), sqlx::Error> {
1425 sqlx::query(
1426 "SELECT set_config('lock_timeout', $1, true), set_config('statement_timeout', $2, true)",
1427 )
1428 .bind(BILLING_ROW_LOCK_TIMEOUT)
1429 .bind(BILLING_OPERATION_TIMEOUT)
1430 .execute(&mut *connection)
1431 .await?;
1432 Ok(())
1433}
1434
1435pub(crate) async fn lock_subscription_aggregate(
1436 connection: &mut PgConnection,
1437 subscriber_id: SubscriberId,
1438 plan_key: &PlanKey,
1439) -> Result<(), sqlx::Error> {
1440 sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':' || $2, 0))")
1441 .bind(subscriber_id.as_uuid())
1442 .bind(plan_key.as_str())
1443 .execute(&mut *connection)
1444 .await?;
1445 Ok(())
1446}
1447
1448pub(crate) async fn try_lock_subscription_aggregate(
1449 transaction: &mut Transaction<'_, Postgres>,
1450 subscriber_id: SubscriberId,
1451 plan_key: &PlanKey,
1452) -> Result<bool, sqlx::Error> {
1453 sqlx::query_scalar(
1454 "SELECT pg_try_advisory_xact_lock(hashtextextended($1::uuid::text || ':' || $2, 0))",
1455 )
1456 .bind(subscriber_id.as_uuid())
1457 .bind(plan_key.as_str())
1458 .fetch_one(&mut **transaction)
1459 .await
1460}
1461
1462async fn payment_attempt_by_idempotency(
1463 transaction: &mut Transaction<'_, Postgres>,
1464 billing_scope_id: BillingScopeId,
1465 subscriber_id: SubscriberId,
1466 idempotency_key: &IdempotencyKey,
1467 for_update: bool,
1468) -> Result<Option<PaymentAttempt>, PaymentAttemptStoreError> {
1469 let lock = if for_update { "FOR UPDATE" } else { "" };
1470 let query = format!(
1471 "{PAYMENT_ATTEMPT_SELECT} \
1472 WHERE billing_scope_id = $1 AND subscriber_id = $2 AND idempotency_key = $3 \
1473 {lock}"
1474 );
1475 let row = sqlx::query(&query)
1476 .bind(billing_scope_id.as_uuid())
1477 .bind(subscriber_id.as_uuid())
1478 .bind(idempotency_key.expose())
1479 .fetch_optional(&mut **transaction)
1480 .await?;
1481 row.as_ref().map(payment_attempt_from_row).transpose()
1482}
1483
1484pub(crate) async fn lock_initial_attempt_rows(
1485 transaction: &mut Transaction<'_, Postgres>,
1486 billing_scope_id: BillingScopeId,
1487 subscriber_id: SubscriberId,
1488 plan_key: &PlanKey,
1489) -> Result<(), sqlx::Error> {
1490 sqlx::query_scalar::<_, Uuid>(
1491 r#"
1492 SELECT id FROM billing_payment_attempts
1493 WHERE billing_scope_id = $1 AND subscriber_id = $2 AND plan_key = $3
1494 AND attempt_kind = 'subscription_initial'
1495 ORDER BY created_at, id FOR UPDATE
1496 "#,
1497 )
1498 .bind(billing_scope_id.as_uuid())
1499 .bind(subscriber_id.as_uuid())
1500 .bind(plan_key.as_str())
1501 .fetch_all(&mut **transaction)
1502 .await?;
1503 Ok(())
1504}
1505
1506pub(crate) async fn lock_initial_charge_rows(
1507 transaction: &mut Transaction<'_, Postgres>,
1508 billing_scope_id: BillingScopeId,
1509 subscriber_id: SubscriberId,
1510 plan_key: &PlanKey,
1511) -> Result<(), sqlx::Error> {
1512 sqlx::query_scalar::<_, Uuid>(
1513 r#"
1514 SELECT charges.id
1515 FROM billing_processor_charges charges
1516 INNER JOIN billing_payment_attempts attempts ON attempts.id = charges.attempt_id
1517 WHERE attempts.billing_scope_id = $1
1518 AND attempts.subscriber_id = $2
1519 AND attempts.plan_key = $3
1520 AND attempts.attempt_kind = 'subscription_initial'
1521 ORDER BY charges.observed_at, charges.id
1522 FOR UPDATE OF charges
1523 "#,
1524 )
1525 .bind(billing_scope_id.as_uuid())
1526 .bind(subscriber_id.as_uuid())
1527 .bind(plan_key.as_str())
1528 .fetch_all(&mut **transaction)
1529 .await?;
1530 Ok(())
1531}
1532
1533pub(crate) async fn expire_stale_initial_attempts(
1534 transaction: &mut Transaction<'_, Postgres>,
1535 billing_scope_id: BillingScopeId,
1536 subscriber_id: SubscriberId,
1537 plan_key: &PlanKey,
1538) -> Result<u64, sqlx::Error> {
1539 let result = sqlx::query(
1540 r#"
1541 UPDATE billing_payment_attempts
1542 SET status = 'failed',
1543 gateway_response_text = COALESCE(gateway_response_text, $4),
1544 gateway_condition = COALESCE(gateway_condition, 'failed'),
1545 resolution_code = COALESCE(
1546 resolution_code,
1547 'subscription_initial_prepared_attempt_expired'
1548 ),
1549 resolved_at = clock_timestamp(), updated_at = clock_timestamp()
1550 WHERE billing_scope_id = $1 AND subscriber_id = $2 AND plan_key = $3
1551 AND attempt_kind = 'subscription_initial' AND status = 'pending'
1552 AND submitted_at IS NULL
1553 AND created_at <= clock_timestamp()
1554 - ($5::bigint * interval '1 second')
1555 "#,
1556 )
1557 .bind(billing_scope_id.as_uuid())
1558 .bind(subscriber_id.as_uuid())
1559 .bind(plan_key.as_str())
1560 .bind(INITIAL_PREPARED_EXPIRED_TEXT)
1561 .bind(INITIAL_PREPARED_STALE_AFTER_SECONDS)
1562 .execute(&mut **transaction)
1563 .await?;
1564 Ok(result.rows_affected())
1565}
1566
1567async fn initial_attempt_is_stale(
1568 transaction: &mut Transaction<'_, Postgres>,
1569 attempt_id: PaymentAttemptId,
1570) -> Result<bool, sqlx::Error> {
1571 sqlx::query_scalar(
1572 r#"
1573 SELECT attempt_kind = 'subscription_initial'
1574 AND status = 'pending'
1575 AND submitted_at IS NULL
1576 AND created_at <= clock_timestamp()
1577 - ($2::bigint * interval '1 second')
1578 FROM billing_payment_attempts
1579 WHERE id = $1
1580 "#,
1581 )
1582 .bind(attempt_id.as_uuid())
1583 .bind(INITIAL_PREPARED_STALE_AFTER_SECONDS)
1584 .fetch_one(&mut **transaction)
1585 .await
1586}
1587
1588async fn current_subscription_exists(
1589 transaction: &mut Transaction<'_, Postgres>,
1590 billing_scope_id: BillingScopeId,
1591 subscriber_id: SubscriberId,
1592 plan_key: &PlanKey,
1593) -> Result<bool, sqlx::Error> {
1594 let rows = sqlx::query_scalar::<_, Uuid>(
1595 r#"
1596 SELECT id FROM billing_subscriptions
1597 WHERE billing_scope_id = $1 AND subscriber_id = $2 AND plan_key = $3
1598 AND (
1599 status IN ('active', 'past_due')
1600 OR (status = 'canceled' AND current_period_end_at > clock_timestamp())
1601 )
1602 ORDER BY updated_at DESC, id DESC FOR NO KEY UPDATE
1603 "#,
1604 )
1605 .bind(billing_scope_id.as_uuid())
1606 .bind(subscriber_id.as_uuid())
1607 .bind(plan_key.as_str())
1608 .fetch_all(&mut **transaction)
1609 .await?;
1610 Ok(!rows.is_empty())
1611}
1612
1613async fn active_grant_exists(
1614 transaction: &mut Transaction<'_, Postgres>,
1615 billing_scope_id: BillingScopeId,
1616 subscriber_id: SubscriberId,
1617 plan_key: &PlanKey,
1618) -> Result<bool, sqlx::Error> {
1619 let rows = sqlx::query_scalar::<_, Uuid>(
1620 r#"
1621 SELECT id FROM billing_subscription_grants
1622 WHERE billing_scope_id = $1 AND subscriber_id = $2 AND plan_key = $3
1623 AND revoked_at IS NULL
1624 AND starts_at <= clock_timestamp() AND ends_at > clock_timestamp()
1625 ORDER BY ends_at DESC, id DESC FOR UPDATE
1626 "#,
1627 )
1628 .bind(billing_scope_id.as_uuid())
1629 .bind(subscriber_id.as_uuid())
1630 .bind(plan_key.as_str())
1631 .fetch_all(&mut **transaction)
1632 .await?;
1633 Ok(!rows.is_empty())
1634}
1635
1636async fn unresolved_initial_charge_exists(
1637 transaction: &mut Transaction<'_, Postgres>,
1638 billing_scope_id: BillingScopeId,
1639 subscriber_id: SubscriberId,
1640 plan_key: &PlanKey,
1641) -> Result<bool, sqlx::Error> {
1642 sqlx::query_scalar(
1643 r#"
1644 SELECT EXISTS (
1645 SELECT 1
1646 FROM billing_processor_charges charges
1647 INNER JOIN billing_payment_attempts attempts ON attempts.id = charges.attempt_id
1648 WHERE attempts.billing_scope_id = $1
1649 AND attempts.subscriber_id = $2
1650 AND attempts.plan_key = $3
1651 AND attempts.attempt_kind = 'subscription_initial'
1652 AND charges.progression_state IN (
1653 'pending', 'reconciliation_required', 'external_reversal_required'
1654 )
1655 )
1656 "#,
1657 )
1658 .bind(billing_scope_id.as_uuid())
1659 .bind(subscriber_id.as_uuid())
1660 .bind(plan_key.as_str())
1661 .fetch_one(&mut **transaction)
1662 .await
1663}
1664
1665async fn blocking_initial_attempt_exists(
1666 transaction: &mut Transaction<'_, Postgres>,
1667 billing_scope_id: BillingScopeId,
1668 subscriber_id: SubscriberId,
1669 plan_key: &PlanKey,
1670) -> Result<bool, sqlx::Error> {
1671 sqlx::query_scalar(
1672 r#"
1673 SELECT EXISTS (
1674 SELECT 1 FROM billing_payment_attempts
1675 WHERE billing_scope_id = $1 AND subscriber_id = $2 AND plan_key = $3
1676 AND attempt_kind = 'subscription_initial'
1677 AND (
1678 status IN ('pending', 'unknown')
1679 OR (
1680 status = 'review_required'
1681 AND resolution_code IS DISTINCT FROM
1682 'subscription_initial_current_subscription_conflict'
1683 )
1684 )
1685 )
1686 "#,
1687 )
1688 .bind(billing_scope_id.as_uuid())
1689 .bind(subscriber_id.as_uuid())
1690 .bind(plan_key.as_str())
1691 .fetch_one(&mut **transaction)
1692 .await
1693}
1694
1695fn enrollment_request_from_locked_terms(
1696 reservation: &SubscriptionEnrollmentReservation,
1697 offer: &syrup_rail::SubscriptionOffer,
1698 saved_claim: Option<&syrup_rail::SubscriptionDiscountClaimRecord>,
1699) -> Option<PaymentAttemptRequest> {
1700 let saved_snapshot = saved_claim.map(|claim| claim.snapshot());
1701 if !reservation
1702 .expected_charge()
1703 .matches_locked_terms(offer, saved_snapshot)
1704 {
1705 return None;
1706 }
1707 let discount = saved_claim.map(|claim| {
1708 SubscriptionEnrollmentDiscountSnapshot::new(
1709 claim.id(),
1710 claim.discount_code_id(),
1711 claim.snapshot().clone(),
1712 )
1713 });
1714 let amount = reservation.expected_charge().charge().money();
1715 let fingerprint = PaymentAttemptFingerprint::for_subscription_initial(
1716 reservation.plan_key(),
1717 amount,
1718 discount.as_ref(),
1719 );
1720 Some(PaymentAttemptRequest::new(
1721 PaymentAttemptTarget::SubscriptionInitial {
1722 plan_key: reservation.plan_key().clone(),
1723 discount,
1724 application: None,
1725 },
1726 reservation.idempotency_key().clone(),
1727 fingerprint,
1728 amount,
1729 reservation.gateway_order_id().clone(),
1730 reservation.billing_contact().clone(),
1731 ))
1732}
1733
1734async fn gateway_identity_matches(
1735 transaction: &mut Transaction<'_, Postgres>,
1736 reservation: &SubscriptionEnrollmentReservation,
1737) -> Result<bool, sqlx::Error> {
1738 let identity = reservation.identity();
1739 let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
1740 r#"
1741 SELECT id, gateway_configuration_id, provider_key
1742 FROM billing_gateway_accounts
1743 WHERE billing_scope_id = $1 FOR SHARE
1744 "#,
1745 )
1746 .bind(identity.billing_scope_id().as_uuid())
1747 .fetch_optional(&mut **transaction)
1748 .await?;
1749 Ok(
1750 row.is_some_and(|(account_id, configuration_id, provider_key)| {
1751 account_id == identity.gateway_account_id().into_uuid()
1752 && configuration_id == identity.gateway_configuration_id().into_uuid()
1753 && provider_key == reservation.provider_key().as_str()
1754 }),
1755 )
1756}
1757
1758fn attempt_is_already_replayable(attempt: &PaymentAttempt) -> bool {
1759 attempt.status() != PaymentAttemptStatus::Pending
1760 || attempt.state().timestamps().submitted_at().is_some()
1761}
1762
1763fn recovery_attempt_matches_command(
1764 attempt: &PaymentAttempt,
1765 command: &syrup_rail::RecoverSubscriptionPayment,
1766) -> bool {
1767 let identity = attempt.identity();
1768 let PaymentAttemptTarget::SubscriptionRecovery {
1769 plan_key,
1770 period,
1771 expected_state,
1772 ..
1773 } = attempt.request().target()
1774 else {
1775 return false;
1776 };
1777 identity.billing_scope_id() == command.billing_scope_id()
1778 && identity.subscriber_id() == command.subscriber_id()
1779 && identity.gateway_configuration_id() == command.gateway_configuration_id()
1780 && plan_key == command.plan_key()
1781 && attempt
1782 .request()
1783 .fingerprint()
1784 .matches_subscription_recovery(
1785 plan_key,
1786 expected_state.subscription_id(),
1787 expected_state.payment_method_id(),
1788 *period.start_at(),
1789 attempt.request().amount(),
1790 )
1791}
1792
1793fn payment_method_replacement_attempt_matches_command(
1794 attempt: &PaymentAttempt,
1795 command: &syrup_rail::ReplaceSubscriptionPaymentMethod,
1796) -> bool {
1797 let identity = attempt.identity();
1798 let PaymentAttemptTarget::SubscriptionPaymentMethodUpdate {
1799 plan_key,
1800 expected_state,
1801 ..
1802 } = attempt.request().target()
1803 else {
1804 return false;
1805 };
1806 identity.billing_scope_id() == command.billing_scope_id()
1807 && identity.subscriber_id() == command.subscriber_id()
1808 && identity.gateway_configuration_id() == command.gateway_configuration_id()
1809 && plan_key == command.plan_key()
1810 && attempt
1811 .request()
1812 .fingerprint()
1813 .matches_subscription_payment_method_update(plan_key, expected_state)
1814}
1815
1816async fn payment_method_replacement_attempt_matches_replay_context(
1817 transaction: &mut Transaction<'_, Postgres>,
1818 attempt: &PaymentAttempt,
1819) -> Result<bool, sqlx::Error> {
1820 let PaymentAttemptTarget::SubscriptionPaymentMethodUpdate {
1821 plan_key,
1822 payment_method_id,
1823 expected_state,
1824 } = attempt.request().target()
1825 else {
1826 return Ok(false);
1827 };
1828 let identity = attempt.identity();
1829 let approved_transaction = attempt
1830 .state()
1831 .processor_evidence()
1832 .transaction_id()
1833 .map(GatewayTransactionId::expose);
1834 sqlx::query_scalar(
1835 r#"
1836 SELECT EXISTS (
1837 SELECT 1 FROM billing_subscriptions
1838 WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
1839 AND gateway_account_id = $4 AND plan_key = $5
1840 AND (
1841 (
1842 status IN ('active', 'past_due')
1843 AND payment_method_id = $6
1844 AND initial_transaction_id = $7
1845 )
1846 OR (
1847 $8 = 'approved'
1848 AND payment_method_id = $9
1849 AND initial_transaction_id = $10
1850 )
1851 )
1852 )
1853 "#,
1854 )
1855 .bind(expected_state.subscription_id().as_uuid())
1856 .bind(identity.billing_scope_id().as_uuid())
1857 .bind(identity.subscriber_id().as_uuid())
1858 .bind(identity.gateway_account_id().as_uuid())
1859 .bind(plan_key.as_str())
1860 .bind(expected_state.payment_method_id().as_uuid())
1861 .bind(expected_state.expected_initial_transaction_id().expose())
1862 .bind(attempt.status().as_str())
1863 .bind(payment_method_id.as_uuid())
1864 .bind(approved_transaction)
1865 .fetch_one(&mut **transaction)
1866 .await
1867}
1868
1869fn payment_method_replacement_attempt_belongs_to_reservation(
1870 attempt: &PaymentAttempt,
1871 reservation: &SubscriptionPaymentMethodReplacement,
1872) -> bool {
1873 attempt.identity() == reservation.identity()
1874 && attempt.kind() == PaymentAttemptKind::SubscriptionPaymentMethodUpdate
1875 && attempt.request().idempotency_key() == reservation.request().idempotency_key()
1876 && attempt.request().gateway_order_id() == reservation.request().gateway_order_id()
1877}
1878
1879async fn recovery_attempt_matches_replay_context(
1880 transaction: &mut Transaction<'_, Postgres>,
1881 attempt: &PaymentAttempt,
1882) -> Result<bool, sqlx::Error> {
1883 let PaymentAttemptTarget::SubscriptionRecovery {
1884 plan_key,
1885 period,
1886 expected_state,
1887 ..
1888 } = attempt.request().target()
1889 else {
1890 return Ok(false);
1891 };
1892 let identity = attempt.identity();
1893 sqlx::query_scalar(
1894 r#"
1895 SELECT EXISTS (
1896 SELECT 1 FROM billing_subscriptions
1897 WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
1898 AND plan_key = $4
1899 AND (
1900 (
1901 status IN ('active', 'past_due')
1902 AND next_renewal_at = $5
1903 )
1904 OR (
1905 $6 = 'approved'
1906 AND next_renewal_at > clock_timestamp()
1907 AND current_period_start_at = $5
1908 )
1909 )
1910 )
1911 "#,
1912 )
1913 .bind(expected_state.subscription_id().as_uuid())
1914 .bind(identity.billing_scope_id().as_uuid())
1915 .bind(identity.subscriber_id().as_uuid())
1916 .bind(plan_key.as_str())
1917 .bind(period.start_at())
1918 .bind(attempt.status().as_str())
1919 .fetch_one(&mut **transaction)
1920 .await
1921}
1922
1923fn recovery_attempt_matches_reservation(
1924 attempt: &PaymentAttempt,
1925 reservation: &SubscriptionRecoveryReservation,
1926) -> bool {
1927 recovery_attempt_belongs_to_reservation(attempt, reservation)
1928 && attempt.request() == reservation.request()
1929}
1930
1931fn recovery_attempt_belongs_to_reservation(
1932 attempt: &PaymentAttempt,
1933 reservation: &SubscriptionRecoveryReservation,
1934) -> bool {
1935 attempt.identity() == reservation.identity()
1936 && attempt.kind() == PaymentAttemptKind::SubscriptionRecovery
1937 && attempt.request().idempotency_key() == reservation.request().idempotency_key()
1938 && attempt.request().gateway_order_id() == reservation.request().gateway_order_id()
1939}
1940
1941fn renewal_attempt_belongs_to_reservation(
1942 attempt: &PaymentAttempt,
1943 reservation: &SubscriptionRenewalReservation,
1944) -> bool {
1945 attempt.identity() == reservation.identity()
1946 && attempt.kind() == PaymentAttemptKind::SubscriptionRenewal
1947 && attempt.request().idempotency_key() == reservation.request().idempotency_key()
1948 && attempt.request().gateway_order_id() == reservation.request().gateway_order_id()
1949}
1950
1951async fn gateway_identity_matches_renewal(
1952 transaction: &mut Transaction<'_, Postgres>,
1953 command: syrup_rail::ChargeRenewal,
1954 gateway: &syrup_rail::ResolvedGateway,
1955) -> Result<bool, sqlx::Error> {
1956 let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
1957 r#"
1958 SELECT accounts.id, accounts.gateway_configuration_id, accounts.provider_key
1959 FROM billing_subscriptions AS subscriptions
1960 JOIN billing_gateway_accounts AS accounts
1961 ON accounts.billing_scope_id = subscriptions.billing_scope_id
1962 AND accounts.id = subscriptions.gateway_account_id
1963 WHERE subscriptions.billing_scope_id = $1 AND subscriptions.id = $2
1964 FOR SHARE OF accounts
1965 "#,
1966 )
1967 .bind(command.billing_scope_id().as_uuid())
1968 .bind(command.subscription_id().as_uuid())
1969 .fetch_optional(&mut **transaction)
1970 .await?;
1971 Ok(
1972 row.is_some_and(|(account_id, configuration_id, provider_key)| {
1973 account_id == gateway.gateway_account_id().into_uuid()
1974 && configuration_id == gateway.gateway_configuration_id().into_uuid()
1975 && provider_key == gateway.provider_key().as_str()
1976 }),
1977 )
1978}
1979
1980async fn gateway_identity_matches_renewal_reservation(
1981 transaction: &mut Transaction<'_, Postgres>,
1982 reservation: &SubscriptionRenewalReservation,
1983) -> Result<bool, sqlx::Error> {
1984 let identity = reservation.identity();
1985 let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
1986 r#"
1987 SELECT id, gateway_configuration_id, provider_key
1988 FROM billing_gateway_accounts
1989 WHERE billing_scope_id = $1 AND id = $2
1990 FOR SHARE
1991 "#,
1992 )
1993 .bind(identity.billing_scope_id().as_uuid())
1994 .bind(identity.gateway_account_id().as_uuid())
1995 .fetch_optional(&mut **transaction)
1996 .await?;
1997 Ok(
1998 row.is_some_and(|(account_id, configuration_id, provider_key)| {
1999 account_id == identity.gateway_account_id().into_uuid()
2000 && configuration_id == identity.gateway_configuration_id().into_uuid()
2001 && provider_key == reservation.provider_key().as_str()
2002 }),
2003 )
2004}
2005
2006async fn gateway_identity_matches_recovery(
2007 transaction: &mut Transaction<'_, Postgres>,
2008 command: &syrup_rail::RecoverSubscriptionPayment,
2009 gateway: &syrup_rail::ResolvedGateway,
2010) -> Result<bool, sqlx::Error> {
2011 let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
2012 r#"
2013 SELECT id, gateway_configuration_id, provider_key
2014 FROM billing_gateway_accounts
2015 WHERE billing_scope_id = $1 AND id = $2
2016 FOR SHARE
2017 "#,
2018 )
2019 .bind(command.billing_scope_id().as_uuid())
2020 .bind(gateway.gateway_account_id().as_uuid())
2021 .fetch_optional(&mut **transaction)
2022 .await?;
2023 Ok(
2024 row.is_some_and(|(account_id, configuration_id, provider_key)| {
2025 account_id == gateway.gateway_account_id().into_uuid()
2026 && configuration_id == command.gateway_configuration_id().into_uuid()
2027 && provider_key == gateway.provider_key().as_str()
2028 }),
2029 )
2030}
2031
2032async fn gateway_identity_matches_payment_method_replacement(
2033 transaction: &mut Transaction<'_, Postgres>,
2034 command: &syrup_rail::ReplaceSubscriptionPaymentMethod,
2035 gateway: &syrup_rail::ResolvedGateway,
2036) -> Result<bool, sqlx::Error> {
2037 let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
2038 r#"
2039 SELECT id, gateway_configuration_id, provider_key
2040 FROM billing_gateway_accounts
2041 WHERE billing_scope_id = $1 AND id = $2
2042 FOR SHARE
2043 "#,
2044 )
2045 .bind(command.billing_scope_id().as_uuid())
2046 .bind(gateway.gateway_account_id().as_uuid())
2047 .fetch_optional(&mut **transaction)
2048 .await?;
2049 Ok(
2050 row.is_some_and(|(account_id, configuration_id, provider_key)| {
2051 account_id == gateway.gateway_account_id().into_uuid()
2052 && configuration_id == command.gateway_configuration_id().into_uuid()
2053 && provider_key == gateway.provider_key().as_str()
2054 }),
2055 )
2056}
2057
2058async fn gateway_identity_matches_payment_method_replacement_reservation(
2059 transaction: &mut Transaction<'_, Postgres>,
2060 reservation: &SubscriptionPaymentMethodReplacement,
2061) -> Result<bool, sqlx::Error> {
2062 let identity = reservation.identity();
2063 let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
2064 r#"
2065 SELECT id, gateway_configuration_id, provider_key
2066 FROM billing_gateway_accounts
2067 WHERE billing_scope_id = $1 AND id = $2
2068 FOR SHARE
2069 "#,
2070 )
2071 .bind(identity.billing_scope_id().as_uuid())
2072 .bind(identity.gateway_account_id().as_uuid())
2073 .fetch_optional(&mut **transaction)
2074 .await?;
2075 Ok(
2076 row.is_some_and(|(account_id, configuration_id, provider_key)| {
2077 account_id == identity.gateway_account_id().into_uuid()
2078 && configuration_id == identity.gateway_configuration_id().into_uuid()
2079 && provider_key == reservation.provider_key().as_str()
2080 }),
2081 )
2082}
2083
2084async fn gateway_identity_matches_reservation(
2085 transaction: &mut Transaction<'_, Postgres>,
2086 reservation: &SubscriptionRecoveryReservation,
2087) -> Result<bool, sqlx::Error> {
2088 let identity = reservation.identity();
2089 let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
2090 r#"
2091 SELECT id, gateway_configuration_id, provider_key
2092 FROM billing_gateway_accounts
2093 WHERE billing_scope_id = $1 AND id = $2
2094 FOR SHARE
2095 "#,
2096 )
2097 .bind(identity.billing_scope_id().as_uuid())
2098 .bind(identity.gateway_account_id().as_uuid())
2099 .fetch_optional(&mut **transaction)
2100 .await?;
2101 Ok(
2102 row.is_some_and(|(account_id, configuration_id, provider_key)| {
2103 account_id == identity.gateway_account_id().into_uuid()
2104 && configuration_id == identity.gateway_configuration_id().into_uuid()
2105 && provider_key == reservation.provider_key().as_str()
2106 }),
2107 )
2108}
2109
2110async fn fail_stale_unsubmitted_payment_method_updates(
2111 transaction: &mut Transaction<'_, Postgres>,
2112 subscription_id: SubscriptionId,
2113) -> Result<(), sqlx::Error> {
2114 sqlx::query(
2115 r#"
2116 UPDATE billing_payment_attempts
2117 SET status = 'failed',
2118 gateway_response_text = COALESCE(
2119 gateway_response_text,
2120 'Payment method update was abandoned before gateway submission.'
2121 ),
2122 resolved_at = COALESCE(resolved_at, clock_timestamp()),
2123 updated_at = clock_timestamp()
2124 WHERE attempt_kind = 'subscription_payment_method_update'
2125 AND subscription_id = $1
2126 AND status = 'pending' AND submitted_at IS NULL
2127 AND created_at <= clock_timestamp()
2128 - ($2::bigint * interval '1 second')
2129 "#,
2130 )
2131 .bind(subscription_id.as_uuid())
2132 .bind(PAYMENT_METHOD_UPDATE_UNSUBMITTED_STALE_AFTER_SECONDS)
2133 .execute(&mut **transaction)
2134 .await?;
2135 Ok(())
2136}
2137
2138async fn blocking_subscription_charge_attempt_exists(
2139 transaction: &mut Transaction<'_, Postgres>,
2140 subscription_id: SubscriptionId,
2141) -> Result<bool, sqlx::Error> {
2142 blocking_subscription_charge_attempt_exists_except(
2143 transaction,
2144 subscription_id,
2145 PaymentAttemptId::new(Uuid::nil()),
2146 )
2147 .await
2148}
2149
2150async fn blocking_subscription_charge_attempt_exists_except(
2151 transaction: &mut Transaction<'_, Postgres>,
2152 subscription_id: SubscriptionId,
2153 excluded_attempt_id: PaymentAttemptId,
2154) -> Result<bool, sqlx::Error> {
2155 sqlx::query_scalar(
2156 r#"
2157 SELECT EXISTS (
2158 SELECT 1
2159 FROM billing_payment_attempts AS attempts
2160 INNER JOIN billing_subscriptions AS subscriptions
2161 ON subscriptions.id = attempts.subscription_id
2162 WHERE attempts.subscription_id = $1
2163 AND attempts.id <> $2
2164 AND attempts.attempt_kind IN ('subscription_renewal', 'subscription_recovery')
2165 AND (
2166 attempts.status IN ('pending', 'unknown', 'review_required')
2167 OR (
2168 attempts.status = 'approved'
2169 AND attempts.billing_period_start_at = subscriptions.next_renewal_at
2170 )
2171 )
2172 )
2173 "#,
2174 )
2175 .bind(subscription_id.as_uuid())
2176 .bind(excluded_attempt_id.as_uuid())
2177 .fetch_one(&mut **transaction)
2178 .await
2179}
2180
2181async fn blocking_subscription_charge_attempt_exists_for_method_replacement(
2182 transaction: &mut Transaction<'_, Postgres>,
2183 subscription_id: SubscriptionId,
2184) -> Result<bool, sqlx::Error> {
2185 sqlx::query_scalar(
2186 r#"
2187 SELECT EXISTS (
2188 SELECT 1
2189 FROM billing_payment_attempts AS attempts
2190 INNER JOIN billing_subscriptions AS subscriptions
2191 ON subscriptions.id = attempts.subscription_id
2192 WHERE attempts.subscription_id = $1
2193 AND attempts.attempt_kind IN ('subscription_renewal', 'subscription_recovery')
2194 AND attempts.billing_period_start_at = subscriptions.next_renewal_at
2195 AND attempts.status IN ('pending', 'unknown', 'approved')
2196 )
2197 "#,
2198 )
2199 .bind(subscription_id.as_uuid())
2200 .fetch_one(&mut **transaction)
2201 .await
2202}
2203
2204async fn blocking_payment_method_update_exists(
2205 transaction: &mut Transaction<'_, Postgres>,
2206 subscription_id: SubscriptionId,
2207) -> Result<bool, sqlx::Error> {
2208 sqlx::query_scalar(
2209 r#"
2210 SELECT EXISTS (
2211 SELECT 1 FROM billing_payment_attempts
2212 WHERE subscription_id = $1
2213 AND attempt_kind = 'subscription_payment_method_update'
2214 AND status IN ('pending', 'unknown', 'review_required')
2215 )
2216 "#,
2217 )
2218 .bind(subscription_id.as_uuid())
2219 .fetch_one(&mut **transaction)
2220 .await
2221}
2222
2223async fn blocking_payment_method_update_exists_except(
2224 transaction: &mut Transaction<'_, Postgres>,
2225 subscription_id: SubscriptionId,
2226 excluded_attempt_id: PaymentAttemptId,
2227) -> Result<bool, sqlx::Error> {
2228 sqlx::query_scalar(
2229 r#"
2230 SELECT EXISTS (
2231 SELECT 1 FROM billing_payment_attempts
2232 WHERE subscription_id = $1 AND id <> $2
2233 AND attempt_kind = 'subscription_payment_method_update'
2234 AND status IN ('pending', 'unknown', 'review_required')
2235 )
2236 "#,
2237 )
2238 .bind(subscription_id.as_uuid())
2239 .bind(excluded_attempt_id.as_uuid())
2240 .fetch_one(&mut **transaction)
2241 .await
2242}
2243
2244async fn payment_method_replacement_subscription_state_matches(
2245 transaction: &mut Transaction<'_, Postgres>,
2246 reservation: &SubscriptionPaymentMethodReplacement,
2247) -> Result<bool, sqlx::Error> {
2248 let identity = reservation.identity();
2249 let expected = reservation.expected_state();
2250 sqlx::query_scalar(
2251 r#"
2252 SELECT EXISTS (
2253 SELECT 1 FROM billing_subscriptions
2254 WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
2255 AND gateway_account_id = $4 AND plan_key = $5
2256 AND status IN ('active', 'past_due')
2257 AND payment_method_id = $6 AND initial_transaction_id = $7
2258 )
2259 "#,
2260 )
2261 .bind(expected.subscription_id().as_uuid())
2262 .bind(identity.billing_scope_id().as_uuid())
2263 .bind(identity.subscriber_id().as_uuid())
2264 .bind(identity.gateway_account_id().as_uuid())
2265 .bind(reservation.plan_key().as_str())
2266 .bind(expected.payment_method_id().as_uuid())
2267 .bind(expected.expected_initial_transaction_id().expose())
2268 .fetch_one(&mut **transaction)
2269 .await
2270}
2271
2272async fn recovery_subscription_state_matches(
2273 transaction: &mut Transaction<'_, Postgres>,
2274 reservation: &SubscriptionRecoveryReservation,
2275) -> Result<bool, sqlx::Error> {
2276 let identity = reservation.identity();
2277 let expected = reservation.expected_state();
2278 sqlx::query_scalar(
2279 r#"
2280 SELECT EXISTS (
2281 SELECT 1 FROM billing_subscriptions
2282 WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
2283 AND gateway_account_id = $4 AND plan_key = $5
2284 AND status = $6 AND status IN ('active', 'past_due')
2285 AND payment_method_id = $7 AND initial_transaction_id = $8
2286 AND next_renewal_at = $9
2287 )
2288 "#,
2289 )
2290 .bind(reservation.subscription_id().as_uuid())
2291 .bind(identity.billing_scope_id().as_uuid())
2292 .bind(identity.subscriber_id().as_uuid())
2293 .bind(identity.gateway_account_id().as_uuid())
2294 .bind(reservation.plan_key().as_str())
2295 .bind(expected.status().as_str())
2296 .bind(expected.payment_method_id().as_uuid())
2297 .bind(expected.initial_transaction_id().expose())
2298 .bind(reservation.period().start_at())
2299 .fetch_one(&mut **transaction)
2300 .await
2301}
2302
2303async fn renewal_subscription_state_matches(
2304 transaction: &mut Transaction<'_, Postgres>,
2305 reservation: &SubscriptionRenewalReservation,
2306) -> Result<bool, sqlx::Error> {
2307 let identity = reservation.identity();
2308 let expected = reservation.expected_state();
2309 let row = sqlx::query(
2310 r#"
2311 SELECT status, payment_method_id, initial_transaction_id,
2312 amount_cents, currency, next_renewal_at
2313 FROM billing_subscriptions
2314 WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
2315 AND gateway_account_id = $4 AND plan_key = $5
2316 FOR NO KEY UPDATE
2317 "#,
2318 )
2319 .bind(reservation.subscription_id().as_uuid())
2320 .bind(identity.billing_scope_id().as_uuid())
2321 .bind(identity.subscriber_id().as_uuid())
2322 .bind(identity.gateway_account_id().as_uuid())
2323 .bind(reservation.plan_key().as_str())
2324 .fetch_optional(&mut **transaction)
2325 .await?;
2326 let Some(row) = row else {
2327 return Ok(false);
2328 };
2329 let status: String = row.try_get("status")?;
2330 let initial_transaction_id: String = row.try_get("initial_transaction_id")?;
2331 Ok(status == expected.status().as_str()
2332 && matches!(status.as_str(), "active" | "past_due")
2333 && row.try_get::<Uuid, _>("payment_method_id")? == expected.payment_method_id().into_uuid()
2334 && syrup_rail::canonical_gateway_transaction_ids_equal(
2335 &initial_transaction_id,
2336 expected.initial_transaction_id().expose(),
2337 )
2338 && row.try_get::<i32, _>("amount_cents")? == reservation.request().amount().cents()
2339 && row.try_get::<String, _>("currency")?
2340 == reservation.request().amount().currency().as_str()
2341 && row.try_get::<DateTime<Utc>, _>("next_renewal_at")? == *reservation.period().start_at())
2342}
2343
2344async fn insert_renewal_attempt(
2345 transaction: &mut Transaction<'_, Postgres>,
2346 reservation: &SubscriptionRenewalReservation,
2347) -> Result<bool, sqlx::Error> {
2348 let identity = reservation.identity();
2349 let request = reservation.request();
2350 let expected = reservation.expected_state();
2351 let result = sqlx::query(
2352 r#"
2353 INSERT INTO billing_payment_attempts (
2354 id, billing_scope_id, subscriber_id, plan_key, subscription_id,
2355 payment_method_id, attempt_kind, status, idempotency_key,
2356 request_fingerprint, amount_cents, currency,
2357 billing_period_start_at, billing_period_end_at,
2358 gateway_account_id, gateway_configuration_id, gateway_order_id,
2359 billing_name, billing_email,
2360 subscription_expected_payment_method_id,
2361 subscription_expected_initial_transaction_id,
2362 subscription_expected_status
2363 ) VALUES (
2364 $1, $2, $3, $4, $5, $6, 'subscription_renewal', 'pending',
2365 $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
2366 $18, $19, $20
2367 )
2368 ON CONFLICT DO NOTHING
2369 "#,
2370 )
2371 .bind(identity.attempt_id().as_uuid())
2372 .bind(identity.billing_scope_id().as_uuid())
2373 .bind(identity.subscriber_id().as_uuid())
2374 .bind(reservation.plan_key().as_str())
2375 .bind(reservation.subscription_id().as_uuid())
2376 .bind(expected.payment_method_id().as_uuid())
2377 .bind(request.idempotency_key().expose())
2378 .bind(request.fingerprint().expose())
2379 .bind(request.amount().cents())
2380 .bind(request.amount().currency().as_str())
2381 .bind(reservation.period().start_at())
2382 .bind(reservation.period().end_at())
2383 .bind(identity.gateway_account_id().as_uuid())
2384 .bind(identity.gateway_configuration_id().as_uuid())
2385 .bind(request.gateway_order_id().expose())
2386 .bind(request.billing_contact().name())
2387 .bind(request.billing_contact().email())
2388 .bind(expected.payment_method_id().as_uuid())
2389 .bind(expected.initial_transaction_id().expose())
2390 .bind(expected.status().as_str())
2391 .execute(&mut **transaction)
2392 .await?;
2393 Ok(result.rows_affected() == 1)
2394}
2395
2396async fn insert_recovery_attempt(
2397 transaction: &mut Transaction<'_, Postgres>,
2398 reservation: &SubscriptionRecoveryReservation,
2399) -> Result<bool, sqlx::Error> {
2400 let identity = reservation.identity();
2401 let request = reservation.request();
2402 let expected = reservation.expected_state();
2403 let result = sqlx::query(
2404 r#"
2405 INSERT INTO billing_payment_attempts (
2406 id, billing_scope_id, subscriber_id, plan_key, subscription_id,
2407 payment_method_id, attempt_kind, status, idempotency_key,
2408 request_fingerprint, amount_cents, currency,
2409 billing_period_start_at, billing_period_end_at,
2410 gateway_account_id, gateway_configuration_id, gateway_order_id,
2411 billing_name, billing_email,
2412 subscription_expected_payment_method_id,
2413 subscription_expected_initial_transaction_id,
2414 subscription_expected_status
2415 ) VALUES (
2416 $1, $2, $3, $4, $5, $6, 'subscription_recovery', 'pending',
2417 $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
2418 $18, $19, $20
2419 )
2420 ON CONFLICT DO NOTHING
2421 "#,
2422 )
2423 .bind(identity.attempt_id().as_uuid())
2424 .bind(identity.billing_scope_id().as_uuid())
2425 .bind(identity.subscriber_id().as_uuid())
2426 .bind(reservation.plan_key().as_str())
2427 .bind(reservation.subscription_id().as_uuid())
2428 .bind(expected.payment_method_id().as_uuid())
2429 .bind(request.idempotency_key().expose())
2430 .bind(request.fingerprint().expose())
2431 .bind(request.amount().cents())
2432 .bind(request.amount().currency().as_str())
2433 .bind(reservation.period().start_at())
2434 .bind(reservation.period().end_at())
2435 .bind(identity.gateway_account_id().as_uuid())
2436 .bind(identity.gateway_configuration_id().as_uuid())
2437 .bind(request.gateway_order_id().expose())
2438 .bind(request.billing_contact().name())
2439 .bind(request.billing_contact().email())
2440 .bind(expected.payment_method_id().as_uuid())
2441 .bind(expected.initial_transaction_id().expose())
2442 .bind(expected.status().as_str())
2443 .execute(&mut **transaction)
2444 .await?;
2445 Ok(result.rows_affected() == 1)
2446}
2447
2448async fn insert_payment_method_replacement_attempt(
2449 transaction: &mut Transaction<'_, Postgres>,
2450 reservation: &SubscriptionPaymentMethodReplacement,
2451) -> Result<bool, sqlx::Error> {
2452 let identity = reservation.identity();
2453 let request = reservation.request();
2454 let expected = reservation.expected_state();
2455 let result = sqlx::query(
2456 r#"
2457 INSERT INTO billing_payment_attempts (
2458 id, billing_scope_id, subscriber_id, plan_key, subscription_id,
2459 payment_method_id, attempt_kind, status, idempotency_key,
2460 request_fingerprint, amount_cents, currency,
2461 gateway_account_id, gateway_configuration_id, gateway_order_id,
2462 billing_name, billing_email,
2463 payment_method_update_expected_payment_method_id,
2464 payment_method_update_expected_initial_transaction_id
2465 ) VALUES (
2466 $1, $2, $3, $4, $5, $6,
2467 'subscription_payment_method_update', 'pending', $7, $8, 0, $9,
2468 $10, $11, $12, $13, $14, $15, $16
2469 )
2470 ON CONFLICT DO NOTHING
2471 "#,
2472 )
2473 .bind(identity.attempt_id().as_uuid())
2474 .bind(identity.billing_scope_id().as_uuid())
2475 .bind(identity.subscriber_id().as_uuid())
2476 .bind(reservation.plan_key().as_str())
2477 .bind(reservation.subscription_id().as_uuid())
2478 .bind(expected.payment_method_id().as_uuid())
2479 .bind(request.idempotency_key().expose())
2480 .bind(request.fingerprint().expose())
2481 .bind(request.amount().currency().as_str())
2482 .bind(identity.gateway_account_id().as_uuid())
2483 .bind(identity.gateway_configuration_id().as_uuid())
2484 .bind(request.gateway_order_id().expose())
2485 .bind(request.billing_contact().name())
2486 .bind(request.billing_contact().email())
2487 .bind(expected.payment_method_id().as_uuid())
2488 .bind(expected.expected_initial_transaction_id().expose())
2489 .execute(&mut **transaction)
2490 .await?;
2491 Ok(result.rows_affected() == 1)
2492}
2493
2494async fn reject_locked_recovery(
2495 transaction: &mut Transaction<'_, Postgres>,
2496 attempt: PaymentAttempt,
2497 reason: SubscriptionRecoverySubmissionRejection,
2498 message: &'static str,
2499) -> Result<SubscriptionRecoverySubmissionOutcome, PaymentAttemptStoreError> {
2500 let resolution_code = match reason {
2501 SubscriptionRecoverySubmissionRejection::BillingStateChanged => {
2502 PaymentResolutionCode::SubscriptionRenewalRetryStateChangedBeforeCharge
2503 }
2504 SubscriptionRecoverySubmissionRejection::GatewayConfigurationChanged => {
2505 PaymentResolutionCode::GatewayConfigurationBeforeSubmission
2506 }
2507 };
2508 sqlx::query(
2509 r#"
2510 UPDATE billing_payment_attempts
2511 SET status = 'failed', resolution_code = $2,
2512 gateway_response_text = $3,
2513 resolved_at = COALESCE(resolved_at, clock_timestamp()),
2514 updated_at = clock_timestamp()
2515 WHERE id = $1 AND status = 'pending' AND submitted_at IS NULL
2516 "#,
2517 )
2518 .bind(attempt.identity().attempt_id().as_uuid())
2519 .bind(resolution_code.as_str())
2520 .bind(message)
2521 .execute(&mut **transaction)
2522 .await?;
2523 let attempt = find_payment_attempt_by_id_in_transaction(
2524 transaction,
2525 attempt.identity().billing_scope_id(),
2526 attempt.identity().attempt_id(),
2527 )
2528 .await?
2529 .ok_or_else(invalid_state)?;
2530 Ok(SubscriptionRecoverySubmissionOutcome::Rejected { attempt, reason })
2531}
2532
2533async fn reject_locked_renewal(
2534 transaction: &mut Transaction<'_, Postgres>,
2535 attempt: PaymentAttempt,
2536 reason: SubscriptionRenewalSubmissionRejection,
2537 message: &'static str,
2538) -> Result<SubscriptionRenewalSubmissionOutcome, PaymentAttemptStoreError> {
2539 let resolution_code = match reason {
2540 SubscriptionRenewalSubmissionRejection::BillingStateChanged => {
2541 PaymentResolutionCode::SubscriptionRenewalRetryStateChangedBeforeCharge
2542 }
2543 SubscriptionRenewalSubmissionRejection::GatewayConfigurationChanged => {
2544 PaymentResolutionCode::GatewayConfigurationBeforeSubmission
2545 }
2546 };
2547 sqlx::query(
2548 r#"
2549 UPDATE billing_payment_attempts
2550 SET status = 'failed', resolution_code = $2,
2551 gateway_response_text = $3,
2552 resolved_at = COALESCE(resolved_at, clock_timestamp()),
2553 updated_at = clock_timestamp()
2554 WHERE id = $1 AND status = 'pending' AND submitted_at IS NULL
2555 "#,
2556 )
2557 .bind(attempt.identity().attempt_id().as_uuid())
2558 .bind(resolution_code.as_str())
2559 .bind(message)
2560 .execute(&mut **transaction)
2561 .await?;
2562 let attempt = find_payment_attempt_by_id_in_transaction(
2563 transaction,
2564 attempt.identity().billing_scope_id(),
2565 attempt.identity().attempt_id(),
2566 )
2567 .await?
2568 .ok_or_else(invalid_state)?;
2569 Ok(SubscriptionRenewalSubmissionOutcome::Rejected { attempt, reason })
2570}
2571
2572fn map_renewal_store_error(error: crate::RenewalStoreError) -> PaymentAttemptStoreError {
2573 match error {
2574 crate::RenewalStoreError::Sql(error) => PaymentAttemptStoreError::Sql(error),
2575 crate::RenewalStoreError::MissingProviderCooldown => invalid_state(),
2576 }
2577}
2578
2579async fn reject_locked_payment_method_replacement(
2580 transaction: &mut Transaction<'_, Postgres>,
2581 attempt: PaymentAttempt,
2582 reason: SubscriptionPaymentMethodReplacementSubmissionRejection,
2583 message: &'static str,
2584) -> Result<SubscriptionPaymentMethodReplacementSubmissionOutcome, PaymentAttemptStoreError> {
2585 let resolution_code = match reason {
2586 SubscriptionPaymentMethodReplacementSubmissionRejection::BillingStateChanged => {
2587 PaymentResolutionCode::SubscriptionRenewalRetryStateChangedBeforeCharge
2588 }
2589 SubscriptionPaymentMethodReplacementSubmissionRejection::GatewayConfigurationChanged => {
2590 PaymentResolutionCode::GatewayConfigurationBeforeSubmission
2591 }
2592 };
2593 sqlx::query(
2594 r#"
2595 UPDATE billing_payment_attempts
2596 SET status = 'failed', resolution_code = $2,
2597 gateway_response_text = $3,
2598 resolved_at = COALESCE(resolved_at, clock_timestamp()),
2599 updated_at = clock_timestamp()
2600 WHERE id = $1 AND status = 'pending' AND submitted_at IS NULL
2601 "#,
2602 )
2603 .bind(attempt.identity().attempt_id().as_uuid())
2604 .bind(resolution_code.as_str())
2605 .bind(message)
2606 .execute(&mut **transaction)
2607 .await?;
2608 let attempt = find_payment_attempt_by_id_in_transaction(
2609 transaction,
2610 attempt.identity().billing_scope_id(),
2611 attempt.identity().attempt_id(),
2612 )
2613 .await?
2614 .ok_or_else(invalid_state)?;
2615 Ok(SubscriptionPaymentMethodReplacementSubmissionOutcome::Rejected { attempt, reason })
2616}
2617
2618fn replay_matches_reservation(
2619 attempt: &PaymentAttempt,
2620 reservation: &SubscriptionEnrollmentReservation,
2621) -> bool {
2622 let identity = attempt.identity();
2623 identity.billing_scope_id() == reservation.identity().billing_scope_id()
2624 && identity.subscriber_id() == reservation.identity().subscriber_id()
2625 && identity.gateway_account_id() == reservation.identity().gateway_account_id()
2626 && identity.gateway_configuration_id() == reservation.identity().gateway_configuration_id()
2627 && attempt.kind() == PaymentAttemptKind::SubscriptionInitial
2628 && attempt.request().target().plan_key() == Some(reservation.plan_key())
2629 && attempt
2630 .request()
2631 .fingerprint()
2632 .matches_subscription_initial_expected_charge(
2633 reservation.plan_key(),
2634 reservation.expected_charge(),
2635 )
2636}
2637
2638fn replay_matches_command(
2639 attempt: &PaymentAttempt,
2640 command: &syrup_rail::EnrollSubscription,
2641) -> bool {
2642 let identity = attempt.identity();
2643 identity.billing_scope_id() == command.billing_scope_id()
2644 && identity.subscriber_id() == command.subscriber_id()
2645 && identity.gateway_configuration_id() == command.gateway_configuration_id()
2646 && attempt.kind() == PaymentAttemptKind::SubscriptionInitial
2647 && attempt.request().target().plan_key() == Some(command.plan_key())
2648 && attempt
2649 .request()
2650 .fingerprint()
2651 .matches_subscription_initial_expected_charge(
2652 command.plan_key(),
2653 command.expected_charge(),
2654 )
2655}
2656
2657fn pending_attempt_matches_request(
2658 attempt: &PaymentAttempt,
2659 requested_identity: PaymentAttemptIdentity,
2660 requested: &PaymentAttemptRequest,
2661) -> bool {
2662 let identity = attempt.identity();
2663 identity.billing_scope_id() == requested_identity.billing_scope_id()
2664 && identity.subscriber_id() == requested_identity.subscriber_id()
2665 && identity.gateway_account_id() == requested_identity.gateway_account_id()
2666 && identity.gateway_configuration_id() == requested_identity.gateway_configuration_id()
2667 && attempt.status() == PaymentAttemptStatus::Pending
2668 && attempt.state().timestamps().submitted_at().is_none()
2669 && attempt.request().target() == requested.target()
2670 && attempt.request().fingerprint() == requested.fingerprint()
2671 && attempt.request().amount() == requested.amount()
2672}
2673
2674fn attempt_identity_matches_requested_gateway(
2675 attempt: &PaymentAttempt,
2676 requested: PaymentAttemptIdentity,
2677) -> bool {
2678 let identity = attempt.identity();
2679 identity.billing_scope_id() == requested.billing_scope_id()
2680 && identity.subscriber_id() == requested.subscriber_id()
2681 && identity.gateway_account_id() == requested.gateway_account_id()
2682 && identity.gateway_configuration_id() == requested.gateway_configuration_id()
2683}
2684
2685async fn insert_initial_attempt(
2686 transaction: &mut Transaction<'_, Postgres>,
2687 identity: PaymentAttemptIdentity,
2688 request: &PaymentAttemptRequest,
2689) -> Result<bool, PaymentAttemptStoreError> {
2690 let plan_key = request.target().plan_key().ok_or_else(invalid_state)?;
2691 let discount = request.target().enrollment_discount();
2692 let snapshot = discount.map(SubscriptionEnrollmentDiscountSnapshot::snapshot);
2693 let kind = snapshot.map(|snapshot| snapshot.kind());
2694 let duration = snapshot.map(|snapshot| snapshot.duration());
2695 let result = sqlx::query(
2696 r#"
2697 INSERT INTO billing_payment_attempts (
2698 id, billing_scope_id, subscriber_id, plan_key, attempt_kind, status,
2699 idempotency_key, request_fingerprint, amount_cents, currency,
2700 gateway_account_id, gateway_configuration_id, gateway_order_id,
2701 subscription_initial_discount_claim_id,
2702 subscription_initial_discount_code_id,
2703 subscription_initial_discount_code_snapshot,
2704 subscription_initial_discount_label_snapshot,
2705 subscription_initial_discount_kind,
2706 subscription_initial_discount_amount_off_cents,
2707 subscription_initial_discount_percent_off_bps,
2708 subscription_initial_discount_currency,
2709 subscription_initial_discount_duration,
2710 subscription_initial_discount_duration_months,
2711 subscription_initial_discount_base_amount_cents,
2712 subscription_initial_discount_discounted_amount_cents,
2713 billing_name, billing_email
2714 ) VALUES (
2715 $1, $2, $3, $4, 'subscription_initial', 'pending',
2716 $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16,
2717 $17, $18, $19, $20, $21, $22, $23, $24, $25
2718 )
2719 ON CONFLICT (billing_scope_id, subscriber_id, idempotency_key) DO NOTHING
2720 "#,
2721 )
2722 .bind(identity.attempt_id().as_uuid())
2723 .bind(identity.billing_scope_id().as_uuid())
2724 .bind(identity.subscriber_id().as_uuid())
2725 .bind(plan_key.as_str())
2726 .bind(request.idempotency_key().expose())
2727 .bind(request.fingerprint().expose())
2728 .bind(request.amount().cents())
2729 .bind(request.amount().currency().as_str())
2730 .bind(identity.gateway_account_id().as_uuid())
2731 .bind(identity.gateway_configuration_id().as_uuid())
2732 .bind(request.gateway_order_id().expose())
2733 .bind(discount.map(|discount| discount.claim_id().into_uuid()))
2734 .bind(discount.map(|discount| discount.code_id().into_uuid()))
2735 .bind(snapshot.map(|snapshot| snapshot.code().as_str()))
2736 .bind(snapshot.and_then(|snapshot| snapshot.label()))
2737 .bind(kind.map(|kind| kind.as_str()))
2738 .bind(kind.and_then(discount_amount_off))
2739 .bind(kind.and_then(discount_percent_off))
2740 .bind(snapshot.map(|snapshot| snapshot.currency().as_str()))
2741 .bind(duration.map(|duration| duration.as_str()))
2742 .bind(duration.and_then(discount_duration_months))
2743 .bind(snapshot.map(|snapshot| snapshot.base_charge().cents()))
2744 .bind(snapshot.map(|snapshot| snapshot.discounted_charge().cents()))
2745 .bind(request.billing_contact().name())
2746 .bind(request.billing_contact().email())
2747 .execute(&mut **transaction)
2748 .await?;
2749 Ok(result.rows_affected() == 1)
2750}
2751
2752fn discount_amount_off(kind: SubscriptionDiscountKind) -> Option<i32> {
2753 match kind {
2754 SubscriptionDiscountKind::AmountOffCents(value) => Some(value.get()),
2755 SubscriptionDiscountKind::PercentOffBasisPoints(_) => None,
2756 }
2757}
2758
2759fn discount_percent_off(kind: SubscriptionDiscountKind) -> Option<i32> {
2760 match kind {
2761 SubscriptionDiscountKind::AmountOffCents(_) => None,
2762 SubscriptionDiscountKind::PercentOffBasisPoints(value) => Some(i32::from(value.get())),
2763 }
2764}
2765
2766fn discount_duration_months(duration: SubscriptionDiscountDuration) -> Option<i32> {
2767 match duration {
2768 SubscriptionDiscountDuration::Indefinite => None,
2769 SubscriptionDiscountDuration::LimitedMonths(value) => Some(i32::from(value.get())),
2770 }
2771}
2772
2773async fn reject_prepared_initial(
2774 transaction: &mut Transaction<'_, Postgres>,
2775 reservation: &SubscriptionEnrollmentReservation,
2776 reason: SubscriptionEnrollmentSubmissionRejection,
2777 message: &'static str,
2778) -> Result<SubscriptionEnrollmentSubmissionOutcome, PaymentAttemptStoreError> {
2779 let identity = reservation.identity();
2780 let attempt = payment_attempt_by_idempotency(
2781 transaction,
2782 identity.billing_scope_id(),
2783 identity.subscriber_id(),
2784 reservation.idempotency_key(),
2785 true,
2786 )
2787 .await?
2788 .ok_or_else(invalid_state)?;
2789 if !attempt_identity_matches_requested_gateway(&attempt, identity)
2790 || attempt.kind() != PaymentAttemptKind::SubscriptionInitial
2791 {
2792 return Err(invalid_state());
2793 }
2794 if attempt.status() != PaymentAttemptStatus::Pending
2795 || attempt.state().timestamps().submitted_at().is_some()
2796 {
2797 return Ok(SubscriptionEnrollmentSubmissionOutcome::AlreadyAdmitted(
2798 attempt,
2799 ));
2800 }
2801 reject_locked_prepared_initial(transaction, attempt, reason, message).await
2802}
2803
2804async fn reject_locked_prepared_initial(
2805 transaction: &mut Transaction<'_, Postgres>,
2806 attempt: PaymentAttempt,
2807 reason: SubscriptionEnrollmentSubmissionRejection,
2808 message: &'static str,
2809) -> Result<SubscriptionEnrollmentSubmissionOutcome, PaymentAttemptStoreError> {
2810 let identity = attempt.identity();
2811 let result = sqlx::query(
2812 r#"
2813 UPDATE billing_payment_attempts
2814 SET status = 'failed', gateway_response_text = $2,
2815 gateway_condition = 'failed', resolved_at = clock_timestamp(),
2816 updated_at = clock_timestamp()
2817 WHERE id = $1 AND status = 'pending' AND submitted_at IS NULL
2818 "#,
2819 )
2820 .bind(identity.attempt_id().as_uuid())
2821 .bind(message)
2822 .execute(&mut **transaction)
2823 .await?;
2824 if result.rows_affected() != 1 {
2825 return Err(invalid_state());
2826 }
2827 let attempt = find_payment_attempt_by_id_in_transaction(
2828 transaction,
2829 identity.billing_scope_id(),
2830 identity.attempt_id(),
2831 )
2832 .await?
2833 .ok_or_else(invalid_state)?;
2834 Ok(SubscriptionEnrollmentSubmissionOutcome::Rejected { attempt, reason })
2835}
2836
2837fn map_discount_error(
2838 error: crate::SubscriptionDiscountOperationError,
2839) -> PaymentAttemptStoreError {
2840 match error {
2841 crate::SubscriptionDiscountOperationError::Sql(error) => {
2842 PaymentAttemptStoreError::Sql(error)
2843 }
2844 _ => invalid_state(),
2845 }
2846}
2847
2848pub(crate) fn payment_attempt_from_row(
2849 row: &PgRow,
2850) -> Result<PaymentAttempt, PaymentAttemptStoreError> {
2851 let attempt_id = PaymentAttemptId::new(row.try_get("id")?);
2852 let identity = PaymentAttemptIdentity::new(
2853 attempt_id,
2854 BillingScopeId::new(row.try_get("billing_scope_id")?),
2855 SubscriberId::new(row.try_get("subscriber_id")?),
2856 GatewayAccountId::new(row.try_get("gateway_account_id")?),
2857 GatewayConfigurationId::new(row.try_get("gateway_configuration_id")?),
2858 );
2859 let kind = row
2860 .try_get::<String, _>("attempt_kind")?
2861 .parse::<PaymentAttemptKind>()
2862 .map_err(|_| invalid_state())?;
2863 let status = row
2864 .try_get::<String, _>("status")?
2865 .parse::<PaymentAttemptStatus>()
2866 .map_err(|_| invalid_state())?;
2867 let target = payment_attempt_target_from_row(row, kind, status)?;
2868 let currency =
2869 CurrencyCode::new(&row.try_get::<String, _>("currency")?).map_err(|_| invalid_state())?;
2870 let amount = Money::new(row.try_get("amount_cents")?, currency).map_err(|_| invalid_state())?;
2871 let order_value = row.try_get::<String, _>("gateway_order_id")?;
2872 let gateway_order_id = GatewayOrderId::from_generated_attempt(&order_value, attempt_id)
2873 .or_else(|_| GatewayOrderId::from_correlation(&order_value))
2874 .map_err(|_| invalid_state())?;
2875 let request = PaymentAttemptRequest::new(
2876 target,
2877 IdempotencyKey::new(row.try_get::<String, _>("idempotency_key")?)
2878 .map_err(|_| invalid_state())?,
2879 PaymentAttemptFingerprint::new(row.try_get::<String, _>("request_fingerprint")?)
2880 .map_err(|_| invalid_state())?,
2881 amount,
2882 gateway_order_id,
2883 BillingContactSnapshot::new(row.try_get("billing_name")?, row.try_get("billing_email")?),
2884 );
2885 let state = PaymentAttemptState::new(
2886 status,
2887 row.try_get::<Option<String>, _>("resolution_code")?
2888 .as_deref()
2889 .map(PaymentResolutionCode::try_from)
2890 .transpose()
2891 .map_err(|_| invalid_state())?,
2892 processor_evidence_from_row(row)?,
2893 lifecycle_from_row(row)?,
2894 PaymentAttemptTimestamps::new(
2895 row.try_get("submitted_at")?,
2896 row.try_get("resolved_at")?,
2897 row.try_get("review_required_at")?,
2898 row.try_get("created_at")?,
2899 row.try_get("updated_at")?,
2900 ),
2901 );
2902 PaymentAttempt::new(identity, request, state).map_err(|_| invalid_state())
2903}
2904
2905fn payment_attempt_target_from_row(
2906 row: &PgRow,
2907 kind: PaymentAttemptKind,
2908 status: PaymentAttemptStatus,
2909) -> Result<PaymentAttemptTarget, PaymentAttemptStoreError> {
2910 let plan_key = row
2911 .try_get::<Option<String>, _>("plan_key")?
2912 .map(PlanKey::new)
2913 .transpose()
2914 .map_err(|_| invalid_state())?;
2915 let host_target = row
2916 .try_get::<Option<Uuid>, _>("host_charge_target_id")?
2917 .map(HostChargeTargetId::new);
2918 let subscription_id = row
2919 .try_get::<Option<Uuid>, _>("subscription_id")?
2920 .map(SubscriptionId::new);
2921 let payment_method_id = row
2922 .try_get::<Option<Uuid>, _>("payment_method_id")?
2923 .map(PaymentMethodId::new);
2924 let period = period_from_row(row)?;
2925 let method_update_snapshot = payment_method_update_snapshot_from_row(row, subscription_id)?;
2926 let subscription_snapshot = subscription_snapshot_from_row(row, subscription_id)?;
2927 let discount = enrollment_discount_from_row(row)?;
2928
2929 match kind {
2930 PaymentAttemptKind::HostCharge
2931 if plan_key.is_none()
2932 && subscription_id.is_none()
2933 && payment_method_id.is_none()
2934 && period.is_none()
2935 && method_update_snapshot.is_none()
2936 && subscription_snapshot.is_none()
2937 && discount.is_none() =>
2938 {
2939 Ok(PaymentAttemptTarget::HostCharge {
2940 target_id: host_target.ok_or_else(invalid_state)?,
2941 })
2942 }
2943 PaymentAttemptKind::SubscriptionInitial
2944 if host_target.is_none()
2945 && period.is_none()
2946 && method_update_snapshot.is_none()
2947 && subscription_snapshot.is_none() =>
2948 {
2949 let application = match (subscription_id, payment_method_id) {
2950 (subscription_id, Some(payment_method_id)) => Some(
2951 SubscriptionInitialApplication::new(subscription_id, payment_method_id),
2952 ),
2953 (None, None) => None,
2954 (Some(_), None) => return Err(invalid_state()),
2955 };
2956 if matches!(
2957 status,
2958 PaymentAttemptStatus::Pending | PaymentAttemptStatus::Unknown
2959 ) && application.is_some()
2960 {
2961 return Err(invalid_state());
2962 }
2963 Ok(PaymentAttemptTarget::SubscriptionInitial {
2964 plan_key: plan_key.ok_or_else(invalid_state)?,
2965 discount,
2966 application,
2967 })
2968 }
2969 PaymentAttemptKind::SubscriptionRenewal
2970 if host_target.is_none() && method_update_snapshot.is_none() && discount.is_none() =>
2971 {
2972 Ok(PaymentAttemptTarget::SubscriptionRenewal {
2973 plan_key: plan_key.ok_or_else(invalid_state)?,
2974 payment_method_id: payment_method_id.ok_or_else(invalid_state)?,
2975 period: period.ok_or_else(invalid_state)?,
2976 expected_state: subscription_snapshot.ok_or_else(invalid_state)?,
2977 })
2978 }
2979 PaymentAttemptKind::SubscriptionRecovery
2980 if host_target.is_none() && method_update_snapshot.is_none() && discount.is_none() =>
2981 {
2982 Ok(PaymentAttemptTarget::SubscriptionRecovery {
2983 plan_key: plan_key.ok_or_else(invalid_state)?,
2984 payment_method_id: payment_method_id.ok_or_else(invalid_state)?,
2985 period: period.ok_or_else(invalid_state)?,
2986 expected_state: subscription_snapshot.ok_or_else(invalid_state)?,
2987 })
2988 }
2989 PaymentAttemptKind::SubscriptionPaymentMethodUpdate
2990 if host_target.is_none()
2991 && period.is_none()
2992 && subscription_snapshot.is_none()
2993 && discount.is_none() =>
2994 {
2995 Ok(PaymentAttemptTarget::SubscriptionPaymentMethodUpdate {
2996 plan_key: plan_key.ok_or_else(invalid_state)?,
2997 payment_method_id: payment_method_id.ok_or_else(invalid_state)?,
2998 expected_state: method_update_snapshot.ok_or_else(invalid_state)?,
2999 })
3000 }
3001 _ => Err(invalid_state()),
3002 }
3003}
3004
3005fn period_from_row(row: &PgRow) -> Result<Option<BillingPeriod>, PaymentAttemptStoreError> {
3006 let start = row.try_get::<Option<DateTime<Utc>>, _>("billing_period_start_at")?;
3007 let end = row.try_get::<Option<DateTime<Utc>>, _>("billing_period_end_at")?;
3008 match (start, end) {
3009 (None, None) => Ok(None),
3010 (Some(start), Some(end)) => BillingPeriod::new(start, end)
3011 .map(Some)
3012 .map_err(|_| invalid_state()),
3013 _ => Err(invalid_state()),
3014 }
3015}
3016
3017fn payment_method_update_snapshot_from_row(
3018 row: &PgRow,
3019 subscription_id: Option<SubscriptionId>,
3020) -> Result<Option<PaymentMethodUpdateSnapshot>, PaymentAttemptStoreError> {
3021 let expected_method = row
3022 .try_get::<Option<Uuid>, _>("payment_method_update_expected_payment_method_id")?
3023 .map(PaymentMethodId::new);
3024 let expected_transaction =
3025 row.try_get::<Option<String>, _>("payment_method_update_expected_initial_transaction_id")?;
3026 match (subscription_id, expected_method, expected_transaction) {
3027 (Some(subscription_id), Some(payment_method_id), Some(transaction)) => {
3028 Ok(Some(PaymentMethodUpdateSnapshot::new(
3029 subscription_id,
3030 payment_method_id,
3031 GatewayTransactionId::new(transaction).map_err(|_| invalid_state())?,
3032 )))
3033 }
3034 (_, None, None) => Ok(None),
3035 _ => Err(invalid_state()),
3036 }
3037}
3038
3039fn subscription_snapshot_from_row(
3040 row: &PgRow,
3041 subscription_id: Option<SubscriptionId>,
3042) -> Result<Option<SubscriptionPaymentStateSnapshot>, PaymentAttemptStoreError> {
3043 let expected_method = row
3044 .try_get::<Option<Uuid>, _>("subscription_expected_payment_method_id")?
3045 .map(PaymentMethodId::new);
3046 let expected_transaction =
3047 row.try_get::<Option<String>, _>("subscription_expected_initial_transaction_id")?;
3048 let expected_status = row.try_get::<Option<String>, _>("subscription_expected_status")?;
3049 match (
3050 subscription_id,
3051 expected_method,
3052 expected_transaction,
3053 expected_status,
3054 ) {
3055 (Some(subscription_id), Some(payment_method_id), Some(transaction), Some(status)) => {
3056 Ok(Some(
3057 SubscriptionPaymentStateSnapshot::new(
3058 subscription_id,
3059 payment_method_id,
3060 GatewayTransactionId::new(transaction).map_err(|_| invalid_state())?,
3061 status
3062 .parse::<SubscriptionStatus>()
3063 .map_err(|_| invalid_state())?,
3064 )
3065 .map_err(|_| invalid_state())?,
3066 ))
3067 }
3068 (_, None, None, None) => Ok(None),
3069 _ => Err(invalid_state()),
3070 }
3071}
3072
3073fn enrollment_discount_from_row(
3074 row: &PgRow,
3075) -> Result<Option<SubscriptionEnrollmentDiscountSnapshot>, PaymentAttemptStoreError> {
3076 let claim_id = row
3077 .try_get::<Option<Uuid>, _>("subscription_initial_discount_claim_id")?
3078 .map(DiscountClaimId::new);
3079 let code_id = row
3080 .try_get::<Option<Uuid>, _>("subscription_initial_discount_code_id")?
3081 .map(DiscountCodeId::new);
3082 let code = row.try_get::<Option<String>, _>("subscription_initial_discount_code_snapshot")?;
3083 let label = row.try_get::<Option<String>, _>("subscription_initial_discount_label_snapshot")?;
3084 let kind = row.try_get::<Option<String>, _>("subscription_initial_discount_kind")?;
3085 let amount_off =
3086 row.try_get::<Option<i32>, _>("subscription_initial_discount_amount_off_cents")?;
3087 let percent_off =
3088 row.try_get::<Option<i32>, _>("subscription_initial_discount_percent_off_bps")?;
3089 let currency = row.try_get::<Option<String>, _>("subscription_initial_discount_currency")?;
3090 let duration = row.try_get::<Option<String>, _>("subscription_initial_discount_duration")?;
3091 let duration_months =
3092 row.try_get::<Option<i32>, _>("subscription_initial_discount_duration_months")?;
3093 let base_amount =
3094 row.try_get::<Option<i32>, _>("subscription_initial_discount_base_amount_cents")?;
3095 let discounted_amount =
3096 row.try_get::<Option<i32>, _>("subscription_initial_discount_discounted_amount_cents")?;
3097
3098 if claim_id.is_none()
3099 && code_id.is_none()
3100 && code.is_none()
3101 && label.is_none()
3102 && kind.is_none()
3103 && amount_off.is_none()
3104 && percent_off.is_none()
3105 && currency.is_none()
3106 && duration.is_none()
3107 && duration_months.is_none()
3108 && base_amount.is_none()
3109 && discounted_amount.is_none()
3110 {
3111 return Ok(None);
3112 }
3113
3114 let kind = match (kind.as_deref(), amount_off, percent_off) {
3115 (Some("amount_off"), Some(value), None) => SubscriptionDiscountKind::AmountOffCents(
3116 PositiveDiscountCents::new(value).map_err(|_| invalid_state())?,
3117 ),
3118 (Some("percent_off"), None, Some(value)) => {
3119 SubscriptionDiscountKind::PercentOffBasisPoints(
3120 PercentOffBasisPoints::new(u16::try_from(value).map_err(|_| invalid_state())?)
3121 .map_err(|_| invalid_state())?,
3122 )
3123 }
3124 _ => return Err(invalid_state()),
3125 };
3126 let duration = match (duration.as_deref(), duration_months) {
3127 (Some("indefinite"), None) => SubscriptionDiscountDuration::Indefinite,
3128 (Some("limited_months"), Some(value)) => SubscriptionDiscountDuration::LimitedMonths(
3129 LimitedDiscountMonths::new(u8::try_from(value).map_err(|_| invalid_state())?)
3130 .map_err(|_| invalid_state())?,
3131 ),
3132 _ => return Err(invalid_state()),
3133 };
3134 let currency = CurrencyCode::new(currency.as_deref().ok_or_else(invalid_state)?)
3135 .map_err(|_| invalid_state())?;
3136 let snapshot = SubscriptionDiscountSnapshot::new(
3137 SubscriptionDiscountCode::new(code.as_deref().ok_or_else(invalid_state)?)
3138 .map_err(|_| invalid_state())?,
3139 label,
3140 kind,
3141 duration,
3142 ChargeAmount::new(base_amount.ok_or_else(invalid_state)?, currency)
3143 .map_err(|_| invalid_state())?,
3144 ChargeAmount::new(discounted_amount.ok_or_else(invalid_state)?, currency)
3145 .map_err(|_| invalid_state())?,
3146 )
3147 .map_err(|_| invalid_state())?;
3148 Ok(Some(SubscriptionEnrollmentDiscountSnapshot::new(
3149 claim_id.ok_or_else(invalid_state)?,
3150 code_id.ok_or_else(invalid_state)?,
3151 snapshot,
3152 )))
3153}
3154
3155pub(crate) fn processor_evidence_from_row(
3156 row: &PgRow,
3157) -> Result<ProcessorEvidence, PaymentAttemptStoreError> {
3158 let card_last_four = row.try_get::<Option<String>, _>("card_last4")?;
3159 let card_exp_month = row.try_get::<Option<i16>, _>("card_exp_month")?;
3160 let card_exp_year = row.try_get::<Option<i16>, _>("card_exp_year")?;
3161 let descriptor = GatewayPaymentDescriptor::from_provider_parts(
3162 diagnostic(row, "payment_type")?,
3163 diagnostic(row, "card_brand")?,
3164 card_last_four.as_deref(),
3165 card_exp_month,
3166 card_exp_year,
3167 );
3168 if descriptor.card_last_four().is_some() != card_last_four.is_some()
3169 || descriptor.card_exp_month() != card_exp_month
3170 || descriptor.card_exp_year() != card_exp_year
3171 {
3172 return Err(invalid_state());
3173 }
3174 Ok(ProcessorEvidence::new(
3175 row.try_get::<Option<String>, _>("gateway_transaction_id")?
3176 .map(GatewayTransactionId::new)
3177 .transpose()
3178 .map_err(|_| invalid_state())?,
3179 row.try_get::<Option<String>, _>("gateway_payment_method_reference")?
3180 .map(GatewayPaymentMethodReference::new)
3181 .transpose()
3182 .map_err(|_| invalid_state())?,
3183 diagnostic(row, "gateway_response")?,
3184 diagnostic(row, "gateway_response_code")?,
3185 diagnostic(row, "gateway_response_text")?,
3186 diagnostic(row, "gateway_condition")?,
3187 descriptor,
3188 ))
3189}
3190
3191fn diagnostic(row: &PgRow, column: &'static str) -> Result<Option<GatewayDiagnostic>, sqlx::Error> {
3192 row.try_get::<Option<String>, _>(column)
3193 .map(|value| value.map(|value| GatewayDiagnostic::new(&value)))
3194}
3195
3196fn lifecycle_from_row(row: &PgRow) -> Result<PaymentAttemptLifecycle, PaymentAttemptStoreError> {
3197 let refunded = row.try_get::<i32, _>("refunded_amount_cents")?;
3198 let state = match row
3199 .try_get::<String, _>("gateway_lifecycle_status")?
3200 .as_str()
3201 {
3202 "unknown" if refunded == 0 => GatewayLifecycleState::Unknown,
3203 "pending_settlement" if refunded == 0 => GatewayLifecycleState::PendingSettlement,
3204 "voided" if refunded == 0 => GatewayLifecycleState::Voided,
3205 "settled" if refunded == 0 => GatewayLifecycleState::Settled {
3206 cumulative_refunded_cents: None,
3207 },
3208 "settled" if refunded > 0 => GatewayLifecycleState::Settled {
3209 cumulative_refunded_cents: Some(
3210 CumulativeRefundCents::new(refunded).map_err(|_| invalid_state())?,
3211 ),
3212 },
3213 "refunded" => GatewayLifecycleState::Refunded {
3214 cumulative_refunded_cents: CumulativeRefundCents::new(refunded)
3215 .map_err(|_| invalid_state())?,
3216 },
3217 "chargeback" if refunded == 0 => GatewayLifecycleState::Chargeback {
3218 cumulative_refunded_cents: None,
3219 },
3220 "chargeback" if refunded > 0 => GatewayLifecycleState::Chargeback {
3221 cumulative_refunded_cents: Some(
3222 CumulativeRefundCents::new(refunded).map_err(|_| invalid_state())?,
3223 ),
3224 },
3225 _ => return Err(invalid_state()),
3226 };
3227 Ok(PaymentAttemptLifecycle::new(
3228 state,
3229 diagnostic(row, "gateway_lifecycle_action")?,
3230 row.try_get("gateway_lifecycle_at")?,
3231 row.try_get("gateway_lifecycle_reconciled_at")?,
3232 ))
3233}
3234
3235const fn invalid_state() -> PaymentAttemptStoreError {
3236 PaymentAttemptStoreError::InvalidState(INVALID_ATTEMPT_STATE)
3237}
3238
3239#[cfg(test)]
3240mod tests {
3241 use std::{error::Error, sync::Arc};
3242
3243 use async_trait::async_trait;
3244 use chrono::Duration;
3245
3246 use super::*;
3247 use crate::test_support::{TestDatabase, create_gateway_account};
3248
3249 struct TestOfferStore;
3250
3251 #[async_trait]
3252 impl crate::SubscriptionOfferStore for TestOfferStore {
3253 async fn lock_current_offer(
3254 &self,
3255 connection: &mut sqlx::PgConnection,
3256 billing_scope_id: BillingScopeId,
3257 plan_key: &PlanKey,
3258 ) -> Result<Option<syrup_rail::SubscriptionOffer>, sqlx::Error> {
3259 let row = sqlx::query_as::<_, (i32, String)>(
3260 r#"
3261 SELECT amount_cents, currency FROM host_subscription_offers
3262 WHERE billing_scope_id = $1 AND plan_key = $2 FOR SHARE
3263 "#,
3264 )
3265 .bind(billing_scope_id.as_uuid())
3266 .bind(plan_key.as_str())
3267 .fetch_optional(connection)
3268 .await?;
3269 row.map(|(amount, currency)| {
3270 let currency = CurrencyCode::new(¤cy)
3271 .map_err(|_| sqlx::Error::Protocol("invalid host currency".to_owned()))?;
3272 let charge = ChargeAmount::new(amount, currency)
3273 .map_err(|_| sqlx::Error::Protocol("invalid host charge".to_owned()))?;
3274 Ok(syrup_rail::SubscriptionOffer::new(plan_key.clone(), charge))
3275 })
3276 .transpose()
3277 }
3278 }
3279
3280 struct TestReferenceFactory;
3281
3282 impl syrup_rail::GatewayMutationReferenceFactory for TestReferenceFactory {
3283 fn for_attempt(
3284 &self,
3285 kind: PaymentAttemptKind,
3286 attempt_id: PaymentAttemptId,
3287 ) -> GatewayOrderId {
3288 assert_eq!(kind, PaymentAttemptKind::SubscriptionInitial);
3289 GatewayOrderId::from_generated_attempt(
3290 format!("sr_initial_{}", attempt_id.as_uuid().simple()),
3291 attempt_id,
3292 )
3293 .expect("test order ID should be canonical")
3294 }
3295 }
3296
3297 struct NeverCalledGateway;
3298
3299 #[async_trait]
3300 impl syrup_rail::PaymentGateway for NeverCalledGateway {
3301 async fn account_mode(
3302 &self,
3303 ) -> Result<syrup_rail::GatewayAccountMode, syrup_rail::GatewayError> {
3304 panic!("reservation must not perform provider I/O")
3305 }
3306
3307 async fn sale(
3308 &self,
3309 _request: syrup_rail::GatewaySaleRequest,
3310 ) -> Result<syrup_rail::GatewayPaymentOutcome, syrup_rail::GatewayMutationError> {
3311 panic!("reservation must not perform provider I/O")
3312 }
3313
3314 async fn store_payment_method(
3315 &self,
3316 _request: syrup_rail::GatewayStorePaymentMethodRequest,
3317 ) -> Result<syrup_rail::GatewayPaymentOutcome, syrup_rail::GatewayMutationError> {
3318 panic!("reservation must not perform provider I/O")
3319 }
3320
3321 async fn query_transaction(
3322 &self,
3323 _request: syrup_rail::GatewayQueryRequest,
3324 ) -> Result<Option<syrup_rail::GatewayPaymentOutcome>, syrup_rail::GatewayError> {
3325 panic!("reservation must not perform provider I/O")
3326 }
3327
3328 async fn query_transaction_reports(
3329 &self,
3330 _request: syrup_rail::GatewayTransactionReportRequest,
3331 ) -> Result<Vec<syrup_rail::GatewayTransactionReport>, syrup_rail::GatewayError> {
3332 panic!("reservation must not perform provider I/O")
3333 }
3334 }
3335
3336 async fn install_host_offers(database: &TestDatabase) -> Result<(), sqlx::Error> {
3337 sqlx::query(
3338 r#"
3339 CREATE TABLE host_subscription_offers (
3340 billing_scope_id uuid NOT NULL,
3341 plan_key text NOT NULL,
3342 amount_cents integer NOT NULL,
3343 currency text NOT NULL,
3344 PRIMARY KEY (billing_scope_id, plan_key)
3345 )
3346 "#,
3347 )
3348 .execute(&database.pool)
3349 .await?;
3350 Ok(())
3351 }
3352
3353 async fn set_offer(
3354 database: &TestDatabase,
3355 scope_id: Uuid,
3356 plan_key: &str,
3357 amount_cents: i32,
3358 ) -> Result<(), sqlx::Error> {
3359 sqlx::query(
3360 r#"
3361 INSERT INTO host_subscription_offers (
3362 billing_scope_id, plan_key, amount_cents, currency
3363 ) VALUES ($1, $2, $3, 'USD')
3364 ON CONFLICT (billing_scope_id, plan_key)
3365 DO UPDATE SET amount_cents = EXCLUDED.amount_cents
3366 "#,
3367 )
3368 .bind(scope_id)
3369 .bind(plan_key)
3370 .bind(amount_cents)
3371 .execute(&database.pool)
3372 .await?;
3373 Ok(())
3374 }
3375
3376 fn resolved_gateway(
3377 account: crate::test_support::GatewayAccountFixture,
3378 ) -> syrup_rail::ResolvedGateway {
3379 syrup_rail::ResolvedGateway::new(
3380 BillingScopeId::new(account.billing_scope_id),
3381 GatewayAccountId::new(account.gateway_account_id),
3382 GatewayConfigurationId::new(account.gateway_configuration_id),
3383 syrup_rail::GatewayProviderKey::new("nmi").unwrap(),
3384 syrup_rail::GatewayLifecycleQueryPolicy::new(
3385 syrup_rail::GatewayLifecycleCursorKey::new("test_cursor").unwrap(),
3386 Duration::minutes(1),
3387 10,
3388 2,
3389 2,
3390 20,
3391 )
3392 .unwrap(),
3393 Arc::new(TestReferenceFactory),
3394 Arc::new(NeverCalledGateway),
3395 )
3396 }
3397
3398 fn enrollment_command(
3399 account: crate::test_support::GatewayAccountFixture,
3400 subscriber_id: Uuid,
3401 attempt_id: Uuid,
3402 idempotency_key: &str,
3403 expected_charge: syrup_rail::SubscriptionEnrollmentExpectedCharge,
3404 ) -> syrup_rail::EnrollSubscription {
3405 syrup_rail::EnrollSubscription::new(
3406 PaymentAttemptId::new(attempt_id),
3407 BillingScopeId::new(account.billing_scope_id),
3408 SubscriberId::new(subscriber_id),
3409 GatewayConfigurationId::new(account.gateway_configuration_id),
3410 IdempotencyKey::new(idempotency_key).unwrap(),
3411 syrup_rail::PaymentToken::new("token-secret").unwrap(),
3412 syrup_rail::BillingContact::new(
3413 Some("Sensitive".to_owned()),
3414 Some("Name".to_owned()),
3415 Some("secret@example.test".to_owned()),
3416 )
3417 .unwrap(),
3418 expected_charge,
3419 )
3420 }
3421
3422 fn full_price(
3423 plan_key: &str,
3424 amount_cents: i32,
3425 ) -> syrup_rail::SubscriptionEnrollmentExpectedCharge {
3426 syrup_rail::SubscriptionEnrollmentExpectedCharge::full_price(
3427 syrup_rail::SubscriptionOffer::new(
3428 PlanKey::new(plan_key).unwrap(),
3429 ChargeAmount::new(amount_cents, CurrencyCode::new("USD").unwrap()).unwrap(),
3430 ),
3431 )
3432 }
3433
3434 async fn create_discount_code(
3435 database: &TestDatabase,
3436 scope_id: Uuid,
3437 plan_key: &str,
3438 ) -> Result<Uuid, sqlx::Error> {
3439 let code_id = Uuid::now_v7();
3440 sqlx::query(
3441 r#"
3442 INSERT INTO billing_subscription_discount_codes (
3443 id, billing_scope_id, plan_key, code_normalized, display_code,
3444 label, status, discount_kind, percent_off_bps, currency,
3445 duration, duration_months
3446 ) VALUES (
3447 $1, $2, $3, 'SAVE20', 'SAVE20', 'Sensitive campaign',
3448 'active', 'percent_off', 2000, 'USD', 'limited_months', 3
3449 )
3450 "#,
3451 )
3452 .bind(code_id)
3453 .bind(scope_id)
3454 .bind(plan_key)
3455 .execute(&database.pool)
3456 .await?;
3457 Ok(code_id)
3458 }
3459
3460 async fn create_saved_claim(
3461 database: &TestDatabase,
3462 scope_id: Uuid,
3463 subscriber_id: Uuid,
3464 plan_key: &str,
3465 code_id: Uuid,
3466 ) -> Result<Uuid, sqlx::Error> {
3467 let claim_id = Uuid::now_v7();
3468 sqlx::query(
3469 r#"
3470 INSERT INTO billing_subscription_discount_claims (
3471 id, billing_scope_id, subscriber_id, plan_key,
3472 discount_code_id, code_snapshot, label_snapshot,
3473 discount_kind, percent_off_bps, currency, duration,
3474 duration_months, base_amount_cents, discounted_amount_cents,
3475 status
3476 ) VALUES (
3477 $1, $2, $3, $4, $5, 'SAVE20', 'Sensitive campaign',
3478 'percent_off', 2000, 'USD', 'limited_months', 3,
3479 1000, 800, 'saved'
3480 )
3481 "#,
3482 )
3483 .bind(claim_id)
3484 .bind(scope_id)
3485 .bind(subscriber_id)
3486 .bind(plan_key)
3487 .bind(code_id)
3488 .execute(&database.pool)
3489 .await?;
3490 Ok(claim_id)
3491 }
3492
3493 fn discounted_expected(plan_key: &str) -> syrup_rail::SubscriptionEnrollmentExpectedCharge {
3494 let currency = CurrencyCode::new("USD").unwrap();
3495 syrup_rail::SubscriptionEnrollmentExpectedCharge::discounted(
3496 PlanKey::new(plan_key).unwrap(),
3497 syrup_rail::SubscriptionDiscountSnapshot::new(
3498 syrup_rail::SubscriptionDiscountCode::new("SAVE20").unwrap(),
3499 Some("Sensitive campaign".to_owned()),
3500 SubscriptionDiscountKind::PercentOffBasisPoints(
3501 PercentOffBasisPoints::new(2_000).unwrap(),
3502 ),
3503 SubscriptionDiscountDuration::LimitedMonths(LimitedDiscountMonths::new(3).unwrap()),
3504 ChargeAmount::new(1_000, currency).unwrap(),
3505 ChargeAmount::new(800, currency).unwrap(),
3506 )
3507 .unwrap(),
3508 )
3509 }
3510
3511 #[tokio::test]
3512 async fn enrollment_reservation_is_token_free_replayable_and_plan_bearing()
3513 -> Result<(), Box<dyn Error>> {
3514 let database = TestDatabase::start("enroll_reserve").await?;
3515 install_host_offers(&database).await?;
3516 let account = create_gateway_account(&database.pool, "nmi").await?;
3517 set_offer(
3518 &database,
3519 account.billing_scope_id,
3520 "base_subscription",
3521 1_000,
3522 )
3523 .await?;
3524 set_offer(&database, account.billing_scope_id, "premium", 1_000).await?;
3525 let subscriber_id = Uuid::now_v7();
3526 let gateway = resolved_gateway(account);
3527 let mut mismatched_account = account;
3528 mismatched_account.gateway_configuration_id = Uuid::now_v7();
3529 let mismatched_command = enrollment_command(
3530 mismatched_account,
3531 subscriber_id,
3532 Uuid::now_v7(),
3533 "mismatched-gateway",
3534 full_price("base_subscription", 1_000),
3535 );
3536 assert_eq!(
3537 SubscriptionEnrollmentReservation::from_command(&mismatched_command, &gateway)
3538 .expect_err("reservation must bind to the resolved configuration"),
3539 syrup_rail::SubscriptionEnrollmentReservationBuildError::GatewayIdentityMismatch,
3540 );
3541 let command = enrollment_command(
3542 account,
3543 subscriber_id,
3544 Uuid::now_v7(),
3545 "same-key",
3546 full_price("base_subscription", 1_000),
3547 );
3548 let reservation = SubscriptionEnrollmentReservation::from_command(&command, &gateway)?;
3549 let mut transaction = database.pool.begin().await?;
3550 let reserved = reserve_subscription_enrollment_in_transaction(
3551 &mut transaction,
3552 &TestOfferStore,
3553 &reservation,
3554 )
3555 .await?;
3556 let attempt = match reserved {
3557 SubscriptionEnrollmentReservationOutcome::Reserved(attempt) => attempt,
3558 other => return Err(format!("unexpected reservation outcome: {other:?}").into()),
3559 };
3560 assert_eq!(attempt.status(), PaymentAttemptStatus::Pending);
3561 assert_eq!(
3562 attempt.request().fingerprint().expose(),
3563 "subscription_initial:base_subscription:1000:USD:discount:none:expected:full_price:1000:USD"
3564 );
3565 assert!(attempt.state().timestamps().submitted_at().is_none());
3566 transaction.commit().await?;
3567
3568 let persisted: String = sqlx::query_scalar(
3569 "SELECT to_jsonb(attempts)::text FROM billing_payment_attempts attempts WHERE id = $1",
3570 )
3571 .bind(attempt.identity().attempt_id().as_uuid())
3572 .fetch_one(&database.pool)
3573 .await?;
3574 assert!(!persisted.contains("token-secret"));
3575 assert!(!format!("{reservation:?}").contains("token-secret"));
3576
3577 let replay_command = enrollment_command(
3578 account,
3579 subscriber_id,
3580 Uuid::now_v7(),
3581 "same-key",
3582 full_price("base_subscription", 1_000),
3583 );
3584 let replay_reservation =
3585 SubscriptionEnrollmentReservation::from_command(&replay_command, &gateway)?;
3586 let mut transaction = database.pool.begin().await?;
3587 let replay = reserve_subscription_enrollment_in_transaction(
3588 &mut transaction,
3589 &TestOfferStore,
3590 &replay_reservation,
3591 )
3592 .await?;
3593 assert!(matches!(
3594 replay,
3595 SubscriptionEnrollmentReservationOutcome::Replay(ref replayed)
3596 if replayed.identity().attempt_id() == attempt.identity().attempt_id()
3597 ));
3598 transaction.commit().await?;
3599
3600 let changed_plan_command = enrollment_command(
3601 account,
3602 subscriber_id,
3603 Uuid::now_v7(),
3604 "same-key",
3605 full_price("premium", 1_000),
3606 );
3607 let changed_plan_reservation =
3608 SubscriptionEnrollmentReservation::from_command(&changed_plan_command, &gateway)?;
3609 let mut transaction = database.pool.begin().await?;
3610 assert_eq!(
3611 reserve_subscription_enrollment_in_transaction(
3612 &mut transaction,
3613 &TestOfferStore,
3614 &changed_plan_reservation,
3615 )
3616 .await?,
3617 SubscriptionEnrollmentReservationOutcome::IdempotencyConflict,
3618 );
3619 transaction.rollback().await?;
3620
3621 let mut transaction = database.pool.begin().await?;
3622 let admitted = admit_subscription_enrollment_submission_in_transaction(
3623 &mut transaction,
3624 &TestOfferStore,
3625 &replay_reservation,
3626 )
3627 .await?;
3628 assert!(matches!(
3629 admitted,
3630 SubscriptionEnrollmentSubmissionOutcome::Admitted(ref admitted)
3631 if admitted.identity().attempt_id() == attempt.identity().attempt_id()
3632 && admitted.state().timestamps().submitted_at().is_some()
3633 ));
3634 transaction.commit().await?;
3635 database.cleanup().await
3636 }
3637
3638 #[tokio::test]
3639 async fn same_key_stale_replay_expires_at_the_exact_boundary_without_a_live_offer()
3640 -> Result<(), Box<dyn Error>> {
3641 let database = TestDatabase::start("enroll_stale").await?;
3642 install_host_offers(&database).await?;
3643 let account = create_gateway_account(&database.pool, "nmi").await?;
3644 let plan_key = "base_subscription";
3645 set_offer(&database, account.billing_scope_id, plan_key, 1_000).await?;
3646 let gateway = resolved_gateway(account);
3647
3648 let before_boundary_subscriber = Uuid::now_v7();
3649 let before_boundary = SubscriptionEnrollmentReservation::from_command(
3650 &enrollment_command(
3651 account,
3652 before_boundary_subscriber,
3653 Uuid::now_v7(),
3654 "before-boundary",
3655 full_price(plan_key, 1_000),
3656 ),
3657 &gateway,
3658 )?;
3659 let mut transaction = database.pool.begin().await?;
3660 let before_boundary_attempt = match reserve_subscription_enrollment_in_transaction(
3661 &mut transaction,
3662 &TestOfferStore,
3663 &before_boundary,
3664 )
3665 .await?
3666 {
3667 SubscriptionEnrollmentReservationOutcome::Reserved(attempt) => attempt,
3668 other => return Err(format!("unexpected reservation outcome: {other:?}").into()),
3669 };
3670 transaction.commit().await?;
3671 sqlx::query(
3672 "UPDATE billing_payment_attempts SET created_at = clock_timestamp() - interval '29 minutes 59 seconds' WHERE id = $1",
3673 )
3674 .bind(before_boundary_attempt.identity().attempt_id().as_uuid())
3675 .execute(&database.pool)
3676 .await?;
3677 sqlx::query(
3678 "DELETE FROM host_subscription_offers WHERE billing_scope_id = $1 AND plan_key = $2",
3679 )
3680 .bind(account.billing_scope_id)
3681 .bind(plan_key)
3682 .execute(&database.pool)
3683 .await?;
3684 let mut transaction = database.pool.begin().await?;
3685 assert_eq!(
3686 reserve_subscription_enrollment_in_transaction(
3687 &mut transaction,
3688 &TestOfferStore,
3689 &before_boundary,
3690 )
3691 .await?,
3692 SubscriptionEnrollmentReservationOutcome::Rejected(
3693 SubscriptionEnrollmentReservationRejection::EnrollmentTermsChanged,
3694 )
3695 );
3696 transaction.rollback().await?;
3697 let before_boundary_status: String =
3698 sqlx::query_scalar("SELECT status FROM billing_payment_attempts WHERE id = $1")
3699 .bind(before_boundary_attempt.identity().attempt_id().as_uuid())
3700 .fetch_one(&database.pool)
3701 .await?;
3702 assert_eq!(before_boundary_status, "pending");
3703
3704 set_offer(&database, account.billing_scope_id, plan_key, 1_000).await?;
3705 let boundary_subscriber = Uuid::now_v7();
3706 let boundary = SubscriptionEnrollmentReservation::from_command(
3707 &enrollment_command(
3708 account,
3709 boundary_subscriber,
3710 Uuid::now_v7(),
3711 "at-boundary",
3712 full_price(plan_key, 1_000),
3713 ),
3714 &gateway,
3715 )?;
3716 let mut transaction = database.pool.begin().await?;
3717 let boundary_attempt = match reserve_subscription_enrollment_in_transaction(
3718 &mut transaction,
3719 &TestOfferStore,
3720 &boundary,
3721 )
3722 .await?
3723 {
3724 SubscriptionEnrollmentReservationOutcome::Reserved(attempt) => attempt,
3725 other => return Err(format!("unexpected reservation outcome: {other:?}").into()),
3726 };
3727 transaction.commit().await?;
3728 sqlx::query(
3729 "UPDATE billing_payment_attempts SET created_at = clock_timestamp() - interval '30 minutes' WHERE id = $1",
3730 )
3731 .bind(boundary_attempt.identity().attempt_id().as_uuid())
3732 .execute(&database.pool)
3733 .await?;
3734 sqlx::query(
3735 "DELETE FROM host_subscription_offers WHERE billing_scope_id = $1 AND plan_key = $2",
3736 )
3737 .bind(account.billing_scope_id)
3738 .bind(plan_key)
3739 .execute(&database.pool)
3740 .await?;
3741 let boundary_replay = SubscriptionEnrollmentReservation::from_command(
3742 &enrollment_command(
3743 account,
3744 boundary_subscriber,
3745 Uuid::now_v7(),
3746 "at-boundary",
3747 full_price(plan_key, 1_000),
3748 ),
3749 &gateway,
3750 )?;
3751
3752 let mut transaction = database.pool.begin().await?;
3753 let replay = reserve_subscription_enrollment_in_transaction(
3754 &mut transaction,
3755 &TestOfferStore,
3756 &boundary_replay,
3757 )
3758 .await?;
3759 let expired = match replay {
3760 SubscriptionEnrollmentReservationOutcome::Replay(attempt) => attempt,
3761 other => return Err(format!("unexpected stale replay outcome: {other:?}").into()),
3762 };
3763 assert_eq!(
3764 expired.identity().attempt_id(),
3765 boundary_attempt.identity().attempt_id()
3766 );
3767 assert_eq!(expired.status(), PaymentAttemptStatus::Failed);
3768 assert_eq!(
3769 expired.state().resolution_code(),
3770 Some(PaymentResolutionCode::SubscriptionInitialPreparedAttemptExpired)
3771 );
3772 assert!(expired.state().timestamps().submitted_at().is_none());
3773 transaction.commit().await?;
3774 database.cleanup().await
3775 }
3776
3777 #[tokio::test]
3778 async fn saved_discount_survives_repricing_but_not_pre_submission_expiry()
3779 -> Result<(), Box<dyn Error>> {
3780 let database = TestDatabase::start("enroll_discount").await?;
3781 install_host_offers(&database).await?;
3782 let account = create_gateway_account(&database.pool, "nmi").await?;
3783 let plan_key = "base_subscription";
3784 set_offer(&database, account.billing_scope_id, plan_key, 1_000).await?;
3785 let code_id = create_discount_code(&database, account.billing_scope_id, plan_key).await?;
3786 let gateway = resolved_gateway(account);
3787
3788 let subscriber_a = Uuid::now_v7();
3789 let claim_a = create_saved_claim(
3790 &database,
3791 account.billing_scope_id,
3792 subscriber_a,
3793 plan_key,
3794 code_id,
3795 )
3796 .await?;
3797 let command_a = enrollment_command(
3798 account,
3799 subscriber_a,
3800 Uuid::now_v7(),
3801 "discount-a",
3802 discounted_expected(plan_key),
3803 );
3804 let reservation_a = SubscriptionEnrollmentReservation::from_command(&command_a, &gateway)?;
3805 let mut transaction = database.pool.begin().await?;
3806 let attempt_a = match reserve_subscription_enrollment_in_transaction(
3807 &mut transaction,
3808 &TestOfferStore,
3809 &reservation_a,
3810 )
3811 .await?
3812 {
3813 SubscriptionEnrollmentReservationOutcome::Reserved(attempt) => attempt,
3814 other => return Err(format!("unexpected discounted reservation: {other:?}").into()),
3815 };
3816 transaction.commit().await?;
3817 let discount = attempt_a
3818 .request()
3819 .target()
3820 .enrollment_discount()
3821 .expect("saved discount should be snapshotted");
3822 assert_eq!(discount.claim_id().as_uuid(), &claim_a);
3823 assert_eq!(discount.code_id().as_uuid(), &code_id);
3824 assert_eq!(attempt_a.request().amount().cents(), 800);
3825 assert_eq!(
3826 attempt_a.request().fingerprint().expose(),
3827 format!(
3828 "subscription_initial:base_subscription:800:USD:discount:{claim_a}:{code_id}:SAVE20:percent_off:none:2000:USD:1000:800:limited_months:3:expected:discounted:SAVE20:percent_off:none:2000:limited_months:3:USD:1000:800"
3829 )
3830 );
3831 let debug = format!("{attempt_a:?}");
3832 assert!(!debug.contains("SAVE20"));
3833 assert!(!debug.contains("Sensitive campaign"));
3834
3835 set_offer(&database, account.billing_scope_id, plan_key, 1_400).await?;
3836 let mut transaction = database.pool.begin().await?;
3837 assert!(matches!(
3838 admit_subscription_enrollment_submission_in_transaction(
3839 &mut transaction,
3840 &TestOfferStore,
3841 &reservation_a,
3842 )
3843 .await?,
3844 SubscriptionEnrollmentSubmissionOutcome::Admitted(_)
3845 ));
3846 transaction.commit().await?;
3847
3848 let subscriber_b = Uuid::now_v7();
3849 let claim_b = create_saved_claim(
3850 &database,
3851 account.billing_scope_id,
3852 subscriber_b,
3853 plan_key,
3854 code_id,
3855 )
3856 .await?;
3857 let command_b = enrollment_command(
3858 account,
3859 subscriber_b,
3860 Uuid::now_v7(),
3861 "discount-b",
3862 discounted_expected(plan_key),
3863 );
3864 let reservation_b = SubscriptionEnrollmentReservation::from_command(&command_b, &gateway)?;
3865 let mut transaction = database.pool.begin().await?;
3866 assert!(matches!(
3867 reserve_subscription_enrollment_in_transaction(
3868 &mut transaction,
3869 &TestOfferStore,
3870 &reservation_b,
3871 )
3872 .await?,
3873 SubscriptionEnrollmentReservationOutcome::Reserved(_)
3874 ));
3875 transaction.commit().await?;
3876 sqlx::query(
3877 "UPDATE billing_subscription_discount_claims SET status = 'expired' WHERE id = $1",
3878 )
3879 .bind(claim_b)
3880 .execute(&database.pool)
3881 .await?;
3882 let mut transaction = database.pool.begin().await?;
3883 let rejected = admit_subscription_enrollment_submission_in_transaction(
3884 &mut transaction,
3885 &TestOfferStore,
3886 &reservation_b,
3887 )
3888 .await?;
3889 assert!(matches!(
3890 rejected,
3891 SubscriptionEnrollmentSubmissionOutcome::Rejected {
3892 ref attempt,
3893 reason: SubscriptionEnrollmentSubmissionRejection::EnrollmentTermsChanged,
3894 } if attempt.status() == PaymentAttemptStatus::Failed
3895 && attempt.state().timestamps().submitted_at().is_none()
3896 ));
3897 transaction.commit().await?;
3898 database.cleanup().await
3899 }
3900
3901 #[tokio::test]
3902 async fn active_grant_blocks_only_its_exact_plan() -> Result<(), Box<dyn Error>> {
3903 let database = TestDatabase::start("enroll_grant").await?;
3904 install_host_offers(&database).await?;
3905 let account = create_gateway_account(&database.pool, "nmi").await?;
3906 set_offer(&database, account.billing_scope_id, "basic", 1_000).await?;
3907 set_offer(&database, account.billing_scope_id, "premium", 2_000).await?;
3908 let subscriber_id = Uuid::now_v7();
3909 sqlx::query(
3910 r#"
3911 INSERT INTO billing_subscription_grants (
3912 id, billing_scope_id, subscriber_id, plan_key, grant_kind,
3913 reason, starts_at, ends_at, granted_by_actor_id
3914 ) VALUES (
3915 $1, $2, $3, 'basic', 'promotion', 'launch',
3916 clock_timestamp() - interval '1 minute',
3917 clock_timestamp() + interval '1 day', $4
3918 )
3919 "#,
3920 )
3921 .bind(Uuid::now_v7())
3922 .bind(account.billing_scope_id)
3923 .bind(subscriber_id)
3924 .bind(Uuid::now_v7())
3925 .execute(&database.pool)
3926 .await?;
3927 let gateway = resolved_gateway(account);
3928
3929 let basic = SubscriptionEnrollmentReservation::from_command(
3930 &enrollment_command(
3931 account,
3932 subscriber_id,
3933 Uuid::now_v7(),
3934 "basic-grant",
3935 full_price("basic", 1_000),
3936 ),
3937 &gateway,
3938 )?;
3939 let mut transaction = database.pool.begin().await?;
3940 assert_eq!(
3941 reserve_subscription_enrollment_in_transaction(
3942 &mut transaction,
3943 &TestOfferStore,
3944 &basic,
3945 )
3946 .await?,
3947 SubscriptionEnrollmentReservationOutcome::Rejected(
3948 SubscriptionEnrollmentReservationRejection::ActiveGrant,
3949 )
3950 );
3951 transaction.rollback().await?;
3952
3953 let premium = SubscriptionEnrollmentReservation::from_command(
3954 &enrollment_command(
3955 account,
3956 subscriber_id,
3957 Uuid::now_v7(),
3958 "premium-with-basic-grant",
3959 full_price("premium", 2_000),
3960 ),
3961 &gateway,
3962 )?;
3963 let mut transaction = database.pool.begin().await?;
3964 assert!(matches!(
3965 reserve_subscription_enrollment_in_transaction(
3966 &mut transaction,
3967 &TestOfferStore,
3968 &premium,
3969 )
3970 .await?,
3971 SubscriptionEnrollmentReservationOutcome::Reserved(_)
3972 ));
3973 transaction.commit().await?;
3974 database.cleanup().await
3975 }
3976
3977 #[tokio::test]
3978 async fn gateway_configuration_rotation_rejects_prepared_submission()
3979 -> Result<(), Box<dyn Error>> {
3980 let database = TestDatabase::start("enroll_rotate").await?;
3981 install_host_offers(&database).await?;
3982 let account = create_gateway_account(&database.pool, "nmi").await?;
3983 let plan_key = "base_subscription";
3984 set_offer(&database, account.billing_scope_id, plan_key, 1_000).await?;
3985 let gateway = resolved_gateway(account);
3986 let reservation = SubscriptionEnrollmentReservation::from_command(
3987 &enrollment_command(
3988 account,
3989 Uuid::now_v7(),
3990 Uuid::now_v7(),
3991 "rotated-configuration",
3992 full_price(plan_key, 1_000),
3993 ),
3994 &gateway,
3995 )?;
3996 let mut transaction = database.pool.begin().await?;
3997 assert!(matches!(
3998 reserve_subscription_enrollment_in_transaction(
3999 &mut transaction,
4000 &TestOfferStore,
4001 &reservation,
4002 )
4003 .await?,
4004 SubscriptionEnrollmentReservationOutcome::Reserved(_)
4005 ));
4006 transaction.commit().await?;
4007
4008 sqlx::query(
4009 "UPDATE billing_gateway_accounts SET gateway_configuration_id = $2, updated_at = clock_timestamp() WHERE id = $1",
4010 )
4011 .bind(account.gateway_account_id)
4012 .bind(Uuid::now_v7())
4013 .execute(&database.pool)
4014 .await?;
4015 let mut transaction = database.pool.begin().await?;
4016 let rejected = admit_subscription_enrollment_submission_in_transaction(
4017 &mut transaction,
4018 &TestOfferStore,
4019 &reservation,
4020 )
4021 .await?;
4022 assert!(matches!(
4023 rejected,
4024 SubscriptionEnrollmentSubmissionOutcome::Rejected {
4025 ref attempt,
4026 reason: SubscriptionEnrollmentSubmissionRejection::GatewayConfigurationChanged,
4027 } if attempt.status() == PaymentAttemptStatus::Failed
4028 && attempt.state().timestamps().submitted_at().is_none()
4029 ));
4030 transaction.commit().await?;
4031 database.cleanup().await
4032 }
4033
4034 #[tokio::test]
4035 async fn loaders_preserve_exact_scope_and_redact_durable_values() -> Result<(), Box<dyn Error>>
4036 {
4037 let database = TestDatabase::start("attempt_owner").await?;
4038 let account = create_gateway_account(&database.pool, "nmi").await?;
4039 let attempt_id = Uuid::now_v7();
4040 let subscriber_id = Uuid::now_v7();
4041 sqlx::query(
4042 r#"
4043 INSERT INTO billing_payment_attempts (
4044 id, billing_scope_id, subscriber_id, host_charge_target_id,
4045 attempt_kind, status, idempotency_key, request_fingerprint,
4046 amount_cents, currency, gateway_account_id,
4047 gateway_configuration_id, gateway_order_id,
4048 gateway_transaction_id, gateway_payment_method_reference,
4049 gateway_response, gateway_response_code, gateway_response_text,
4050 gateway_condition, billing_name, billing_email
4051 ) VALUES (
4052 $1, $2, $3, $4, 'host_charge', 'pending', $5, $6,
4053 1000, 'USD', $7, $8, $9, $10, $11, $12, $13, $14,
4054 $15, $16, $17
4055 )
4056 "#,
4057 )
4058 .bind(attempt_id)
4059 .bind(account.billing_scope_id)
4060 .bind(subscriber_id)
4061 .bind(Uuid::now_v7())
4062 .bind("idempotency-secret")
4063 .bind("fingerprint-secret")
4064 .bind(account.gateway_account_id)
4065 .bind(account.gateway_configuration_id)
4066 .bind("order-secret")
4067 .bind("transaction-secret")
4068 .bind("method-secret")
4069 .bind("response-secret")
4070 .bind("code-secret")
4071 .bind("text-secret")
4072 .bind("condition-secret")
4073 .bind("Sensitive Name")
4074 .bind("secret@example.test")
4075 .execute(&database.pool)
4076 .await?;
4077
4078 let mut transaction = database.pool.begin().await?;
4079 assert!(
4080 find_payment_attempt_by_id_in_transaction(
4081 &mut transaction,
4082 BillingScopeId::new(Uuid::now_v7()),
4083 PaymentAttemptId::new(attempt_id),
4084 )
4085 .await?
4086 .is_none()
4087 );
4088 let attempt = lock_payment_attempt_by_idempotency_in_transaction(
4089 &mut transaction,
4090 BillingScopeId::new(account.billing_scope_id),
4091 SubscriberId::new(subscriber_id),
4092 &IdempotencyKey::new("idempotency-secret")?,
4093 )
4094 .await?
4095 .expect("exact owner row should load");
4096 assert_eq!(attempt.identity().attempt_id().as_uuid(), &attempt_id);
4097 assert_eq!(attempt.kind(), PaymentAttemptKind::HostCharge);
4098 assert_eq!(attempt.request().amount().cents(), 1_000);
4099 assert_eq!(
4100 attempt
4101 .state()
4102 .processor_evidence()
4103 .transaction_id()
4104 .expect("transaction ID")
4105 .expose(),
4106 "transaction-secret"
4107 );
4108 let debug = format!("{attempt:?}");
4109 for secret in [
4110 "idempotency-secret",
4111 "fingerprint-secret",
4112 "order-secret",
4113 "transaction-secret",
4114 "method-secret",
4115 "response-secret",
4116 "code-secret",
4117 "text-secret",
4118 "condition-secret",
4119 "Sensitive Name",
4120 "secret@example.test",
4121 ] {
4122 assert!(!debug.contains(secret), "debug leaked {secret}");
4123 }
4124 transaction.rollback().await?;
4125 database.cleanup().await
4126 }
4127
4128 #[tokio::test]
4129 async fn recovery_keeps_related_and_expected_payment_methods_distinct()
4130 -> Result<(), Box<dyn Error>> {
4131 let database = TestDatabase::start("attempt_recovery").await?;
4132 let account = create_gateway_account(&database.pool, "nmi").await?;
4133 let subscriber_id = Uuid::now_v7();
4134 let expected_method_id = Uuid::now_v7();
4135 let related_method_id = Uuid::now_v7();
4136 for (method_id, reference) in [
4137 (expected_method_id, "vault-expected"),
4138 (related_method_id, "vault-related"),
4139 ] {
4140 sqlx::query(
4141 r#"
4142 INSERT INTO billing_payment_methods (
4143 id, billing_scope_id, subscriber_id, gateway_account_id,
4144 gateway_payment_method_reference, status
4145 ) VALUES ($1, $2, $3, $4, $5, 'active')
4146 "#,
4147 )
4148 .bind(method_id)
4149 .bind(account.billing_scope_id)
4150 .bind(subscriber_id)
4151 .bind(account.gateway_account_id)
4152 .bind(reference)
4153 .execute(&database.pool)
4154 .await?;
4155 }
4156 let subscription_id = Uuid::now_v7();
4157 let period_start = Utc::now();
4158 let period_end = period_start + Duration::days(30);
4159 sqlx::query(
4160 r#"
4161 INSERT INTO billing_subscriptions (
4162 id, billing_scope_id, subscriber_id, plan_key, status,
4163 gateway_account_id, payment_method_id, amount_cents, currency,
4164 current_period_start_at, current_period_end_at, next_renewal_at,
4165 initial_transaction_id
4166 ) VALUES (
4167 $1, $2, $3, 'premium', 'active', $4, $5, 1000, 'USD',
4168 $6, $7, $7, 'txn-initial'
4169 )
4170 "#,
4171 )
4172 .bind(subscription_id)
4173 .bind(account.billing_scope_id)
4174 .bind(subscriber_id)
4175 .bind(account.gateway_account_id)
4176 .bind(related_method_id)
4177 .bind(period_start)
4178 .bind(period_end)
4179 .execute(&database.pool)
4180 .await?;
4181
4182 let attempt_id = Uuid::now_v7();
4183 let charge_start = period_end;
4184 let charge_end = charge_start + Duration::days(30);
4185 let order_id = format!("sr_recovery_{}", attempt_id.simple());
4186 sqlx::query(
4187 r#"
4188 INSERT INTO billing_payment_attempts (
4189 id, billing_scope_id, subscriber_id, plan_key,
4190 subscription_id, payment_method_id, attempt_kind, status,
4191 idempotency_key, request_fingerprint, amount_cents, currency,
4192 billing_period_start_at, billing_period_end_at,
4193 gateway_account_id, gateway_configuration_id, gateway_order_id,
4194 gateway_transaction_id, submitted_at, resolved_at,
4195 subscription_expected_payment_method_id,
4196 subscription_expected_initial_transaction_id,
4197 subscription_expected_status
4198 ) VALUES (
4199 $1, $2, $3, 'premium', $4, $5,
4200 'subscription_recovery', 'approved', $6, $7, 1000, 'USD',
4201 $8, $9, $10, $11, $12, 'txn-recovery', now(), now(),
4202 $13, 'txn-initial', 'past_due'
4203 )
4204 "#,
4205 )
4206 .bind(attempt_id)
4207 .bind(account.billing_scope_id)
4208 .bind(subscriber_id)
4209 .bind(subscription_id)
4210 .bind(related_method_id)
4211 .bind("recovery-key")
4212 .bind("recovery-fingerprint")
4213 .bind(charge_start)
4214 .bind(charge_end)
4215 .bind(account.gateway_account_id)
4216 .bind(account.gateway_configuration_id)
4217 .bind(order_id)
4218 .bind(expected_method_id)
4219 .execute(&database.pool)
4220 .await?;
4221
4222 let mut transaction = database.pool.begin().await?;
4223 let attempt = find_payment_attempt_by_id_in_transaction(
4224 &mut transaction,
4225 BillingScopeId::new(account.billing_scope_id),
4226 PaymentAttemptId::new(attempt_id),
4227 )
4228 .await?
4229 .expect("recovery row should load");
4230 let target = attempt.request().target();
4231 assert_eq!(
4232 target.payment_method_id().unwrap().as_uuid(),
4233 &related_method_id
4234 );
4235 assert_eq!(
4236 target.subscription_id().unwrap().as_uuid(),
4237 &subscription_id
4238 );
4239 assert_eq!(
4240 target
4241 .subscription_payment_state_snapshot()
4242 .expect("expected state")
4243 .payment_method_id()
4244 .as_uuid(),
4245 &expected_method_id
4246 );
4247 assert_eq!(
4248 target
4249 .subscription_payment_state_snapshot()
4250 .expect("expected state")
4251 .status(),
4252 SubscriptionStatus::PastDue
4253 );
4254 transaction.rollback().await?;
4255 database.cleanup().await
4256 }
4257}