Skip to main content

syrup_rail_postgres/
attempts.rs

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
115/// Reserves or resumes one exact-plan initial enrollment inside the caller's transaction.
116///
117/// The payment token is deliberately absent from [`SubscriptionEnrollmentReservation`].
118/// The transaction locks the plan aggregate, authoritative host offer, saved claim, attempt
119/// rows, unresolved charge evidence, and exact gateway identity before inserting a token-free
120/// pending attempt.
121pub 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
360/// Resolves subscriber-wide enrollment idempotency before host admission.
361///
362/// Matching stale prepared work is expired at the exact database-clock
363/// boundary and returned as a replay. This operation deliberately does not
364/// lock an offer or inspect current subscription state: replay semantics are
365/// determined by the historical request before any live policy or quota.
366pub 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
448/// Revalidates a prepared initial attempt and durably admits its one provider mutation.
449///
450/// No provider I/O may occur after this function returns `Admitted` and before the caller
451/// invokes the sale. Every semantic rejection terminally fails the still-unsubmitted attempt.
452pub 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
601/// Locks one exact due subscription and inserts its automatic-renewal attempt.
602pub 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(&currency_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
743/// Revalidates one exact renewal snapshot and commits one-shot submission admission.
744pub 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
831/// Resolves subscriber-wide recovery idempotency before host admission.
832///
833/// A recovery command deliberately contains no amount or period. Matching is
834/// therefore against the immutable canonical target already stored on the
835/// attempt, plus the command's owner, plan, and gateway configuration.
836pub 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
863/// Locks the canonical subscription, derives the exact due-period request, and
864/// inserts a token-free recovery attempt in one transaction.
865pub 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(&currency_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
1012/// Revalidates the exact locked snapshot and commits one-shot provider
1013/// admission. Every semantic rejection terminalizes the prepared attempt.
1014pub 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
1093/// Resolves payment-method replacement idempotency before host admission.
1094pub 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
1122/// Locks the exact subscription baseline and reserves a token-free replacement
1123/// attempt before Customer Vault I/O.
1124pub 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
1275/// Revalidates the exact subscription baseline and commits one-shot Customer
1276/// Vault admission. Semantic drift terminalizes the prepared attempt.
1277pub 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
1357/// Loads an attempt by its exact scope and durable identity without locking it.
1358pub 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
1372/// Locks the exact owner-scoped idempotency row for replay or mutation.
1373pub 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(&currency)
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}