Skip to main content

syrup_rail_postgres/
operator_review.rs

1use std::{error::Error, fmt};
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use sqlx::{PgConnection, PgPool, Postgres, Row, Transaction, postgres::PgRow};
6use syrup_rail::{
7    ActorId, AttemptReviewCursor, AttemptReviewPage, BillingEvent, BillingEventSubject,
8    BillingScopeId, ChargeAmount, CurrencyCode, ExternalReversalAttestation,
9    ExternalReversalHostChargeRelease, ExternalReversalKind, ExternalReversalReason,
10    GatewayAccountId, GatewayConfigurationId, GatewayDiagnostic, GatewayOrderId,
11    GatewayPaymentDescriptor, GatewayPaymentMethodReference, GatewayTransactionId,
12    HostChargeTargetId, ManualAttemptFailureOutcome, ManualFailureHostCharge, Money,
13    OperatorReviewPageLimit, PaymentAttempt, PaymentAttemptId, PaymentAttemptKind,
14    PaymentAttemptStatus, PaymentResolutionCode, PlanKey, ProcessorCharge, ProcessorChargeId,
15    ProcessorChargeProgression, ProcessorChargeReviewCursor, ProcessorChargeReviewItem,
16    ProcessorChargeReviewPage, ProcessorChargeRole, ProcessorChargeStateCode, ProcessorEvidence,
17    SubscriberId, review_required_attempt_can_be_manually_failed,
18    review_required_manual_failure_evidence,
19};
20use thiserror::Error;
21use uuid::Uuid;
22
23use crate::attempts::{
24    lock_payment_attempt_by_id_on_connection, lock_subscription_aggregate,
25    payment_attempt_from_row, processor_evidence_from_row, set_enrollment_timeouts,
26};
27use crate::transactions::{
28    BillingEventWriteError, BillingTransactionCoordinator, BillingTransactionError,
29};
30
31const INVALID_OPERATOR_STATE: &str = "canonical operator review state is invalid";
32type BoxError = Box<dyn Error + Send + Sync + 'static>;
33
34pub async fn attempt_review_page(
35    pool: &PgPool,
36    limit: OperatorReviewPageLimit,
37    cursor: Option<AttemptReviewCursor>,
38) -> Result<AttemptReviewPage, OperatorReviewError> {
39    let query = format!(
40        r#"
41        {}
42        WHERE status = 'review_required'
43            AND review_required_at IS NOT NULL
44            AND NOT EXISTS (
45                SELECT 1 FROM billing_processor_charges charges
46                WHERE charges.attempt_id = billing_payment_attempts.id
47                    AND charges.progression_state = 'external_reversal_required'
48            )
49            AND (
50                $1::timestamptz IS NULL
51                OR (review_required_at, id) > ($1::timestamptz, $2::uuid)
52            )
53        ORDER BY review_required_at, id
54        LIMIT $3
55        "#,
56        crate::attempts::PAYMENT_ATTEMPT_SELECT
57    );
58    let rows = sqlx::query(&query)
59        .bind(cursor.map(AttemptReviewCursor::reviewed_at))
60        .bind(cursor.map(|value| value.attempt_id().into_uuid()))
61        .bind(limit.get() + 1)
62        .fetch_all(pool)
63        .await?;
64    let mut items = rows
65        .iter()
66        .map(payment_attempt_from_row)
67        .collect::<Result<Vec<_>, _>>()
68        .map_err(OperatorReviewError::from)?;
69    let has_more = items.len() > limit.get() as usize;
70    if has_more {
71        items.pop();
72    }
73    let next_cursor = if has_more {
74        items.last().map(|attempt| {
75            AttemptReviewCursor::new(
76                attempt
77                    .state()
78                    .timestamps()
79                    .review_required_at()
80                    .expect("review page query requires review timestamp"),
81                attempt.identity().attempt_id(),
82            )
83        })
84    } else {
85        None
86    };
87    Ok(AttemptReviewPage::new(items, next_cursor))
88}
89
90pub async fn processor_charge_review_page(
91    pool: &PgPool,
92    limit: OperatorReviewPageLimit,
93    cursor: Option<ProcessorChargeReviewCursor>,
94) -> Result<ProcessorChargeReviewPage, OperatorReviewError> {
95    let query = format!(
96        r#"
97        SELECT attempts.*,
98            charges.id AS review_charge_id,
99            charges.attempt_id AS review_charge_attempt_id,
100            charges.billing_scope_id AS review_charge_billing_scope_id,
101            charges.gateway_account_id AS review_charge_gateway_account_id,
102            charges.gateway_order_id AS review_charge_gateway_order_id,
103            charges.attempt_kind AS review_charge_attempt_kind,
104            charges.amount_cents AS review_charge_amount_cents,
105            charges.currency AS review_charge_currency,
106            charges.charge_role AS review_charge_role,
107            charges.progression_state AS review_charge_progression_state,
108            charges.state_code AS review_charge_state_code,
109            charges.gateway_transaction_id AS review_charge_gateway_transaction_id,
110            charges.gateway_payment_method_reference AS review_charge_gateway_payment_method_reference,
111            charges.gateway_response AS review_charge_gateway_response,
112            charges.gateway_response_code AS review_charge_gateway_response_code,
113            charges.gateway_response_text AS review_charge_gateway_response_text,
114            charges.gateway_condition AS review_charge_gateway_condition,
115            charges.payment_type AS review_charge_payment_type,
116            charges.card_brand AS review_charge_card_brand,
117            charges.card_last4 AS review_charge_card_last4,
118            charges.card_exp_month AS review_charge_card_exp_month,
119            charges.card_exp_year AS review_charge_card_exp_year,
120            charges.observed_at AS review_charge_observed_at,
121            charges.external_reversal_required_at AS review_charge_required_at
122        FROM billing_processor_charges charges
123        INNER JOIN ({}) attempts ON attempts.id = charges.attempt_id
124        WHERE charges.progression_state = 'external_reversal_required'
125            AND charges.external_reversal_required_at IS NOT NULL
126            AND charges.amount_cents > 0
127            AND public.billing_canonical_gateway_transaction_id(
128                charges.gateway_transaction_id
129            ) IS NOT NULL
130            AND charges.attempt_kind IN (
131                'host_charge', 'subscription_initial',
132                'subscription_renewal', 'subscription_recovery'
133            )
134            AND (
135                $1::timestamptz IS NULL
136                OR (charges.external_reversal_required_at, charges.id)
137                    > ($1::timestamptz, $2::uuid)
138            )
139        ORDER BY charges.external_reversal_required_at, charges.id
140        LIMIT $3
141        "#,
142        crate::attempts::PAYMENT_ATTEMPT_SELECT
143    );
144    let rows = sqlx::query(&query)
145        .bind(cursor.map(ProcessorChargeReviewCursor::reviewed_at))
146        .bind(cursor.map(|value| value.processor_charge_id().into_uuid()))
147        .bind(limit.get() + 1)
148        .fetch_all(pool)
149        .await?;
150    let mut items = rows
151        .iter()
152        .map(|row| {
153            Ok(ProcessorChargeReviewItem::new(
154                payment_attempt_from_row(row).map_err(OperatorReviewError::from)?,
155                processor_charge_from_review_row(row)?,
156                row.try_get("review_charge_required_at")?,
157            ))
158        })
159        .collect::<Result<Vec<_>, OperatorReviewError>>()?;
160    let has_more = items.len() > limit.get() as usize;
161    if has_more {
162        items.pop();
163    }
164    let next_cursor = if has_more {
165        items.last().map(|item| {
166            ProcessorChargeReviewCursor::new(
167                item.external_reversal_required_at(),
168                item.charge().id(),
169            )
170        })
171    } else {
172        None
173    };
174    Ok(ProcessorChargeReviewPage::new(items, next_cursor))
175}
176
177#[derive(Debug)]
178pub struct ManualAttemptFailureHostStoreError {
179    source: BoxError,
180}
181
182impl ManualAttemptFailureHostStoreError {
183    pub fn new(source: impl Error + Send + Sync + 'static) -> Self {
184        Self {
185            source: Box::new(source),
186        }
187    }
188
189    pub fn into_source(self) -> BoxError {
190        self.source
191    }
192}
193
194impl fmt::Display for ManualAttemptFailureHostStoreError {
195    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196        formatter.write_str("manual attempt failure host target transition failed")
197    }
198}
199
200impl Error for ManualAttemptFailureHostStoreError {
201    fn source(&self) -> Option<&(dyn Error + 'static)> {
202        Some(self.source.as_ref())
203    }
204}
205
206#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub enum ManualAttemptFailureHostTransitionOutcome {
208    Changed,
209    Unchanged,
210}
211
212#[async_trait]
213pub trait ManualAttemptFailureHostStore: Send + Sync {
214    async fn lock_payment_failure_target(
215        &self,
216        connection: &mut PgConnection,
217        charge: ManualFailureHostCharge,
218    ) -> Result<(), ManualAttemptFailureHostStoreError>;
219
220    async fn mark_payment_failed(
221        &self,
222        connection: &mut PgConnection,
223        charge: ManualFailureHostCharge,
224    ) -> Result<ManualAttemptFailureHostTransitionOutcome, ManualAttemptFailureHostStoreError>;
225}
226
227#[derive(Debug)]
228pub struct ExternalReversalHostStoreError {
229    source: BoxError,
230}
231
232impl ExternalReversalHostStoreError {
233    pub fn new(source: impl Error + Send + Sync + 'static) -> Self {
234        Self {
235            source: Box::new(source),
236        }
237    }
238    pub fn into_source(self) -> BoxError {
239        self.source
240    }
241}
242
243impl fmt::Display for ExternalReversalHostStoreError {
244    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245        formatter.write_str("external reversal host target transition failed")
246    }
247}
248
249impl Error for ExternalReversalHostStoreError {
250    fn source(&self) -> Option<&(dyn Error + 'static)> {
251        Some(self.source.as_ref())
252    }
253}
254
255#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub enum ExternalReversalHostTransitionOutcome {
257    Changed,
258    Unchanged,
259}
260
261#[async_trait]
262pub trait ExternalReversalHostStore: Send + Sync {
263    async fn release(
264        &self,
265        connection: &mut PgConnection,
266        release: ExternalReversalHostChargeRelease,
267    ) -> Result<ExternalReversalHostTransitionOutcome, ExternalReversalHostStoreError>;
268}
269
270#[derive(Debug, Error)]
271pub enum OperatorReviewError {
272    #[error("operator review storage operation failed")]
273    Sql(#[from] sqlx::Error),
274    #[error("{0}")]
275    InvalidState(&'static str),
276    #[error(transparent)]
277    Host(#[from] ExternalReversalHostStoreError),
278    #[error(transparent)]
279    ManualFailureHost(#[from] ManualAttemptFailureHostStoreError),
280    #[error(transparent)]
281    BillingTransaction(#[from] BillingTransactionError),
282    #[error(transparent)]
283    BillingEvent(#[from] BillingEventWriteError),
284}
285
286impl From<crate::PaymentAttemptStoreError> for OperatorReviewError {
287    fn from(error: crate::PaymentAttemptStoreError) -> Self {
288        match error {
289            crate::PaymentAttemptStoreError::Sql(error) => Self::Sql(error),
290            crate::PaymentAttemptStoreError::InvalidState(_) => {
291                Self::InvalidState(INVALID_OPERATOR_STATE)
292            }
293        }
294    }
295}
296
297#[derive(Clone, Debug, Eq, PartialEq)]
298pub enum ExternalReversalAttestationOutcome {
299    Attested {
300        attempt: PaymentAttempt,
301        attestation: ExternalReversalAttestation,
302    },
303    Replayed {
304        attempt: PaymentAttempt,
305        attestation: ExternalReversalAttestation,
306    },
307    NotFound,
308    Ineligible,
309    ReplayConflict,
310}
311
312#[derive(Clone, Debug)]
313struct ChargeLocator {
314    charge_id: ProcessorChargeId,
315    attempt_id: PaymentAttemptId,
316    billing_scope_id: BillingScopeId,
317    subscriber_id: SubscriberId,
318    kind: PaymentAttemptKind,
319    plan_key: Option<PlanKey>,
320    host_target_id: Option<HostChargeTargetId>,
321}
322
323pub async fn fail_review_required_attempt(
324    pool: &PgPool,
325    coordinator: &dyn BillingTransactionCoordinator,
326    host: &dyn ManualAttemptFailureHostStore,
327    attempt_id: PaymentAttemptId,
328) -> Result<ManualAttemptFailureOutcome, OperatorReviewError> {
329    let Some(preloaded) = payment_attempt_by_id(pool, attempt_id).await? else {
330        return Ok(ManualAttemptFailureOutcome::NotFound);
331    };
332    if !review_required_attempt_can_be_manually_failed(&preloaded) {
333        return Ok(ManualAttemptFailureOutcome::KeptOpen(preloaded));
334    }
335
336    let identity = preloaded.identity();
337    let subject = BillingEventSubject::new(identity.billing_scope_id(), identity.subscriber_id());
338    let mut transaction = coordinator
339        .begin(subject, std::time::Duration::from_millis(250))
340        .await?;
341    let result = async {
342        let connection = transaction.connection();
343        set_enrollment_timeouts(connection).await?;
344        let target = preloaded.request().target();
345        if let Some(plan_key) = target.plan_key() {
346            lock_subscription_aggregate(connection, identity.subscriber_id(), plan_key).await?;
347        } else if let Some(target_id) = target.host_charge_target_id() {
348            host.lock_payment_failure_target(
349                connection,
350                ManualFailureHostCharge::new(
351                    identity.billing_scope_id(),
352                    identity.subscriber_id(),
353                    target_id,
354                ),
355            )
356            .await?;
357        }
358
359        let Some(current) = lock_payment_attempt_by_id_on_connection(
360            connection,
361            identity.billing_scope_id(),
362            attempt_id,
363        )
364        .await?
365        else {
366            return Ok((ManualAttemptFailureOutcome::NotFound, None));
367        };
368        if current.identity() != preloaded.identity() || current.request() != preloaded.request() {
369            return Err(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE));
370        }
371        if !review_required_attempt_can_be_manually_failed(&current)
372            || (current.kind() != PaymentAttemptKind::SubscriptionPaymentMethodUpdate
373                && unresolved_processor_charge_exists(connection, attempt_id).await?)
374        {
375            return Ok((ManualAttemptFailureOutcome::KeptOpen(current), None));
376        }
377
378        let evidence = review_required_manual_failure_evidence(&current);
379        let updated = update_attempt_for_manual_failure(connection, &current, &evidence).await?;
380        if updated != 1 {
381            return Err(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE));
382        }
383
384        let event = if matches!(
385            current.kind(),
386            PaymentAttemptKind::SubscriptionRenewal | PaymentAttemptKind::SubscriptionRecovery
387        ) {
388            mark_subscription_past_due_for_manual_failure(connection, &current).await?
389        } else {
390            None
391        };
392        if let Some(target_id) = current.request().target().host_charge_target_id() {
393            host.mark_payment_failed(
394                connection,
395                ManualFailureHostCharge::new(
396                    identity.billing_scope_id(),
397                    identity.subscriber_id(),
398                    target_id,
399                ),
400            )
401            .await?;
402        }
403        let attempt = lock_payment_attempt_by_id_on_connection(
404            connection,
405            identity.billing_scope_id(),
406            attempt_id,
407        )
408        .await?
409        .ok_or(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?;
410        Ok((ManualAttemptFailureOutcome::Failed(attempt), event))
411    }
412    .await;
413
414    let (outcome, event) = match result {
415        Ok(value) => value,
416        Err(error) => {
417            let _ = transaction.rollback().await;
418            return Err(error);
419        }
420    };
421    if let Some(event) = event.as_ref()
422        && let Err(error) = transaction.append_event(event).await
423    {
424        let _ = transaction.rollback().await;
425        return Err(error.into());
426    }
427    transaction.commit().await?;
428    Ok(outcome)
429}
430
431async fn payment_attempt_by_id(
432    pool: &PgPool,
433    attempt_id: PaymentAttemptId,
434) -> Result<Option<PaymentAttempt>, OperatorReviewError> {
435    let query = format!("{} WHERE id = $1", crate::attempts::PAYMENT_ATTEMPT_SELECT);
436    let row = sqlx::query(&query)
437        .bind(attempt_id.as_uuid())
438        .fetch_optional(pool)
439        .await?;
440    row.as_ref()
441        .map(payment_attempt_from_row)
442        .transpose()
443        .map_err(Into::into)
444}
445
446async fn unresolved_processor_charge_exists(
447    connection: &mut PgConnection,
448    attempt_id: PaymentAttemptId,
449) -> Result<bool, sqlx::Error> {
450    sqlx::query_scalar(
451        r#"
452        SELECT EXISTS (
453            SELECT 1
454            FROM billing_processor_charges
455            WHERE attempt_id = $1
456                AND progression_state IN (
457                    'pending', 'reconciliation_required', 'external_reversal_required'
458                )
459        )
460        "#,
461    )
462    .bind(attempt_id.as_uuid())
463    .fetch_one(&mut *connection)
464    .await
465}
466
467async fn update_attempt_for_manual_failure(
468    connection: &mut PgConnection,
469    attempt: &PaymentAttempt,
470    evidence: &ProcessorEvidence,
471) -> Result<u64, sqlx::Error> {
472    let descriptor = evidence.descriptor();
473    let result = sqlx::query(
474        r#"
475        UPDATE billing_payment_attempts
476        SET status = 'failed',
477            gateway_transaction_id = $2,
478            gateway_payment_method_reference = $3,
479            gateway_response = $4,
480            gateway_response_code = $5,
481            gateway_response_text = $6,
482            gateway_condition = $7,
483            payment_type = $8,
484            card_brand = $9,
485            card_last4 = $10,
486            card_exp_month = $11,
487            card_exp_year = $12,
488            resolved_at = clock_timestamp(),
489            updated_at = clock_timestamp()
490        WHERE id = $1 AND status = 'review_required'
491        "#,
492    )
493    .bind(attempt.identity().attempt_id().as_uuid())
494    .bind(evidence.transaction_id().map(GatewayTransactionId::expose))
495    .bind(
496        evidence
497            .payment_method_reference()
498            .map(GatewayPaymentMethodReference::expose),
499    )
500    .bind(evidence.response().map(GatewayDiagnostic::expose))
501    .bind(evidence.response_code().map(GatewayDiagnostic::expose))
502    .bind(evidence.response_text().map(GatewayDiagnostic::expose))
503    .bind(evidence.condition().map(GatewayDiagnostic::expose))
504    .bind(descriptor.payment_type().map(GatewayDiagnostic::expose))
505    .bind(descriptor.card_brand().map(GatewayDiagnostic::expose))
506    .bind(
507        descriptor
508            .card_last_four()
509            .map(syrup_rail::CardLastFour::expose),
510    )
511    .bind(descriptor.card_exp_month())
512    .bind(descriptor.card_exp_year())
513    .execute(&mut *connection)
514    .await?;
515    Ok(result.rows_affected())
516}
517
518async fn mark_subscription_past_due_for_manual_failure(
519    connection: &mut PgConnection,
520    attempt: &PaymentAttempt,
521) -> Result<Option<BillingEvent>, OperatorReviewError> {
522    let identity = attempt.identity();
523    let target = attempt.request().target();
524    let subscription_id = target
525        .subscription_id()
526        .ok_or(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?;
527    let plan_key = target
528        .plan_key()
529        .ok_or(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?;
530    let retry_at = sqlx::query_scalar::<_, DateTime<Utc>>(
531        r#"
532        UPDATE billing_subscriptions
533        SET status = 'past_due', updated_at = clock_timestamp()
534        WHERE id = $1
535            AND billing_scope_id = $2
536            AND subscriber_id = $3
537            AND plan_key = $4
538            AND status = 'active'
539        RETURNING next_renewal_at
540        "#,
541    )
542    .bind(subscription_id.as_uuid())
543    .bind(identity.billing_scope_id().as_uuid())
544    .bind(identity.subscriber_id().as_uuid())
545    .bind(plan_key.as_str())
546    .fetch_optional(&mut *connection)
547    .await?;
548    Ok(
549        retry_at.map(|retry_at| BillingEvent::SubscriptionPaymentFailed {
550            attempt_id: identity.attempt_id(),
551            subscription_id,
552            plan_key: plan_key.clone(),
553            retry_at,
554        }),
555    )
556}
557
558pub async fn attest_external_reversal(
559    pool: &PgPool,
560    host: &dyn ExternalReversalHostStore,
561    processor_charge_id: ProcessorChargeId,
562    actor_id: ActorId,
563    kind: ExternalReversalKind,
564    expected_transaction_id: &GatewayTransactionId,
565    reason: &ExternalReversalReason,
566) -> Result<ExternalReversalAttestationOutcome, OperatorReviewError> {
567    let mut transaction = pool.begin().await?;
568    set_enrollment_timeouts(&mut transaction).await?;
569    let Some(locator) = charge_locator(&mut transaction, processor_charge_id).await? else {
570        transaction.commit().await?;
571        return Ok(ExternalReversalAttestationOutcome::NotFound);
572    };
573    if let Some(plan_key) = &locator.plan_key {
574        lock_subscription_aggregate(&mut transaction, locator.subscriber_id, plan_key).await?;
575    }
576    let Some(attempt) = lock_payment_attempt_by_id_on_connection(
577        &mut transaction,
578        locator.billing_scope_id,
579        locator.attempt_id,
580    )
581    .await
582    .map_err(|error| match error {
583        crate::PaymentAttemptStoreError::Sql(error) => OperatorReviewError::Sql(error),
584        crate::PaymentAttemptStoreError::InvalidState(_) => {
585            OperatorReviewError::InvalidState("locked operator review attempt is invalid")
586        }
587    })?
588    else {
589        transaction.commit().await?;
590        return Ok(ExternalReversalAttestationOutcome::NotFound);
591    };
592    let Some(charge) = lock_processor_charge(&mut transaction, processor_charge_id).await? else {
593        return Err(OperatorReviewError::InvalidState(
594            "operator review processor charge disappeared while locked",
595        ));
596    };
597    if !locator_matches(&locator, &attempt, &charge) {
598        return Err(OperatorReviewError::InvalidState(
599            "operator review locator changed while locking",
600        ));
601    }
602
603    if let Some(existing) =
604        attestation_by_charge(&mut transaction, processor_charge_id.into_uuid()).await?
605    {
606        let matches = existing.actor_id() == actor_id
607            && existing.kind() == kind
608            && existing.reason() == reason
609            && existing.gateway_transaction_id() == expected_transaction_id
610            && charge.progression() == ProcessorChargeProgression::ExternallyReversed
611            && attestation_matches_source(&existing, &attempt, &charge);
612        if matches && can_release_host_target(&attempt, &charge) {
613            release_host_target(host, &mut transaction, &attempt).await?;
614        }
615        transaction.commit().await?;
616        return Ok(if matches {
617            ExternalReversalAttestationOutcome::Replayed {
618                attempt,
619                attestation: existing,
620            }
621        } else {
622            ExternalReversalAttestationOutcome::ReplayConflict
623        });
624    }
625
626    if !processor_charge_can_attest_external_reversal(&charge)
627        || charge.evidence().transaction_id() != Some(expected_transaction_id)
628    {
629        transaction.commit().await?;
630        return Ok(ExternalReversalAttestationOutcome::Ineligible);
631    }
632
633    let prior = expected_prior_resolution_code(&attempt, &charge).to_owned();
634    let final_code = expected_final_resolution_code(attempt.kind(), kind);
635    let attested_at: DateTime<Utc> = sqlx::query_scalar("SELECT clock_timestamp()")
636        .fetch_one(&mut *transaction)
637        .await?;
638    insert_attestation(
639        &mut transaction,
640        &attempt,
641        &charge,
642        actor_id,
643        kind,
644        reason,
645        &prior,
646        final_code,
647        attested_at,
648    )
649    .await?;
650    let updated = sqlx::query(
651        r#"
652        UPDATE billing_processor_charges
653        SET progression_state = 'externally_reversed',
654            state_code = $2,
655            externally_reversed_at = COALESCE(externally_reversed_at, clock_timestamp()),
656            updated_at = clock_timestamp()
657        WHERE id = $1 AND progression_state = 'external_reversal_required'
658        "#,
659    )
660    .bind(processor_charge_id.as_uuid())
661    .bind(&prior)
662    .execute(&mut *transaction)
663    .await?;
664    if updated.rows_affected() != 1 {
665        return Err(OperatorReviewError::InvalidState(
666            "eligible processor charge did not accept external reversal",
667        ));
668    }
669    if charge.role() == ProcessorChargeRole::Primary && !attempt.status().is_terminal() {
670        let updated = sqlx::query(
671            r#"
672            UPDATE billing_payment_attempts
673            SET status = 'failed', resolution_code = $2,
674                resolved_at = $3, updated_at = $3
675            WHERE id = $1 AND status = $4
676            "#,
677        )
678        .bind(attempt.identity().attempt_id().as_uuid())
679        .bind(final_code.as_str())
680        .bind(attested_at)
681        .bind(attempt.status().as_str())
682        .execute(&mut *transaction)
683        .await?;
684        if updated.rows_affected() != 1 {
685            return Err(OperatorReviewError::InvalidState(
686                "eligible attempt did not accept external reversal",
687            ));
688        }
689    }
690    if can_release_host_target(&attempt, &charge) {
691        release_host_target(host, &mut transaction, &attempt).await?;
692    }
693    let attempt = load_attempt(
694        &mut transaction,
695        locator.billing_scope_id,
696        locator.attempt_id,
697    )
698    .await?;
699    let attestation = attestation_by_charge(&mut transaction, processor_charge_id.into_uuid())
700        .await?
701        .ok_or(OperatorReviewError::InvalidState(
702            "external reversal attestation disappeared while locked",
703        ))?;
704    transaction.commit().await?;
705    Ok(ExternalReversalAttestationOutcome::Attested {
706        attempt,
707        attestation,
708    })
709}
710
711async fn charge_locator(
712    transaction: &mut Transaction<'_, Postgres>,
713    charge_id: ProcessorChargeId,
714) -> Result<Option<ChargeLocator>, OperatorReviewError> {
715    let row = sqlx::query(
716        r#"
717        SELECT charges.id, charges.attempt_id,
718            attempts.billing_scope_id, attempts.subscriber_id,
719            attempts.attempt_kind, attempts.plan_key, attempts.host_charge_target_id
720        FROM billing_processor_charges charges
721        INNER JOIN billing_payment_attempts attempts ON attempts.id = charges.attempt_id
722        WHERE charges.id = $1
723        "#,
724    )
725    .bind(charge_id.as_uuid())
726    .fetch_optional(&mut **transaction)
727    .await?;
728    row.map(|row| {
729        Ok(ChargeLocator {
730            charge_id: ProcessorChargeId::new(row.try_get("id")?),
731            attempt_id: PaymentAttemptId::new(row.try_get("attempt_id")?),
732            billing_scope_id: BillingScopeId::new(row.try_get("billing_scope_id")?),
733            subscriber_id: SubscriberId::new(row.try_get("subscriber_id")?),
734            kind: row
735                .try_get::<String, _>("attempt_kind")?
736                .parse()
737                .map_err(|_| {
738                    OperatorReviewError::InvalidState("operator locator attempt kind is invalid")
739                })?,
740            plan_key: row
741                .try_get::<Option<String>, _>("plan_key")?
742                .map(PlanKey::new)
743                .transpose()
744                .map_err(|_| {
745                    OperatorReviewError::InvalidState("operator locator plan key is invalid")
746                })?,
747            host_target_id: row
748                .try_get::<Option<Uuid>, _>("host_charge_target_id")?
749                .map(HostChargeTargetId::new),
750        })
751    })
752    .transpose()
753}
754
755async fn lock_processor_charge(
756    transaction: &mut Transaction<'_, Postgres>,
757    charge_id: ProcessorChargeId,
758) -> Result<Option<ProcessorCharge>, OperatorReviewError> {
759    let row = sqlx::query(
760        r#"
761        SELECT id, attempt_id, billing_scope_id, gateway_account_id,
762            gateway_order_id, attempt_kind, amount_cents, currency,
763            charge_role, progression_state, state_code,
764            gateway_transaction_id, gateway_payment_method_reference,
765            gateway_response, gateway_response_code, gateway_response_text,
766            gateway_condition, payment_type, card_brand, card_last4,
767            card_exp_month, card_exp_year, observed_at
768        FROM billing_processor_charges WHERE id = $1 FOR UPDATE
769        "#,
770    )
771    .bind(charge_id.as_uuid())
772    .fetch_optional(&mut **transaction)
773    .await?;
774    row.as_ref().map(processor_charge_from_row).transpose()
775}
776
777pub(crate) fn processor_charge_from_row(
778    row: &PgRow,
779) -> Result<ProcessorCharge, OperatorReviewError> {
780    let attempt_id = PaymentAttemptId::new(row.try_get("attempt_id")?);
781    let currency = CurrencyCode::new(&row.try_get::<String, _>("currency")?)
782        .map_err(|_| OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?;
783    let amount = Money::new(row.try_get("amount_cents")?, currency)
784        .map_err(|_| OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?;
785    let order = row.try_get::<String, _>("gateway_order_id")?;
786    let gateway_order_id = GatewayOrderId::from_generated_attempt(&order, attempt_id)
787        .or_else(|_| GatewayOrderId::from_correlation(&order))
788        .map_err(|_| OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?;
789    Ok(ProcessorCharge::new(
790        ProcessorChargeId::new(row.try_get("id")?),
791        attempt_id,
792        BillingScopeId::new(row.try_get("billing_scope_id")?),
793        GatewayAccountId::new(row.try_get("gateway_account_id")?),
794        gateway_order_id,
795        parse_kind(&row.try_get::<String, _>("attempt_kind")?)?,
796        amount,
797        parse_role(&row.try_get::<String, _>("charge_role")?)?,
798        parse_progression(&row.try_get::<String, _>("progression_state")?)?,
799        row.try_get::<Option<String>, _>("state_code")?
800            .as_deref()
801            .map(parse_charge_state_code)
802            .transpose()?,
803        processor_evidence_from_row(row).map_err(|_| {
804            OperatorReviewError::InvalidState("operator charge evidence is invalid")
805        })?,
806        row.try_get("observed_at")?,
807    ))
808}
809
810fn processor_charge_from_review_row(row: &PgRow) -> Result<ProcessorCharge, OperatorReviewError> {
811    let attempt_id = PaymentAttemptId::new(row.try_get("review_charge_attempt_id")?);
812    let currency = CurrencyCode::new(&row.try_get::<String, _>("review_charge_currency")?)
813        .map_err(|_| OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?;
814    let amount = Money::new(row.try_get("review_charge_amount_cents")?, currency)
815        .map_err(|_| OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?;
816    let order = row.try_get::<String, _>("review_charge_gateway_order_id")?;
817    let gateway_order_id = GatewayOrderId::from_generated_attempt(&order, attempt_id)
818        .or_else(|_| GatewayOrderId::from_correlation(&order))
819        .map_err(|_| OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?;
820    let card_last_four = row.try_get::<Option<String>, _>("review_charge_card_last4")?;
821    let card_exp_month = row.try_get::<Option<i16>, _>("review_charge_card_exp_month")?;
822    let card_exp_year = row.try_get::<Option<i16>, _>("review_charge_card_exp_year")?;
823    let descriptor = GatewayPaymentDescriptor::from_provider_parts(
824        review_diagnostic(row, "review_charge_payment_type")?,
825        review_diagnostic(row, "review_charge_card_brand")?,
826        card_last_four.as_deref(),
827        card_exp_month,
828        card_exp_year,
829    );
830    if descriptor.card_last_four().is_some() != card_last_four.is_some()
831        || descriptor.card_exp_month() != card_exp_month
832        || descriptor.card_exp_year() != card_exp_year
833    {
834        return Err(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE));
835    }
836    let evidence = ProcessorEvidence::new(
837        row.try_get::<Option<String>, _>("review_charge_gateway_transaction_id")?
838            .map(GatewayTransactionId::new)
839            .transpose()
840            .map_err(|_| OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?,
841        row.try_get::<Option<String>, _>("review_charge_gateway_payment_method_reference")?
842            .map(GatewayPaymentMethodReference::new)
843            .transpose()
844            .map_err(|_| OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))?,
845        review_diagnostic(row, "review_charge_gateway_response")?,
846        review_diagnostic(row, "review_charge_gateway_response_code")?,
847        review_diagnostic(row, "review_charge_gateway_response_text")?,
848        review_diagnostic(row, "review_charge_gateway_condition")?,
849        descriptor,
850    );
851    Ok(ProcessorCharge::new(
852        ProcessorChargeId::new(row.try_get("review_charge_id")?),
853        attempt_id,
854        BillingScopeId::new(row.try_get("review_charge_billing_scope_id")?),
855        GatewayAccountId::new(row.try_get("review_charge_gateway_account_id")?),
856        gateway_order_id,
857        parse_kind(&row.try_get::<String, _>("review_charge_attempt_kind")?)?,
858        amount,
859        parse_role(&row.try_get::<String, _>("review_charge_role")?)?,
860        parse_progression(&row.try_get::<String, _>("review_charge_progression_state")?)?,
861        row.try_get::<Option<String>, _>("review_charge_state_code")?
862            .as_deref()
863            .map(parse_charge_state_code)
864            .transpose()?,
865        evidence,
866        row.try_get("review_charge_observed_at")?,
867    ))
868}
869
870fn review_diagnostic(
871    row: &PgRow,
872    column: &'static str,
873) -> Result<Option<GatewayDiagnostic>, sqlx::Error> {
874    row.try_get::<Option<String>, _>(column)
875        .map(|value| value.map(|value| GatewayDiagnostic::new(&value)))
876}
877
878#[allow(clippy::too_many_arguments)]
879async fn insert_attestation(
880    transaction: &mut Transaction<'_, Postgres>,
881    attempt: &PaymentAttempt,
882    charge: &ProcessorCharge,
883    actor_id: ActorId,
884    kind: ExternalReversalKind,
885    reason: &ExternalReversalReason,
886    prior: &str,
887    final_code: PaymentResolutionCode,
888    attested_at: DateTime<Utc>,
889) -> Result<(), OperatorReviewError> {
890    let identity = attempt.identity();
891    let evidence = charge.evidence();
892    let descriptor = evidence.descriptor();
893    let transaction_id = evidence
894        .transaction_id()
895        .ok_or(OperatorReviewError::InvalidState(
896            "eligible charge is missing transaction identity",
897        ))?;
898    sqlx::query(
899        r#"
900        INSERT INTO billing_external_reversal_attestations (
901            processor_charge_id, attempt_id, actor_id, reversal_kind, reason,
902            prior_resolution_code, final_resolution_code,
903            gateway_account_id, gateway_configuration_id, gateway_order_id,
904            amount_cents, currency, gateway_transaction_id,
905            gateway_payment_method_reference, gateway_response, gateway_response_code,
906            gateway_response_text, gateway_condition, payment_type, card_brand,
907            card_last4, card_exp_month, card_exp_year, attested_at
908        ) VALUES (
909            $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
910            $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24
911        )
912        "#,
913    )
914    .bind(charge.id().as_uuid())
915    .bind(identity.attempt_id().as_uuid())
916    .bind(actor_id.as_uuid())
917    .bind(kind.as_str())
918    .bind(reason.expose())
919    .bind(prior)
920    .bind(final_code.as_str())
921    .bind(identity.gateway_account_id().as_uuid())
922    .bind(identity.gateway_configuration_id().as_uuid())
923    .bind(charge.gateway_order_id().expose())
924    .bind(charge.amount().cents())
925    .bind(charge.amount().currency().as_str())
926    .bind(transaction_id.expose())
927    .bind(
928        evidence
929            .payment_method_reference()
930            .map(|value| value.expose()),
931    )
932    .bind(evidence.response().map(|value| value.expose()))
933    .bind(evidence.response_code().map(|value| value.expose()))
934    .bind(evidence.response_text().map(|value| value.expose()))
935    .bind(evidence.condition().map(|value| value.expose()))
936    .bind(descriptor.payment_type().map(|value| value.expose()))
937    .bind(descriptor.card_brand().map(|value| value.expose()))
938    .bind(descriptor.card_last_four().map(|value| value.expose()))
939    .bind(descriptor.card_exp_month())
940    .bind(descriptor.card_exp_year())
941    .bind(attested_at)
942    .execute(&mut **transaction)
943    .await?;
944    Ok(())
945}
946
947pub(crate) async fn attestation_by_charge(
948    connection: &mut PgConnection,
949    charge_id: Uuid,
950) -> Result<Option<ExternalReversalAttestation>, OperatorReviewError> {
951    let row = sqlx::query(
952        r#"
953        SELECT processor_charge_id, attempt_id, actor_id, reversal_kind, reason,
954            prior_resolution_code, final_resolution_code,
955            gateway_account_id, gateway_configuration_id, gateway_order_id,
956            amount_cents, currency, gateway_transaction_id,
957            gateway_payment_method_reference, gateway_response, gateway_response_code,
958            gateway_response_text, gateway_condition, payment_type, card_brand,
959            card_last4, card_exp_month, card_exp_year, attested_at
960        FROM billing_external_reversal_attestations
961        WHERE processor_charge_id = $1 FOR UPDATE
962        "#,
963    )
964    .bind(charge_id)
965    .fetch_optional(connection)
966    .await?;
967    row.as_ref().map(attestation_from_row).transpose()
968}
969
970fn attestation_from_row(row: &PgRow) -> Result<ExternalReversalAttestation, OperatorReviewError> {
971    let attempt_id = PaymentAttemptId::new(row.try_get("attempt_id")?);
972    let order = row.try_get::<String, _>("gateway_order_id")?;
973    let order = GatewayOrderId::from_generated_attempt(&order, attempt_id)
974        .or_else(|_| GatewayOrderId::from_correlation(&order))
975        .map_err(|_| {
976            OperatorReviewError::InvalidState("operator attestation order identity is invalid")
977        })?;
978    let currency = CurrencyCode::new(&row.try_get::<String, _>("currency")?).map_err(|_| {
979        OperatorReviewError::InvalidState("operator attestation currency is invalid")
980    })?;
981    Ok(ExternalReversalAttestation::new(
982        ProcessorChargeId::new(row.try_get("processor_charge_id")?),
983        attempt_id,
984        ActorId::new(row.try_get("actor_id")?),
985        parse_reversal_kind(&row.try_get::<String, _>("reversal_kind")?).map_err(|_| {
986            OperatorReviewError::InvalidState("operator attestation reversal kind is invalid")
987        })?,
988        ExternalReversalReason::new(row.try_get::<String, _>("reason")?).map_err(|_| {
989            OperatorReviewError::InvalidState("operator attestation reason is invalid")
990        })?,
991        row.try_get("prior_resolution_code")?,
992        PaymentResolutionCode::try_from(
993            row.try_get::<String, _>("final_resolution_code")?.as_str(),
994        )
995        .map_err(|_| {
996            OperatorReviewError::InvalidState("operator attestation final resolution is invalid")
997        })?,
998        GatewayAccountId::new(row.try_get("gateway_account_id")?),
999        GatewayConfigurationId::new(row.try_get("gateway_configuration_id")?),
1000        order,
1001        ChargeAmount::new(row.try_get("amount_cents")?, currency).map_err(|_| {
1002            OperatorReviewError::InvalidState("operator attestation amount is invalid")
1003        })?,
1004        GatewayTransactionId::new(row.try_get::<String, _>("gateway_transaction_id")?).map_err(
1005            |_| {
1006                OperatorReviewError::InvalidState(
1007                    "operator attestation transaction identity is invalid",
1008                )
1009            },
1010        )?,
1011        processor_evidence_from_row(row).map_err(|_| {
1012            OperatorReviewError::InvalidState("operator attestation evidence is invalid")
1013        })?,
1014        row.try_get("attested_at")?,
1015    ))
1016}
1017
1018fn locator_matches(
1019    locator: &ChargeLocator,
1020    attempt: &PaymentAttempt,
1021    charge: &ProcessorCharge,
1022) -> bool {
1023    let identity = attempt.identity();
1024    locator.charge_id == charge.id()
1025        && locator.attempt_id == identity.attempt_id()
1026        && locator.billing_scope_id == identity.billing_scope_id()
1027        && locator.subscriber_id == identity.subscriber_id()
1028        && locator.kind == attempt.kind()
1029        && locator.plan_key.as_ref() == attempt.request().target().plan_key()
1030        && locator.host_target_id == attempt.request().target().host_charge_target_id()
1031        && charge.attempt_id() == identity.attempt_id()
1032        && charge.billing_scope_id() == identity.billing_scope_id()
1033        && charge.gateway_account_id() == identity.gateway_account_id()
1034        && charge.attempt_kind() == attempt.kind()
1035        && charge.gateway_order_id() == attempt.request().gateway_order_id()
1036        && charge.amount() == attempt.request().amount()
1037}
1038
1039fn processor_charge_can_attest_external_reversal(charge: &ProcessorCharge) -> bool {
1040    charge.progression() == ProcessorChargeProgression::ExternalReversalRequired
1041        && charge.evidence().transaction_id().is_some()
1042        && matches!(
1043            charge.attempt_kind(),
1044            PaymentAttemptKind::HostCharge
1045                | PaymentAttemptKind::SubscriptionInitial
1046                | PaymentAttemptKind::SubscriptionRenewal
1047                | PaymentAttemptKind::SubscriptionRecovery
1048        )
1049}
1050
1051fn can_release_host_target(attempt: &PaymentAttempt, charge: &ProcessorCharge) -> bool {
1052    attempt.kind() == PaymentAttemptKind::HostCharge
1053        && (matches!(
1054            attempt.status(),
1055            PaymentAttemptStatus::Declined | PaymentAttemptStatus::Failed
1056        ) || (charge.role() == ProcessorChargeRole::Primary && !attempt.status().is_terminal()))
1057}
1058
1059async fn release_host_target(
1060    host: &dyn ExternalReversalHostStore,
1061    transaction: &mut Transaction<'_, Postgres>,
1062    attempt: &PaymentAttempt,
1063) -> Result<(), OperatorReviewError> {
1064    let target_id = attempt.request().target().host_charge_target_id().ok_or(
1065        OperatorReviewError::InvalidState("host charge is missing exact target"),
1066    )?;
1067    let identity = attempt.identity();
1068    let _ = host
1069        .release(
1070            &mut *transaction,
1071            ExternalReversalHostChargeRelease::new(
1072                identity.billing_scope_id(),
1073                identity.subscriber_id(),
1074                target_id,
1075            ),
1076        )
1077        .await?;
1078    Ok(())
1079}
1080
1081pub(crate) fn attestation_matches_source(
1082    attestation: &ExternalReversalAttestation,
1083    attempt: &PaymentAttempt,
1084    charge: &ProcessorCharge,
1085) -> bool {
1086    attestation.processor_charge_id() == charge.id()
1087        && attestation.attempt_id() == attempt.identity().attempt_id()
1088        && attestation.gateway_account_id() == attempt.identity().gateway_account_id()
1089        && attestation.gateway_configuration_id() == attempt.identity().gateway_configuration_id()
1090        && attestation.gateway_order_id() == attempt.request().gateway_order_id()
1091        && attestation.amount().money() == charge.amount()
1092        && attestation.gateway_transaction_id()
1093            == charge
1094                .evidence()
1095                .transaction_id()
1096                .expect("eligible charge has transaction identity")
1097        && attestation.processor_evidence() == charge.evidence()
1098        && attestation.prior_resolution_code() == expected_prior_resolution_code(attempt, charge)
1099        && attestation.final_resolution_code()
1100            == expected_final_resolution_code(attempt.kind(), attestation.kind())
1101}
1102
1103fn expected_prior_resolution_code(
1104    attempt: &PaymentAttempt,
1105    charge: &ProcessorCharge,
1106) -> &'static str {
1107    if charge.role() == ProcessorChargeRole::Primary
1108        && charge.attempt_kind() == PaymentAttemptKind::SubscriptionInitial
1109        && (attempt.state().resolution_code()
1110            == Some(PaymentResolutionCode::SubscriptionInitialCurrentGrantConflict)
1111            || charge.state_code()
1112                == Some(ProcessorChargeStateCode::PaymentResolution(
1113                    PaymentResolutionCode::SubscriptionInitialCurrentGrantConflict,
1114                )))
1115    {
1116        PaymentResolutionCode::SubscriptionInitialCurrentGrantConflict.as_str()
1117    } else {
1118        "processor_charge_external_reversal_required"
1119    }
1120}
1121
1122fn expected_final_resolution_code(
1123    attempt_kind: PaymentAttemptKind,
1124    kind: ExternalReversalKind,
1125) -> PaymentResolutionCode {
1126    match (attempt_kind, kind) {
1127        (PaymentAttemptKind::SubscriptionInitial, ExternalReversalKind::Refund) => {
1128            PaymentResolutionCode::SubscriptionInitialExternallyRefunded
1129        }
1130        (PaymentAttemptKind::SubscriptionInitial, ExternalReversalKind::Void) => {
1131            PaymentResolutionCode::SubscriptionInitialExternallyVoided
1132        }
1133        (_, ExternalReversalKind::Refund) => {
1134            PaymentResolutionCode::ProcessorChargeExternallyRefunded
1135        }
1136        (_, ExternalReversalKind::Void) => PaymentResolutionCode::ProcessorChargeExternallyVoided,
1137    }
1138}
1139
1140async fn load_attempt(
1141    transaction: &mut Transaction<'_, Postgres>,
1142    scope: BillingScopeId,
1143    attempt_id: PaymentAttemptId,
1144) -> Result<PaymentAttempt, OperatorReviewError> {
1145    let query = format!(
1146        "{} WHERE billing_scope_id = $1 AND id = $2",
1147        crate::attempts::PAYMENT_ATTEMPT_SELECT
1148    );
1149    let row = sqlx::query(&query)
1150        .bind(scope.as_uuid())
1151        .bind(attempt_id.as_uuid())
1152        .fetch_optional(&mut **transaction)
1153        .await?
1154        .ok_or(OperatorReviewError::InvalidState(
1155            "operator review attempt disappeared",
1156        ))?;
1157    payment_attempt_from_row(&row).map_err(|_| {
1158        OperatorReviewError::InvalidState("post-attestation payment attempt is invalid")
1159    })
1160}
1161
1162fn parse_kind(value: &str) -> Result<PaymentAttemptKind, OperatorReviewError> {
1163    value
1164        .parse()
1165        .map_err(|_| OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))
1166}
1167fn parse_role(value: &str) -> Result<ProcessorChargeRole, OperatorReviewError> {
1168    match value {
1169        "primary" => Ok(ProcessorChargeRole::Primary),
1170        "additional" => Ok(ProcessorChargeRole::Additional),
1171        _ => Err(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE)),
1172    }
1173}
1174fn parse_progression(value: &str) -> Result<ProcessorChargeProgression, OperatorReviewError> {
1175    match value {
1176        "pending" => Ok(ProcessorChargeProgression::Pending),
1177        "reconciliation_required" => Ok(ProcessorChargeProgression::ReconciliationRequired),
1178        "external_reversal_required" => Ok(ProcessorChargeProgression::ExternalReversalRequired),
1179        "applied" => Ok(ProcessorChargeProgression::Applied),
1180        "externally_reversed" => Ok(ProcessorChargeProgression::ExternallyReversed),
1181        _ => Err(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE)),
1182    }
1183}
1184fn parse_reversal_kind(value: &str) -> Result<ExternalReversalKind, OperatorReviewError> {
1185    match value {
1186        "refund" => Ok(ExternalReversalKind::Refund),
1187        "void" => Ok(ExternalReversalKind::Void),
1188        _ => Err(OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE)),
1189    }
1190}
1191
1192fn parse_charge_state_code(value: &str) -> Result<ProcessorChargeStateCode, OperatorReviewError> {
1193    match value {
1194        "processor_charge_external_reversal_required" => {
1195            return Ok(ProcessorChargeStateCode::ExternalReversalRequired);
1196        }
1197        "additional_approved_charge_identified" => {
1198            return Ok(ProcessorChargeStateCode::AdditionalApprovedChargeIdentified);
1199        }
1200        "processor_charge_transaction_identity_required" => {
1201            return Ok(ProcessorChargeStateCode::TransactionIdentityRequired);
1202        }
1203        "approved_charge_waiting_for_application" => {
1204            return Ok(ProcessorChargeStateCode::ApprovedChargeWaitingForApplication);
1205        }
1206        "zero_amount_additional_approved_charge" => {
1207            return Ok(ProcessorChargeStateCode::ZeroAmountAdditionalApprovedCharge);
1208        }
1209        _ => {}
1210    }
1211    PaymentResolutionCode::try_from(value)
1212        .map(ProcessorChargeStateCode::PaymentResolution)
1213        .map_err(|_| OperatorReviewError::InvalidState(INVALID_OPERATOR_STATE))
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218    use std::{
1219        error::Error,
1220        fmt,
1221        sync::{
1222            Arc,
1223            atomic::{AtomicU64, Ordering},
1224        },
1225        time::Duration,
1226    };
1227
1228    use super::*;
1229    use crate::test_support::{TestDatabase, create_gateway_account};
1230    use crate::transactions::{BillingTransaction, BillingTransactionSubjectState};
1231    use tokio::sync::Mutex;
1232
1233    #[derive(Debug)]
1234    struct InjectedTestError;
1235
1236    impl fmt::Display for InjectedTestError {
1237        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1238            formatter.write_str("injected test error")
1239        }
1240    }
1241
1242    impl Error for InjectedTestError {}
1243
1244    #[derive(Clone)]
1245    struct TestCoordinator {
1246        pool: PgPool,
1247        events: Arc<Mutex<Vec<BillingEvent>>>,
1248        fail_event: bool,
1249    }
1250
1251    #[async_trait]
1252    impl BillingTransactionCoordinator for TestCoordinator {
1253        async fn begin(
1254            &self,
1255            _subject: BillingEventSubject,
1256            _lock_timeout: Duration,
1257        ) -> Result<Box<dyn BillingTransaction>, BillingTransactionError> {
1258            Ok(Box::new(TestTransaction {
1259                transaction: Some(
1260                    self.pool
1261                        .begin()
1262                        .await
1263                        .map_err(BillingTransactionError::new)?,
1264                ),
1265                events: Arc::clone(&self.events),
1266                fail_event: self.fail_event,
1267            }))
1268        }
1269    }
1270
1271    struct TestTransaction {
1272        transaction: Option<Transaction<'static, Postgres>>,
1273        events: Arc<Mutex<Vec<BillingEvent>>>,
1274        fail_event: bool,
1275    }
1276
1277    #[async_trait]
1278    impl BillingTransaction for TestTransaction {
1279        fn connection(&mut self) -> &mut PgConnection {
1280            &mut *self.transaction.as_mut().expect("active test transaction")
1281        }
1282
1283        fn subject_state(&self) -> BillingTransactionSubjectState {
1284            BillingTransactionSubjectState::LiveRecipient
1285        }
1286
1287        async fn append_event(
1288            &mut self,
1289            event: &BillingEvent,
1290        ) -> Result<(), BillingEventWriteError> {
1291            if self.fail_event {
1292                return Err(BillingEventWriteError::new(InjectedTestError));
1293            }
1294            self.events.lock().await.push(event.clone());
1295            Ok(())
1296        }
1297
1298        async fn commit(mut self: Box<Self>) -> Result<(), BillingTransactionError> {
1299            self.transaction
1300                .take()
1301                .expect("active test transaction")
1302                .commit()
1303                .await
1304                .map_err(BillingTransactionError::new)
1305        }
1306
1307        async fn rollback(mut self: Box<Self>) -> Result<(), BillingTransactionError> {
1308            self.transaction
1309                .take()
1310                .expect("active test transaction")
1311                .rollback()
1312                .await
1313                .map_err(BillingTransactionError::new)
1314        }
1315    }
1316
1317    struct ExactManualFailureHost;
1318
1319    #[async_trait]
1320    impl ManualAttemptFailureHostStore for ExactManualFailureHost {
1321        async fn lock_payment_failure_target(
1322            &self,
1323            connection: &mut PgConnection,
1324            charge: ManualFailureHostCharge,
1325        ) -> Result<(), ManualAttemptFailureHostStoreError> {
1326            sqlx::query_scalar::<_, Uuid>(
1327                "SELECT id FROM manual_failure_host_targets WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3 FOR UPDATE",
1328            )
1329            .bind(charge.target_id().as_uuid())
1330            .bind(charge.billing_scope_id().as_uuid())
1331            .bind(charge.subscriber_id().as_uuid())
1332            .fetch_optional(connection)
1333            .await
1334            .map_err(ManualAttemptFailureHostStoreError::new)?;
1335            Ok(())
1336        }
1337
1338        async fn mark_payment_failed(
1339            &self,
1340            connection: &mut PgConnection,
1341            charge: ManualFailureHostCharge,
1342        ) -> Result<ManualAttemptFailureHostTransitionOutcome, ManualAttemptFailureHostStoreError>
1343        {
1344            let result = sqlx::query(
1345                "UPDATE manual_failure_host_targets SET status = 'payment_failed' WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3 AND status = 'pending'",
1346            )
1347            .bind(charge.target_id().as_uuid())
1348            .bind(charge.billing_scope_id().as_uuid())
1349            .bind(charge.subscriber_id().as_uuid())
1350            .execute(connection)
1351            .await
1352            .map_err(ManualAttemptFailureHostStoreError::new)?;
1353            Ok(if result.rows_affected() == 1 {
1354                ManualAttemptFailureHostTransitionOutcome::Changed
1355            } else {
1356                ManualAttemptFailureHostTransitionOutcome::Unchanged
1357            })
1358        }
1359    }
1360
1361    async fn insert_review_renewal(
1362        database: &TestDatabase,
1363        account: &crate::test_support::GatewayAccountFixture,
1364        subscriber_id: Uuid,
1365        suffix: &str,
1366    ) -> Result<(Uuid, Uuid), Box<dyn Error>> {
1367        let payment_method_id = Uuid::now_v7();
1368        let subscription_id = Uuid::now_v7();
1369        let attempt_id = Uuid::now_v7();
1370        let initial_transaction_id = format!("txn-initial-{suffix}");
1371        sqlx::query(
1372            r#"
1373            INSERT INTO billing_payment_methods (
1374                id, billing_scope_id, subscriber_id, gateway_account_id,
1375                gateway_payment_method_reference, status
1376            ) VALUES ($1, $2, $3, $4, $5, 'active')
1377            "#,
1378        )
1379        .bind(payment_method_id)
1380        .bind(account.billing_scope_id)
1381        .bind(subscriber_id)
1382        .bind(account.gateway_account_id)
1383        .bind(format!("method-{suffix}"))
1384        .execute(&database.pool)
1385        .await?;
1386        sqlx::query(
1387            r#"
1388            WITH clock AS MATERIALIZED (SELECT clock_timestamp() AS observed_at)
1389            INSERT INTO billing_subscriptions (
1390                id, billing_scope_id, subscriber_id, plan_key, status,
1391                gateway_account_id, payment_method_id, amount_cents, currency,
1392                current_period_start_at, current_period_end_at, next_renewal_at,
1393                initial_transaction_id
1394            ) SELECT
1395                $1, $2, $3, 'test_plan', 'active', $4, $5, 500, 'USD',
1396                observed_at - interval '1 month', observed_at, observed_at,
1397                $6
1398            FROM clock
1399            "#,
1400        )
1401        .bind(subscription_id)
1402        .bind(account.billing_scope_id)
1403        .bind(subscriber_id)
1404        .bind(account.gateway_account_id)
1405        .bind(payment_method_id)
1406        .bind(&initial_transaction_id)
1407        .execute(&database.pool)
1408        .await?;
1409        sqlx::query(
1410            r#"
1411            WITH clock AS MATERIALIZED (SELECT clock_timestamp() AS observed_at)
1412            INSERT INTO billing_payment_attempts (
1413                id, billing_scope_id, subscriber_id, plan_key,
1414                subscription_id, payment_method_id, attempt_kind, status,
1415                idempotency_key, request_fingerprint, amount_cents, currency,
1416                billing_period_start_at, billing_period_end_at,
1417                gateway_account_id, gateway_configuration_id, gateway_order_id,
1418                submitted_at, review_required_at,
1419                subscription_expected_payment_method_id,
1420                subscription_expected_initial_transaction_id,
1421                subscription_expected_status
1422            ) SELECT
1423                $1, $2, $3, 'test_plan', $4, $5, 'subscription_renewal',
1424                'review_required', $6, $7, 500, 'USD', observed_at,
1425                observed_at + interval '1 month', $8, $9, $10,
1426                observed_at, observed_at, $5, $11, 'active'
1427            FROM clock
1428            "#,
1429        )
1430        .bind(attempt_id)
1431        .bind(account.billing_scope_id)
1432        .bind(subscriber_id)
1433        .bind(subscription_id)
1434        .bind(payment_method_id)
1435        .bind(format!("idem-{suffix}"))
1436        .bind(format!("fingerprint-{suffix}"))
1437        .bind(account.gateway_account_id)
1438        .bind(account.gateway_configuration_id)
1439        .bind(format!("order-{suffix}"))
1440        .bind(initial_transaction_id)
1441        .execute(&database.pool)
1442        .await?;
1443        Ok((subscription_id, attempt_id))
1444    }
1445
1446    #[tokio::test]
1447    async fn manual_failure_is_policy_safe_atomic_eventful_and_host_exact()
1448    -> Result<(), Box<dyn Error>> {
1449        let database = TestDatabase::start("rail_manual").await?;
1450        let account = create_gateway_account(&database.pool, "nmi").await?;
1451        sqlx::query(
1452            r#"
1453            CREATE TABLE manual_failure_host_targets (
1454                id uuid PRIMARY KEY,
1455                billing_scope_id uuid NOT NULL,
1456                subscriber_id uuid NOT NULL,
1457                status text NOT NULL
1458            )
1459            "#,
1460        )
1461        .execute(&database.pool)
1462        .await?;
1463        let events = Arc::new(Mutex::new(Vec::new()));
1464        let coordinator = TestCoordinator {
1465            pool: database.pool.clone(),
1466            events: Arc::clone(&events),
1467            fail_event: false,
1468        };
1469        let host = ExactManualFailureHost;
1470
1471        let subscriber_id = Uuid::now_v7();
1472        let (subscription_id, renewal_attempt_id) =
1473            insert_review_renewal(&database, &account, subscriber_id, "success").await?;
1474        let outcome = fail_review_required_attempt(
1475            &database.pool,
1476            &coordinator,
1477            &host,
1478            PaymentAttemptId::new(renewal_attempt_id),
1479        )
1480        .await?;
1481        assert!(matches!(outcome, ManualAttemptFailureOutcome::Failed(_)));
1482        let (attempt_status, response_text, condition): (String, Option<String>, Option<String>) =
1483            sqlx::query_as(
1484                "SELECT status, gateway_response_text, gateway_condition FROM billing_payment_attempts WHERE id = $1",
1485            )
1486            .bind(renewal_attempt_id)
1487            .fetch_one(&database.pool)
1488            .await?;
1489        assert_eq!(attempt_status, "failed");
1490        assert_eq!(
1491            response_text.as_deref(),
1492            Some(syrup_rail::MANUAL_ATTEMPT_FAILURE_NOTE)
1493        );
1494        assert_eq!(condition.as_deref(), Some("failed"));
1495        let subscription_status: String =
1496            sqlx::query_scalar("SELECT status FROM billing_subscriptions WHERE id = $1")
1497                .bind(subscription_id)
1498                .fetch_one(&database.pool)
1499                .await?;
1500        assert_eq!(subscription_status, "past_due");
1501        let recorded_events = events.lock().await;
1502        assert_eq!(recorded_events.len(), 1);
1503        assert!(matches!(
1504            &recorded_events[0],
1505            BillingEvent::SubscriptionPaymentFailed { attempt_id, .. }
1506                if *attempt_id == PaymentAttemptId::new(renewal_attempt_id)
1507        ));
1508        drop(recorded_events);
1509        assert!(matches!(
1510            fail_review_required_attempt(
1511                &database.pool,
1512                &coordinator,
1513                &host,
1514                PaymentAttemptId::new(renewal_attempt_id),
1515            )
1516            .await?,
1517            ManualAttemptFailureOutcome::KeptOpen(_)
1518        ));
1519        assert_eq!(events.lock().await.len(), 1);
1520
1521        let blocked_subscriber_id = Uuid::now_v7();
1522        let (blocked_subscription_id, blocked_attempt_id) =
1523            insert_review_renewal(&database, &account, blocked_subscriber_id, "blocked").await?;
1524        let failing_coordinator = TestCoordinator {
1525            pool: database.pool.clone(),
1526            events: Arc::new(Mutex::new(Vec::new())),
1527            fail_event: true,
1528        };
1529        assert!(
1530            fail_review_required_attempt(
1531                &database.pool,
1532                &failing_coordinator,
1533                &host,
1534                PaymentAttemptId::new(blocked_attempt_id),
1535            )
1536            .await
1537            .is_err()
1538        );
1539        let rolled_back: (String, String) = sqlx::query_as(
1540            "SELECT attempts.status, subscriptions.status FROM billing_payment_attempts attempts INNER JOIN billing_subscriptions subscriptions ON subscriptions.id = attempts.subscription_id WHERE attempts.id = $1",
1541        )
1542        .bind(blocked_attempt_id)
1543        .fetch_one(&database.pool)
1544        .await?;
1545        assert_eq!(
1546            rolled_back,
1547            ("review_required".to_owned(), "active".to_owned())
1548        );
1549        sqlx::query(
1550            r#"
1551            INSERT INTO billing_processor_charges (
1552                id, attempt_id, billing_scope_id, gateway_account_id,
1553                gateway_order_id, gateway_transaction_id, charge_role,
1554                progression_state, observed_at, attempt_kind, plan_key,
1555                amount_cents, currency
1556            ) SELECT
1557                $2, id, billing_scope_id, gateway_account_id, gateway_order_id,
1558                'txn-blocked', 'primary', 'pending', clock_timestamp(),
1559                attempt_kind, plan_key, amount_cents, currency
1560            FROM billing_payment_attempts WHERE id = $1
1561            "#,
1562        )
1563        .bind(blocked_attempt_id)
1564        .bind(Uuid::now_v7())
1565        .execute(&database.pool)
1566        .await?;
1567        assert!(matches!(
1568            fail_review_required_attempt(
1569                &database.pool,
1570                &coordinator,
1571                &host,
1572                PaymentAttemptId::new(blocked_attempt_id),
1573            )
1574            .await?,
1575            ManualAttemptFailureOutcome::KeptOpen(_)
1576        ));
1577        let blocked_subscription_status: String =
1578            sqlx::query_scalar("SELECT status FROM billing_subscriptions WHERE id = $1")
1579                .bind(blocked_subscription_id)
1580                .fetch_one(&database.pool)
1581                .await?;
1582        assert_eq!(blocked_subscription_status, "active");
1583
1584        let host_subscriber_id = Uuid::now_v7();
1585        let host_target_id = Uuid::now_v7();
1586        let host_attempt_id = Uuid::now_v7();
1587        sqlx::query(
1588            "INSERT INTO manual_failure_host_targets (id, billing_scope_id, subscriber_id, status) VALUES ($1, $2, $3, 'pending')",
1589        )
1590        .bind(host_target_id)
1591        .bind(account.billing_scope_id)
1592        .bind(host_subscriber_id)
1593        .execute(&database.pool)
1594        .await?;
1595        sqlx::query(
1596            r#"
1597            INSERT INTO billing_payment_attempts (
1598                id, billing_scope_id, subscriber_id, host_charge_target_id,
1599                attempt_kind, status, idempotency_key, request_fingerprint,
1600                amount_cents, currency, gateway_account_id,
1601                gateway_configuration_id, gateway_order_id, review_required_at
1602            ) VALUES (
1603                $1, $2, $3, $4, 'host_charge', 'review_required', $5, $6,
1604                500, 'USD', $7, $8, $9, clock_timestamp()
1605            )
1606            "#,
1607        )
1608        .bind(host_attempt_id)
1609        .bind(account.billing_scope_id)
1610        .bind(host_subscriber_id)
1611        .bind(host_target_id)
1612        .bind(format!("idem-{host_attempt_id}"))
1613        .bind(format!("fingerprint-{host_attempt_id}"))
1614        .bind(account.gateway_account_id)
1615        .bind(account.gateway_configuration_id)
1616        .bind(format!("host-order-{host_attempt_id}"))
1617        .execute(&database.pool)
1618        .await?;
1619        assert!(matches!(
1620            fail_review_required_attempt(
1621                &database.pool,
1622                &coordinator,
1623                &host,
1624                PaymentAttemptId::new(host_attempt_id),
1625            )
1626            .await?,
1627            ManualAttemptFailureOutcome::Failed(_)
1628        ));
1629        let host_state: (String, String) = sqlx::query_as(
1630            "SELECT attempts.status, targets.status FROM billing_payment_attempts attempts INNER JOIN manual_failure_host_targets targets ON targets.id = attempts.host_charge_target_id WHERE attempts.id = $1",
1631        )
1632        .bind(host_attempt_id)
1633        .fetch_one(&database.pool)
1634        .await?;
1635        assert_eq!(
1636            host_state,
1637            ("failed".to_owned(), "payment_failed".to_owned())
1638        );
1639        Ok(())
1640    }
1641
1642    #[derive(Default)]
1643    struct ExactHostRelease {
1644        calls: AtomicU64,
1645    }
1646
1647    #[async_trait]
1648    impl ExternalReversalHostStore for ExactHostRelease {
1649        async fn release(
1650            &self,
1651            connection: &mut PgConnection,
1652            release: ExternalReversalHostChargeRelease,
1653        ) -> Result<ExternalReversalHostTransitionOutcome, ExternalReversalHostStoreError> {
1654            self.calls.fetch_add(1, Ordering::SeqCst);
1655            let changed = sqlx::query(
1656                r#"
1657                UPDATE host_targets SET released = true
1658                WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
1659                    AND released = false
1660                "#,
1661            )
1662            .bind(release.target_id().as_uuid())
1663            .bind(release.billing_scope_id().as_uuid())
1664            .bind(release.subscriber_id().as_uuid())
1665            .execute(connection)
1666            .await
1667            .map_err(ExternalReversalHostStoreError::new)?
1668            .rows_affected()
1669                == 1;
1670            Ok(if changed {
1671                ExternalReversalHostTransitionOutcome::Changed
1672            } else {
1673                ExternalReversalHostTransitionOutcome::Unchanged
1674            })
1675        }
1676    }
1677
1678    #[tokio::test]
1679    async fn external_reversal_is_exact_atomic_replayable_and_conflict_safe()
1680    -> Result<(), Box<dyn Error>> {
1681        let database = TestDatabase::start("rail_operator").await?;
1682        let account = create_gateway_account(&database.pool, "nmi").await?;
1683        let attempt_id = Uuid::now_v7();
1684        let charge_id = Uuid::now_v7();
1685        let additional_charge_id = Uuid::now_v7();
1686        let subscriber_id = Uuid::now_v7();
1687        let target_id = Uuid::now_v7();
1688        let order_id = format!("ck_{}", attempt_id.simple());
1689        sqlx::query(
1690            r#"
1691            CREATE TABLE host_targets (
1692                id uuid PRIMARY KEY, billing_scope_id uuid NOT NULL,
1693                subscriber_id uuid NOT NULL, released boolean NOT NULL DEFAULT false
1694            )
1695            "#,
1696        )
1697        .execute(&database.pool)
1698        .await?;
1699        sqlx::query(
1700            "INSERT INTO host_targets (id, billing_scope_id, subscriber_id) VALUES ($1, $2, $3)",
1701        )
1702        .bind(target_id)
1703        .bind(account.billing_scope_id)
1704        .bind(subscriber_id)
1705        .execute(&database.pool)
1706        .await?;
1707        sqlx::query(
1708            r#"
1709            INSERT INTO billing_payment_attempts (
1710                id, billing_scope_id, subscriber_id, host_charge_target_id,
1711                attempt_kind, status, idempotency_key, request_fingerprint,
1712                amount_cents, currency, gateway_account_id,
1713                gateway_configuration_id, gateway_order_id, review_required_at
1714            ) VALUES (
1715                $1, $2, $3, $4, 'host_charge', 'review_required', $5, $6,
1716                500, 'USD', $7, $8, $9, clock_timestamp()
1717            )
1718            "#,
1719        )
1720        .bind(attempt_id)
1721        .bind(account.billing_scope_id)
1722        .bind(subscriber_id)
1723        .bind(target_id)
1724        .bind(format!("idem-{attempt_id}"))
1725        .bind(format!("fingerprint-{attempt_id}"))
1726        .bind(account.gateway_account_id)
1727        .bind(account.gateway_configuration_id)
1728        .bind(&order_id)
1729        .execute(&database.pool)
1730        .await?;
1731        sqlx::query(
1732            r#"
1733            INSERT INTO billing_processor_charges (
1734                id, attempt_id, billing_scope_id, gateway_account_id,
1735                gateway_order_id, gateway_transaction_id, gateway_response,
1736                gateway_response_code, gateway_response_text, gateway_condition,
1737                charge_role, progression_state, observed_at, attempt_kind,
1738                host_charge_target_id, amount_cents, currency,
1739                external_reversal_required_at
1740            ) VALUES (
1741                $1, $2, $3, $4, $5, 'txn-operator', '1', '100', 'Approved',
1742                'complete', 'primary', 'external_reversal_required',
1743                clock_timestamp(), 'host_charge', $6, 500, 'USD', clock_timestamp()
1744            )
1745            "#,
1746        )
1747        .bind(charge_id)
1748        .bind(attempt_id)
1749        .bind(account.billing_scope_id)
1750        .bind(account.gateway_account_id)
1751        .bind(&order_id)
1752        .bind(target_id)
1753        .execute(&database.pool)
1754        .await?;
1755        sqlx::query(
1756            r#"
1757            INSERT INTO billing_processor_charges (
1758                id, attempt_id, billing_scope_id, gateway_account_id,
1759                gateway_order_id, gateway_transaction_id, gateway_response,
1760                gateway_response_code, gateway_response_text, gateway_condition,
1761                charge_role, progression_state, observed_at, attempt_kind,
1762                host_charge_target_id, amount_cents, currency,
1763                external_reversal_required_at
1764            ) VALUES (
1765                $1, $2, $3, $4, $5, 'txn-operator-additional', '1', '100',
1766                'Approved additional charge', 'complete', 'additional',
1767                'external_reversal_required', clock_timestamp(), 'host_charge',
1768                $6, 500, 'USD', clock_timestamp()
1769            )
1770            "#,
1771        )
1772        .bind(additional_charge_id)
1773        .bind(attempt_id)
1774        .bind(account.billing_scope_id)
1775        .bind(account.gateway_account_id)
1776        .bind(&order_id)
1777        .bind(target_id)
1778        .execute(&database.pool)
1779        .await?;
1780
1781        let page_limit = OperatorReviewPageLimit::new(1)?;
1782        let attempt_page = attempt_review_page(&database.pool, page_limit, None).await?;
1783        assert!(attempt_page.into_items().is_empty());
1784        let first_charge_page =
1785            processor_charge_review_page(&database.pool, page_limit, None).await?;
1786        let next_cursor = first_charge_page.next_cursor().expect("second charge page");
1787        let first_charge_items = first_charge_page.into_items();
1788        assert_eq!(first_charge_items.len(), 1);
1789        let second_charge_page =
1790            processor_charge_review_page(&database.pool, page_limit, Some(next_cursor)).await?;
1791        assert!(second_charge_page.next_cursor().is_none());
1792        let second_charge_items = second_charge_page.into_items();
1793        assert_eq!(second_charge_items.len(), 1);
1794        let returned_charge_ids = [
1795            first_charge_items[0].charge().id(),
1796            second_charge_items[0].charge().id(),
1797        ];
1798        assert!(returned_charge_ids.contains(&ProcessorChargeId::new(charge_id)));
1799        assert!(returned_charge_ids.contains(&ProcessorChargeId::new(additional_charge_id)));
1800        assert!(
1801            first_charge_items
1802                .iter()
1803                .chain(&second_charge_items)
1804                .all(|item| item.attempt().identity().attempt_id()
1805                    == PaymentAttemptId::new(attempt_id))
1806        );
1807
1808        let mut preflight = database.pool.begin().await?;
1809        let locator = charge_locator(&mut preflight, ProcessorChargeId::new(charge_id))
1810            .await?
1811            .expect("charge locator");
1812        lock_payment_attempt_by_id_on_connection(
1813            &mut preflight,
1814            locator.billing_scope_id,
1815            locator.attempt_id,
1816        )
1817        .await
1818        .expect("attempt parser")
1819        .expect("attempt exists");
1820        lock_processor_charge(&mut preflight, ProcessorChargeId::new(charge_id))
1821            .await
1822            .expect("charge parser")
1823            .expect("charge parser");
1824        preflight.rollback().await?;
1825
1826        let host = ExactHostRelease::default();
1827        let actor = ActorId::new(Uuid::now_v7());
1828        let reason = ExternalReversalReason::new("processor refund verified")?;
1829        let mismatch = GatewayTransactionId::new("txn-other")?;
1830        assert_eq!(
1831            attest_external_reversal(
1832                &database.pool,
1833                &host,
1834                ProcessorChargeId::new(charge_id),
1835                actor,
1836                ExternalReversalKind::Refund,
1837                &mismatch,
1838                &reason,
1839            )
1840            .await?,
1841            ExternalReversalAttestationOutcome::Ineligible
1842        );
1843        let transaction_id = GatewayTransactionId::new("txn-operator")?;
1844        let attested = attest_external_reversal(
1845            &database.pool,
1846            &host,
1847            ProcessorChargeId::new(charge_id),
1848            actor,
1849            ExternalReversalKind::Refund,
1850            &transaction_id,
1851            &reason,
1852        )
1853        .await?;
1854        let ExternalReversalAttestationOutcome::Attested {
1855            attempt,
1856            attestation,
1857        } = attested
1858        else {
1859            panic!("expected attestation");
1860        };
1861        assert_eq!(attempt.status(), PaymentAttemptStatus::Failed);
1862        assert_eq!(attestation.actor_id(), actor);
1863        assert_eq!(host.calls.load(Ordering::SeqCst), 1);
1864        assert!(
1865            sqlx::query_scalar::<_, bool>("SELECT released FROM host_targets WHERE id = $1")
1866                .bind(target_id)
1867                .fetch_one(&database.pool)
1868                .await?
1869        );
1870
1871        assert!(matches!(
1872            attest_external_reversal(
1873                &database.pool,
1874                &host,
1875                ProcessorChargeId::new(charge_id),
1876                actor,
1877                ExternalReversalKind::Refund,
1878                &transaction_id,
1879                &reason,
1880            )
1881            .await?,
1882            ExternalReversalAttestationOutcome::Replayed { .. }
1883        ));
1884        assert_eq!(host.calls.load(Ordering::SeqCst), 2);
1885        assert_eq!(
1886            attest_external_reversal(
1887                &database.pool,
1888                &host,
1889                ProcessorChargeId::new(charge_id),
1890                ActorId::new(Uuid::now_v7()),
1891                ExternalReversalKind::Refund,
1892                &transaction_id,
1893                &reason,
1894            )
1895            .await?,
1896            ExternalReversalAttestationOutcome::ReplayConflict
1897        );
1898        let counts: (i64, String) = sqlx::query_as(
1899            "SELECT COUNT(*)::bigint, MIN(progression_state) FROM billing_processor_charges WHERE id = $1",
1900        ).bind(charge_id).fetch_one(&database.pool).await?;
1901        assert_eq!(counts, (1, "externally_reversed".to_owned()));
1902
1903        database.cleanup().await?;
1904        Ok(())
1905    }
1906
1907    #[tokio::test]
1908    async fn grant_conflict_replay_uses_the_persisted_prior_charge_classification()
1909    -> Result<(), Box<dyn Error>> {
1910        let database = TestDatabase::start("rail_op_grant").await?;
1911        let account = create_gateway_account(&database.pool, "nmi").await?;
1912        let attempt_id = Uuid::now_v7();
1913        let charge_id = Uuid::now_v7();
1914        let subscriber_id = Uuid::now_v7();
1915        let order_id = format!("subscription_{}", attempt_id.simple());
1916        sqlx::query(
1917            r#"
1918            INSERT INTO billing_payment_attempts (
1919                id, billing_scope_id, subscriber_id, plan_key,
1920                attempt_kind, status, idempotency_key, request_fingerprint,
1921                amount_cents, currency, gateway_account_id,
1922                gateway_configuration_id, gateway_order_id,
1923                gateway_transaction_id, gateway_response, gateway_response_code,
1924                gateway_response_text, gateway_condition, resolution_code,
1925                submitted_at, review_required_at
1926            ) VALUES (
1927                $1, $2, $3, 'base', 'subscription_initial', 'review_required',
1928                $4, $5, 500, 'USD', $6, $7, $8, 'txn-grant-conflict',
1929                '1', '100', 'Approved', 'complete',
1930                'subscription_initial_current_grant_conflict',
1931                clock_timestamp(), clock_timestamp()
1932            )
1933            "#,
1934        )
1935        .bind(attempt_id)
1936        .bind(account.billing_scope_id)
1937        .bind(subscriber_id)
1938        .bind(format!("idem-{attempt_id}"))
1939        .bind(format!("fingerprint-{attempt_id}"))
1940        .bind(account.gateway_account_id)
1941        .bind(account.gateway_configuration_id)
1942        .bind(&order_id)
1943        .execute(&database.pool)
1944        .await?;
1945        sqlx::query(
1946            r#"
1947            INSERT INTO billing_processor_charges (
1948                id, attempt_id, billing_scope_id, gateway_account_id,
1949                gateway_order_id, gateway_transaction_id, gateway_response,
1950                gateway_response_code, gateway_response_text, gateway_condition,
1951                charge_role, progression_state, state_code, observed_at,
1952                attempt_kind, plan_key, amount_cents, currency,
1953                external_reversal_required_at
1954            ) VALUES (
1955                $1, $2, $3, $4, $5, 'txn-grant-conflict', '1', '100',
1956                'Approved', 'complete', 'primary', 'external_reversal_required',
1957                'processor_charge_external_reversal_required', clock_timestamp(),
1958                'subscription_initial', 'base', 500, 'USD', clock_timestamp()
1959            )
1960            "#,
1961        )
1962        .bind(charge_id)
1963        .bind(attempt_id)
1964        .bind(account.billing_scope_id)
1965        .bind(account.gateway_account_id)
1966        .bind(&order_id)
1967        .execute(&database.pool)
1968        .await?;
1969
1970        let host = ExactHostRelease::default();
1971        let actor = ActorId::new(Uuid::now_v7());
1972        let reason = ExternalReversalReason::new("processor refund verified")?;
1973        let transaction_id = GatewayTransactionId::new("txn-grant-conflict")?;
1974        let attested = attest_external_reversal(
1975            &database.pool,
1976            &host,
1977            ProcessorChargeId::new(charge_id),
1978            actor,
1979            ExternalReversalKind::Refund,
1980            &transaction_id,
1981            &reason,
1982        )
1983        .await?;
1984        assert!(matches!(
1985            attested,
1986            ExternalReversalAttestationOutcome::Attested { .. }
1987        ));
1988        let state_code: String =
1989            sqlx::query_scalar("SELECT state_code FROM billing_processor_charges WHERE id = $1")
1990                .bind(charge_id)
1991                .fetch_one(&database.pool)
1992                .await?;
1993        assert_eq!(
1994            state_code,
1995            PaymentResolutionCode::SubscriptionInitialCurrentGrantConflict.as_str()
1996        );
1997        assert!(matches!(
1998            attest_external_reversal(
1999                &database.pool,
2000                &host,
2001                ProcessorChargeId::new(charge_id),
2002                actor,
2003                ExternalReversalKind::Refund,
2004                &transaction_id,
2005                &reason,
2006            )
2007            .await?,
2008            ExternalReversalAttestationOutcome::Replayed { .. }
2009        ));
2010
2011        database.cleanup().await?;
2012        Ok(())
2013    }
2014}