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(¤t)
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(¤t);
379 let updated = update_attempt_for_manual_failure(connection, ¤t, &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, ¤t).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}