Skip to main content

syrup_rail_postgres/
enrollment_application.rs

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