1use chrono::{DateTime, Utc};
2use sqlx::{PgPool, Row};
3use syrup_rail::{
4 BillingScopeId, GatewayAccountId, GatewayAccountReconciliationCandidate, PaymentAttempt,
5 PaymentAttemptKind, PaymentAttemptStatus, PaymentResolutionCode, PlanKey, SubscriberId,
6};
7use uuid::Uuid;
8
9use crate::PaymentAttemptStoreError;
10use crate::attempts::{
11 expire_stale_initial_attempts, lock_initial_attempt_rows, lock_initial_charge_rows,
12 lock_payment_attempt_by_id_on_connection, payment_attempt_from_row, set_enrollment_timeouts,
13 try_lock_subscription_aggregate,
14};
15
16const PAYMENT_METHOD_REPLACEMENT_STALE_AFTER_SECONDS: i64 = 3 * 60;
17const RECONCILIATION_PHASE_BATCH_SIZE: i64 = 100;
18const STALE_PAYMENT_METHOD_REPLACEMENT_RESPONSE_TEXT: &str =
19 "Payment method update was abandoned before gateway submission.";
20const PROCESSOR_CHARGE_CANDIDATE_PAGE_SIZE: i64 = 128;
21const EXACT_REQUERY_AFTER_SECONDS: i64 = 60;
22const EXACT_STALE_AFTER_SECONDS: i64 = 30 * 60;
23const EXACT_RECENT_TERMINAL_SECONDS: i64 = 24 * 60 * 60;
24const EXACT_EMPTY_REVIEW_KEEPALIVE_TEXT: &str =
25 "Payment processor still has not returned a transaction during manual review.";
26const EXACT_EMPTY_STALE_PAYMENT_METHOD_TEXT: &str = "Payment method update was submitted locally but no processor transaction appeared before the reconciliation deadline.";
27const EXACT_EMPTY_STALE_REVIEW_TEXT: &str =
28 "Payment processor did not return a transaction before the reconciliation deadline.";
29const EXACT_EMPTY_UNKNOWN_TEXT: &str = "Payment processor has not returned a transaction yet.";
30const EXACT_MALFORMED_STALE_REVIEW_TEXT: &str = "Payment processor returned a malformed exact-query response after the reconciliation deadline.";
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum ExactQueryObservation {
34 NoTransaction,
35 MalformedResponse,
36}
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub struct ProcessorChargeClassificationSummary {
40 transitioned: u64,
41 skipped_locked: u64,
42 remaining_pending: u64,
43}
44
45impl ProcessorChargeClassificationSummary {
46 pub const fn transitioned(self) -> u64 {
47 self.transitioned
48 }
49
50 pub const fn skipped_locked(self) -> u64 {
51 self.skipped_locked
52 }
53
54 pub const fn remaining_pending(self) -> u64 {
55 self.remaining_pending
56 }
57}
58
59#[derive(Clone, Debug)]
60struct PendingChargeCandidate {
61 id: Uuid,
62 attempt_id: Uuid,
63 transaction_id: Option<String>,
64 observed_at: DateTime<Utc>,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
68struct AttemptLocator {
69 id: Uuid,
70 billing_scope_id: BillingScopeId,
71 subscriber_id: SubscriberId,
72 plan_key: Option<PlanKey>,
73 gateway_account_id: GatewayAccountId,
74 kind: PaymentAttemptKind,
75}
76
77#[derive(Clone, Debug)]
78struct LockedAttempt {
79 locator: AttemptLocator,
80 status: PaymentAttemptStatus,
81 resolution_code: Option<PaymentResolutionCode>,
82 amount_cents: i32,
83 transaction_id: Option<String>,
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87enum ChargeRole {
88 Primary,
89 Additional,
90}
91
92#[derive(Clone, Copy, Debug, Eq, PartialEq)]
93enum ChargeProgression {
94 ReconciliationRequired,
95 ExternalReversalRequired,
96 Applied,
97 ExternallyReversed,
98}
99
100impl ChargeProgression {
101 const fn as_str(self) -> &'static str {
102 match self {
103 Self::ReconciliationRequired => "reconciliation_required",
104 Self::ExternalReversalRequired => "external_reversal_required",
105 Self::Applied => "applied",
106 Self::ExternallyReversed => "externally_reversed",
107 }
108 }
109}
110
111pub async fn reconciliation_gateway_accounts(
117 pool: &PgPool,
118) -> Result<Vec<GatewayAccountReconciliationCandidate>, sqlx::Error> {
119 let rows = sqlx::query_as::<_, (Uuid, Uuid)>(
120 r#"
121 SELECT billing_scope_id, id
122 FROM billing_gateway_accounts
123 ORDER BY billing_scope_id, id
124 "#,
125 )
126 .fetch_all(pool)
127 .await?;
128
129 Ok(rows
130 .into_iter()
131 .map(|(billing_scope_id, gateway_account_id)| {
132 GatewayAccountReconciliationCandidate::new(
133 BillingScopeId::new(billing_scope_id),
134 GatewayAccountId::new(gateway_account_id),
135 )
136 })
137 .collect())
138}
139
140pub async fn claim_exact_reconciliation_attempts(
146 pool: &PgPool,
147 gateway_account_id: GatewayAccountId,
148) -> Result<Vec<PaymentAttempt>, PaymentAttemptStoreError> {
149 let mut transaction = pool.begin().await?;
150 sqlx::query("SELECT set_config('lock_timeout', '250ms', true)")
151 .execute(&mut *transaction)
152 .await?;
153 let rows = sqlx::query(
154 r#"
155 WITH candidate_attempts AS MATERIALIZED (
156 SELECT attempts.id AS attempt_id, attempts.created_at
157 FROM billing_payment_attempts AS attempts
158 WHERE attempts.gateway_account_id = $1
159 AND (
160 (
161 attempts.status IN ('unknown', 'review_required')
162 AND attempts.updated_at <= clock_timestamp()
163 - ($2::bigint * interval '1 second')
164 )
165 OR (
166 attempts.status = 'pending'
167 AND COALESCE(attempts.submitted_at, attempts.created_at)
168 <= clock_timestamp() - ($3::bigint * interval '1 second')
169 AND attempts.updated_at <= clock_timestamp()
170 - ($2::bigint * interval '1 second')
171 )
172 OR (
173 attempts.status IN ('declined', 'failed')
174 AND public.billing_canonical_gateway_transaction_id(
175 attempts.gateway_transaction_id
176 ) IS NOT NULL
177 AND attempts.resolved_at IS NOT NULL
178 AND attempts.resolved_at >= clock_timestamp()
179 - ($4::bigint * interval '1 second')
180 AND attempts.updated_at <= clock_timestamp()
181 - ($2::bigint * interval '1 second')
182 )
183 )
184 AND NOT (
185 attempts.attempt_kind = 'subscription_initial'
186 AND attempts.status = 'review_required'
187 AND (
188 attempts.resolution_code IS NOT DISTINCT FROM
189 'subscription_initial_current_subscription_conflict'
190 OR attempts.resolution_code IS NOT DISTINCT FROM
191 'subscription_initial_current_grant_conflict'
192 )
193 )
194 AND NOT (
195 attempts.attempt_kind = 'subscription_payment_method_update'
196 AND attempts.submitted_at IS NULL
197 )
198 AND attempts.resolution_code IS DISTINCT FROM
199 'subscription_initial_externally_refunded'
200 AND attempts.resolution_code IS DISTINCT FROM
201 'subscription_initial_externally_voided'
202 ORDER BY attempts.created_at, attempts.id
203 LIMIT $5
204 FOR UPDATE OF attempts SKIP LOCKED
205 ), updated_attempts AS (
206 UPDATE billing_payment_attempts AS attempts
207 SET updated_at = clock_timestamp()
208 FROM candidate_attempts
209 WHERE attempts.id = candidate_attempts.attempt_id
210 RETURNING attempts.*
211 )
212 SELECT updated_attempts.*
213 FROM updated_attempts
214 INNER JOIN candidate_attempts
215 ON candidate_attempts.attempt_id = updated_attempts.id
216 ORDER BY candidate_attempts.created_at, candidate_attempts.attempt_id
217 "#,
218 )
219 .bind(gateway_account_id.as_uuid())
220 .bind(EXACT_REQUERY_AFTER_SECONDS)
221 .bind(EXACT_STALE_AFTER_SECONDS)
222 .bind(EXACT_RECENT_TERMINAL_SECONDS)
223 .bind(RECONCILIATION_PHASE_BATCH_SIZE)
224 .fetch_all(&mut *transaction)
225 .await?;
226 let attempts = rows
227 .iter()
228 .map(payment_attempt_from_row)
229 .collect::<Result<Vec<_>, _>>()?;
230 transaction.commit().await?;
231 Ok(attempts)
232}
233
234pub async fn apply_exact_query_observation(
240 pool: &PgPool,
241 claimed: &PaymentAttempt,
242 observation: ExactQueryObservation,
243) -> Result<bool, PaymentAttemptStoreError> {
244 let mut transaction = pool.begin().await?;
245 sqlx::query("SELECT set_config('lock_timeout', '250ms', true)")
246 .execute(&mut *transaction)
247 .await?;
248 let current = lock_payment_attempt_by_id_on_connection(
249 &mut transaction,
250 claimed.identity().billing_scope_id(),
251 claimed.identity().attempt_id(),
252 )
253 .await?
254 .ok_or(PaymentAttemptStoreError::InvalidState(
255 "claimed exact-query attempt was not found",
256 ))?;
257 if current.identity() != claimed.identity() || current.request() != claimed.request() {
258 return Err(PaymentAttemptStoreError::InvalidState(
259 "claimed exact-query attempt identity changed",
260 ));
261 }
262 let now: DateTime<Utc> = sqlx::query_scalar("SELECT clock_timestamp()")
263 .fetch_one(&mut *transaction)
264 .await?;
265 let stale = current.state().timestamps().submitted_or_created_at()
266 <= now - chrono::Duration::seconds(EXACT_STALE_AFTER_SECONDS);
267 let status = current.status();
268 let evidence = current.state().processor_evidence();
269
270 let (next_status, message, transitioned) = match observation {
271 ExactQueryObservation::NoTransaction
272 if status == PaymentAttemptStatus::ReviewRequired
273 && evidence.has_gateway_reference() =>
274 {
275 (status, EXACT_EMPTY_REVIEW_KEEPALIVE_TEXT, false)
276 }
277 ExactQueryObservation::NoTransaction
278 if stale
279 && current.kind() == PaymentAttemptKind::SubscriptionPaymentMethodUpdate
280 && matches!(
281 status,
282 PaymentAttemptStatus::Pending | PaymentAttemptStatus::ReviewRequired
283 )
284 && current.state().timestamps().submitted_at().is_some()
285 && evidence.transaction_id().is_none()
286 && evidence.condition().is_none() =>
287 {
288 (
289 PaymentAttemptStatus::Failed,
290 EXACT_EMPTY_STALE_PAYMENT_METHOD_TEXT,
291 true,
292 )
293 }
294 ExactQueryObservation::NoTransaction if stale => (
295 PaymentAttemptStatus::ReviewRequired,
296 EXACT_EMPTY_STALE_REVIEW_TEXT,
297 status != PaymentAttemptStatus::ReviewRequired,
298 ),
299 ExactQueryObservation::NoTransaction if status == PaymentAttemptStatus::Unknown => {
300 (status, EXACT_EMPTY_UNKNOWN_TEXT, false)
301 }
302 ExactQueryObservation::MalformedResponse if stale => (
303 PaymentAttemptStatus::ReviewRequired,
304 EXACT_MALFORMED_STALE_REVIEW_TEXT,
305 status != PaymentAttemptStatus::ReviewRequired,
306 ),
307 _ => {
308 transaction.commit().await?;
309 return Ok(false);
310 }
311 };
312
313 let result = if next_status == PaymentAttemptStatus::Failed {
314 sqlx::query(
315 r#"
316 UPDATE billing_payment_attempts
317 SET status = 'failed', gateway_response_text = $2,
318 gateway_condition = COALESCE(gateway_condition, 'failed'),
319 resolved_at = clock_timestamp(), updated_at = clock_timestamp()
320 WHERE id = $1 AND status IN ('pending', 'review_required')
321 "#,
322 )
323 .bind(current.identity().attempt_id().as_uuid())
324 .bind(message)
325 .execute(&mut *transaction)
326 .await?
327 } else if next_status == PaymentAttemptStatus::ReviewRequired {
328 sqlx::query(
329 r#"
330 UPDATE billing_payment_attempts
331 SET status = 'review_required',
332 gateway_response_text = CASE
333 WHEN status = 'review_required'
334 AND NULLIF(BTRIM(gateway_response_text), '') IS NOT NULL
335 THEN gateway_response_text ELSE $2
336 END,
337 updated_at = clock_timestamp()
338 WHERE id = $1 AND status IN ('pending', 'unknown', 'review_required')
339 "#,
340 )
341 .bind(current.identity().attempt_id().as_uuid())
342 .bind(message)
343 .execute(&mut *transaction)
344 .await?
345 } else {
346 sqlx::query(
347 r#"
348 UPDATE billing_payment_attempts
349 SET gateway_response_text = $2, updated_at = clock_timestamp()
350 WHERE id = $1 AND status = $3
351 "#,
352 )
353 .bind(current.identity().attempt_id().as_uuid())
354 .bind(message)
355 .bind(status.as_str())
356 .execute(&mut *transaction)
357 .await?
358 };
359 transaction.commit().await?;
360 Ok(transitioned && result.rows_affected() == 1)
361}
362
363pub async fn fail_stale_unsubmitted_payment_method_replacements(
368 pool: &PgPool,
369 gateway_account_id: GatewayAccountId,
370) -> Result<u64, sqlx::Error> {
371 let mut transaction = pool.begin().await?;
372 sqlx::query("SELECT set_config('lock_timeout', '250ms', true)")
373 .execute(&mut *transaction)
374 .await?;
375 let result = sqlx::query(
376 r#"
377 WITH stale_attempts AS (
378 SELECT id
379 FROM billing_payment_attempts
380 WHERE attempt_kind = 'subscription_payment_method_update'
381 AND status = 'pending'
382 AND submitted_at IS NULL
383 AND created_at <= clock_timestamp()
384 - ($1::bigint * interval '1 second')
385 AND gateway_account_id = $2
386 ORDER BY created_at, id
387 LIMIT $3
388 FOR UPDATE SKIP LOCKED
389 )
390 UPDATE billing_payment_attempts AS attempts
391 SET status = 'failed',
392 gateway_response_text = COALESCE(gateway_response_text, $4),
393 gateway_condition = COALESCE(gateway_condition, 'failed'),
394 resolved_at = clock_timestamp(),
395 updated_at = clock_timestamp()
396 FROM stale_attempts
397 WHERE attempts.id = stale_attempts.id
398 "#,
399 )
400 .bind(PAYMENT_METHOD_REPLACEMENT_STALE_AFTER_SECONDS)
401 .bind(gateway_account_id.as_uuid())
402 .bind(RECONCILIATION_PHASE_BATCH_SIZE)
403 .bind(STALE_PAYMENT_METHOD_REPLACEMENT_RESPONSE_TEXT)
404 .execute(&mut *transaction)
405 .await?;
406 transaction.commit().await?;
407 Ok(result.rows_affected())
408}
409
410pub async fn fail_stale_unsubmitted_subscription_enrollments(
416 pool: &PgPool,
417 gateway_account_id: GatewayAccountId,
418) -> Result<u64, sqlx::Error> {
419 let candidates = sqlx::query_as::<_, (Uuid, Uuid, String)>(
420 r#"
421 SELECT DISTINCT billing_scope_id, subscriber_id, plan_key
422 FROM billing_payment_attempts
423 WHERE gateway_account_id = $1
424 AND attempt_kind = 'subscription_initial'
425 AND status = 'pending'
426 AND submitted_at IS NULL
427 AND created_at <= clock_timestamp() - interval '30 minutes'
428 ORDER BY billing_scope_id, subscriber_id, plan_key
429 "#,
430 )
431 .bind(gateway_account_id.as_uuid())
432 .fetch_all(pool)
433 .await?;
434
435 let mut failed = 0;
436 for (billing_scope_id, subscriber_id, plan_key) in candidates {
437 let plan_key = PlanKey::new(plan_key)
438 .map_err(|_| sqlx::Error::Protocol("stored plan key is invalid".to_owned()))?;
439 let billing_scope_id = BillingScopeId::new(billing_scope_id);
440 let subscriber_id = SubscriberId::new(subscriber_id);
441 let mut transaction = pool.begin().await?;
442 set_enrollment_timeouts(&mut transaction).await?;
443 if !try_lock_subscription_aggregate(&mut transaction, subscriber_id, &plan_key).await? {
444 transaction.rollback().await?;
445 continue;
446 }
447 lock_initial_attempt_rows(&mut transaction, billing_scope_id, subscriber_id, &plan_key)
448 .await?;
449 lock_initial_charge_rows(&mut transaction, billing_scope_id, subscriber_id, &plan_key)
450 .await?;
451 failed += expire_stale_initial_attempts(
452 &mut transaction,
453 billing_scope_id,
454 subscriber_id,
455 &plan_key,
456 )
457 .await?;
458 transaction.commit().await?;
459 }
460 Ok(failed)
461}
462
463pub async fn classify_pending_processor_charges(
470 pool: &PgPool,
471 gateway_account_id: GatewayAccountId,
472 max_transitions: u64,
473) -> Result<ProcessorChargeClassificationSummary, sqlx::Error> {
474 let transition_limit = max_transitions.min(RECONCILIATION_PHASE_BATCH_SIZE as u64);
475 if transition_limit == 0 {
476 return Ok(ProcessorChargeClassificationSummary {
477 transitioned: 0,
478 skipped_locked: 0,
479 remaining_pending: count_pending_processor_charges(pool, gateway_account_id).await?,
480 });
481 }
482
483 let upper_bound = sqlx::query_as::<_, (DateTime<Utc>, Uuid)>(
484 r#"
485 SELECT observed_at, id
486 FROM billing_processor_charges
487 WHERE gateway_account_id = $1 AND progression_state = 'pending'
488 ORDER BY observed_at DESC, id DESC
489 LIMIT 1
490 "#,
491 )
492 .bind(gateway_account_id.as_uuid())
493 .fetch_optional(pool)
494 .await?;
495 let Some((upper_observed_at, upper_id)) = upper_bound else {
496 return Ok(ProcessorChargeClassificationSummary {
497 transitioned: 0,
498 skipped_locked: 0,
499 remaining_pending: 0,
500 });
501 };
502
503 let mut transitioned = 0;
504 let mut skipped_locked = 0;
505 let mut cursor: Option<(DateTime<Utc>, Uuid)> = None;
506 while transitioned < transition_limit {
507 let rows = sqlx::query(
508 r#"
509 SELECT charges.id, charges.attempt_id,
510 billing_canonical_gateway_transaction_id(
511 charges.gateway_transaction_id
512 ) AS transaction_id,
513 charges.observed_at
514 FROM billing_processor_charges charges
515 INNER JOIN billing_payment_attempts attempts
516 ON attempts.id = charges.attempt_id
517 AND attempts.gateway_account_id = $1
518 WHERE charges.gateway_account_id = $1
519 AND charges.progression_state = 'pending'
520 AND (
521 $2::timestamptz IS NULL
522 OR (charges.observed_at, charges.id)
523 > ($2::timestamptz, $3::uuid)
524 )
525 AND (charges.observed_at, charges.id) <= ($4, $5)
526 ORDER BY charges.observed_at, charges.id
527 LIMIT $6
528 "#,
529 )
530 .bind(gateway_account_id.as_uuid())
531 .bind(cursor.as_ref().map(|(observed_at, _)| observed_at))
532 .bind(cursor.as_ref().map(|(_, id)| id))
533 .bind(upper_observed_at)
534 .bind(upper_id)
535 .bind(PROCESSOR_CHARGE_CANDIDATE_PAGE_SIZE)
536 .fetch_all(pool)
537 .await?;
538 let candidates = rows
539 .into_iter()
540 .map(|row| {
541 Ok(PendingChargeCandidate {
542 id: row.try_get("id")?,
543 attempt_id: row.try_get("attempt_id")?,
544 transaction_id: row.try_get("transaction_id")?,
545 observed_at: row.try_get("observed_at")?,
546 })
547 })
548 .collect::<Result<Vec<_>, sqlx::Error>>()?;
549 let Some(last) = candidates.last() else {
550 break;
551 };
552 cursor = Some((last.observed_at, last.id));
553
554 for candidate in candidates {
555 if transitioned >= transition_limit {
556 break;
557 }
558 let mut transaction = pool.begin().await?;
559 set_enrollment_timeouts(&mut transaction).await?;
560 let Some(locator) = attempt_locator(&mut transaction, candidate.attempt_id).await?
561 else {
562 transaction.rollback().await?;
563 continue;
564 };
565 if locator.gateway_account_id != gateway_account_id {
566 return Err(invalid_reconciliation_state());
567 }
568 if let Some(plan_key) = locator.plan_key.as_ref()
569 && !try_lock_subscription_aggregate(
570 &mut transaction,
571 locator.subscriber_id,
572 plan_key,
573 )
574 .await?
575 {
576 skipped_locked += 1;
577 transaction.rollback().await?;
578 continue;
579 }
580 let Some(attempt) = lock_attempt_for_classification(&mut transaction, locator).await?
581 else {
582 skipped_locked += 1;
583 transaction.rollback().await?;
584 continue;
585 };
586 let Some((role, charge_transaction_id, same_charge, dimensions_match)) =
587 lock_pending_charge_for_classification(
588 &mut transaction,
589 candidate.id,
590 candidate.attempt_id,
591 )
592 .await?
593 else {
594 skipped_locked += 1;
595 transaction.rollback().await?;
596 continue;
597 };
598 if !dimensions_match || charge_transaction_id != candidate.transaction_id {
599 return Err(invalid_reconciliation_state());
600 }
601
602 let (progression, state_code) = classify_pending_charge(
603 &mut transaction,
604 &attempt,
605 candidate.id,
606 role,
607 charge_transaction_id.as_deref(),
608 same_charge,
609 )
610 .await?;
611 transition_pending_charge(
612 &mut transaction,
613 candidate.id,
614 progression,
615 state_code.as_deref(),
616 )
617 .await?;
618 transaction.commit().await?;
619 transitioned += 1;
620 }
621 }
622
623 Ok(ProcessorChargeClassificationSummary {
624 transitioned,
625 skipped_locked,
626 remaining_pending: count_pending_processor_charges(pool, gateway_account_id).await?,
627 })
628}
629
630async fn count_pending_processor_charges(
631 pool: &PgPool,
632 gateway_account_id: GatewayAccountId,
633) -> Result<u64, sqlx::Error> {
634 let count: i64 = sqlx::query_scalar(
635 "SELECT count(*)::bigint FROM billing_processor_charges WHERE gateway_account_id = $1 AND progression_state = 'pending'",
636 )
637 .bind(gateway_account_id.as_uuid())
638 .fetch_one(pool)
639 .await?;
640 u64::try_from(count).map_err(|_| invalid_reconciliation_state())
641}
642
643async fn attempt_locator(
644 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
645 attempt_id: Uuid,
646) -> Result<Option<AttemptLocator>, sqlx::Error> {
647 let row = sqlx::query(
648 r#"
649 SELECT id, billing_scope_id, subscriber_id, plan_key,
650 gateway_account_id, attempt_kind
651 FROM billing_payment_attempts
652 WHERE id = $1
653 "#,
654 )
655 .bind(attempt_id)
656 .fetch_optional(&mut **transaction)
657 .await?;
658 row.as_ref().map(attempt_locator_from_row).transpose()
659}
660
661fn attempt_locator_from_row(row: &sqlx::postgres::PgRow) -> Result<AttemptLocator, sqlx::Error> {
662 let kind = row
663 .try_get::<String, _>("attempt_kind")?
664 .parse::<PaymentAttemptKind>()
665 .map_err(|_| invalid_reconciliation_state())?;
666 let plan_key = row
667 .try_get::<Option<String>, _>("plan_key")?
668 .map(PlanKey::new)
669 .transpose()
670 .map_err(|_| invalid_reconciliation_state())?;
671 if (kind == PaymentAttemptKind::HostCharge) != plan_key.is_none() {
672 return Err(invalid_reconciliation_state());
673 }
674 Ok(AttemptLocator {
675 id: row.try_get("id")?,
676 billing_scope_id: BillingScopeId::new(row.try_get("billing_scope_id")?),
677 subscriber_id: SubscriberId::new(row.try_get("subscriber_id")?),
678 plan_key,
679 gateway_account_id: GatewayAccountId::new(row.try_get("gateway_account_id")?),
680 kind,
681 })
682}
683
684async fn lock_attempt_for_classification(
685 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
686 locator: AttemptLocator,
687) -> Result<Option<LockedAttempt>, sqlx::Error> {
688 let row = sqlx::query(
689 r#"
690 SELECT id, billing_scope_id, subscriber_id, plan_key,
691 gateway_account_id, attempt_kind, status, resolution_code,
692 amount_cents,
693 billing_canonical_gateway_transaction_id(
694 gateway_transaction_id
695 ) AS transaction_id
696 FROM billing_payment_attempts
697 WHERE id = $1
698 FOR UPDATE SKIP LOCKED
699 "#,
700 )
701 .bind(locator.id)
702 .fetch_optional(&mut **transaction)
703 .await?;
704 let Some(row) = row else {
705 return Ok(None);
706 };
707 let locked_locator = attempt_locator_from_row(&row)?;
708 if locked_locator != locator {
709 return Err(invalid_reconciliation_state());
710 }
711 let status = row
712 .try_get::<String, _>("status")?
713 .parse::<PaymentAttemptStatus>()
714 .map_err(|_| invalid_reconciliation_state())?;
715 let resolution_code = row
716 .try_get::<Option<String>, _>("resolution_code")?
717 .as_deref()
718 .map(PaymentResolutionCode::try_from)
719 .transpose()
720 .map_err(|_| invalid_reconciliation_state())?;
721 Ok(Some(LockedAttempt {
722 locator,
723 status,
724 resolution_code,
725 amount_cents: row.try_get("amount_cents")?,
726 transaction_id: row.try_get("transaction_id")?,
727 }))
728}
729
730async fn lock_pending_charge_for_classification(
731 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
732 charge_id: Uuid,
733 attempt_id: Uuid,
734) -> Result<Option<(ChargeRole, Option<String>, bool, bool)>, sqlx::Error> {
735 let row = sqlx::query(
736 r#"
737 SELECT charges.charge_role,
738 billing_canonical_gateway_transaction_id(
739 charges.gateway_transaction_id
740 ) AS transaction_id,
741 CASE
742 WHEN billing_canonical_gateway_transaction_id(
743 attempts.gateway_transaction_id
744 ) IS NOT NULL
745 AND billing_canonical_gateway_transaction_id(
746 charges.gateway_transaction_id
747 ) IS NOT NULL
748 THEN billing_canonical_gateway_transaction_id(
749 attempts.gateway_transaction_id
750 ) = billing_canonical_gateway_transaction_id(
751 charges.gateway_transaction_id
752 )
753 WHEN billing_canonical_gateway_transaction_id(
754 attempts.gateway_transaction_id
755 ) IS NULL
756 AND billing_canonical_gateway_transaction_id(
757 charges.gateway_transaction_id
758 ) IS NULL
759 THEN attempts.gateway_order_id = charges.gateway_order_id
760 AND attempts.gateway_payment_method_reference
761 IS NOT DISTINCT FROM charges.gateway_payment_method_reference
762 AND attempts.gateway_response
763 IS NOT DISTINCT FROM charges.gateway_response
764 AND attempts.gateway_response_code
765 IS NOT DISTINCT FROM charges.gateway_response_code
766 AND attempts.gateway_response_text
767 IS NOT DISTINCT FROM charges.gateway_response_text
768 AND attempts.gateway_condition
769 IS NOT DISTINCT FROM charges.gateway_condition
770 AND attempts.payment_type IS NOT DISTINCT FROM charges.payment_type
771 AND attempts.card_brand IS NOT DISTINCT FROM charges.card_brand
772 AND attempts.card_last4 IS NOT DISTINCT FROM charges.card_last4
773 AND attempts.card_exp_month
774 IS NOT DISTINCT FROM charges.card_exp_month
775 AND attempts.card_exp_year
776 IS NOT DISTINCT FROM charges.card_exp_year
777 ELSE false
778 END AS same_charge,
779 charges.attempt_id = attempts.id
780 AND charges.billing_scope_id = attempts.billing_scope_id
781 AND charges.gateway_account_id = attempts.gateway_account_id
782 AND charges.gateway_order_id = attempts.gateway_order_id
783 AND charges.attempt_kind = attempts.attempt_kind
784 AND charges.plan_key IS NOT DISTINCT FROM attempts.plan_key
785 AND charges.host_charge_target_id
786 IS NOT DISTINCT FROM attempts.host_charge_target_id
787 AND charges.amount_cents = attempts.amount_cents
788 AND charges.currency = attempts.currency AS dimensions_match
789 FROM billing_processor_charges charges
790 INNER JOIN billing_payment_attempts attempts
791 ON attempts.id = charges.attempt_id
792 WHERE charges.id = $1 AND attempts.id = $2
793 AND charges.progression_state = 'pending'
794 FOR UPDATE OF charges SKIP LOCKED
795 "#,
796 )
797 .bind(charge_id)
798 .bind(attempt_id)
799 .fetch_optional(&mut **transaction)
800 .await?;
801 row.map(|row| {
802 let role = match row.try_get::<String, _>("charge_role")?.as_str() {
803 "primary" => ChargeRole::Primary,
804 "additional" => ChargeRole::Additional,
805 _ => return Err(invalid_reconciliation_state()),
806 };
807 Ok((
808 role,
809 row.try_get("transaction_id")?,
810 row.try_get("same_charge")?,
811 row.try_get("dimensions_match")?,
812 ))
813 })
814 .transpose()
815}
816
817async fn classify_pending_charge(
818 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
819 attempt: &LockedAttempt,
820 charge_id: Uuid,
821 role: ChargeRole,
822 transaction_id: Option<&str>,
823 same_charge: bool,
824) -> Result<(ChargeProgression, Option<String>), sqlx::Error> {
825 let Some(transaction_id) = transaction_id else {
826 return Ok((
827 ChargeProgression::ReconciliationRequired,
828 Some("processor_charge_transaction_identity_required".to_owned()),
829 ));
830 };
831 let attestation = sqlx::query_as::<_, (Uuid, String)>(
832 r#"
833 SELECT processor_charge_id, final_resolution_code
834 FROM billing_external_reversal_attestations
835 WHERE attempt_id = $1 AND gateway_transaction_id = $2
836 FOR UPDATE
837 "#,
838 )
839 .bind(attempt.locator.id)
840 .bind(transaction_id)
841 .fetch_optional(&mut **transaction)
842 .await?;
843 if let Some((attested_charge_id, final_resolution_code)) = attestation {
844 if attested_charge_id != charge_id {
845 return Err(invalid_reconciliation_state());
846 }
847 let final_resolution_code = PaymentResolutionCode::try_from(final_resolution_code.as_str())
848 .map_err(|_| invalid_reconciliation_state())?;
849 return Ok((
850 ChargeProgression::ExternallyReversed,
851 Some(final_resolution_code.as_str().to_owned()),
852 ));
853 }
854
855 let terminal_external_reversal = attempt.status == PaymentAttemptStatus::Failed
856 && matches!(
857 attempt.resolution_code,
858 Some(
859 PaymentResolutionCode::SubscriptionInitialExternallyRefunded
860 | PaymentResolutionCode::SubscriptionInitialExternallyVoided
861 | PaymentResolutionCode::ProcessorChargeExternallyRefunded
862 | PaymentResolutionCode::ProcessorChargeExternallyVoided
863 )
864 );
865 let initial_grant_conflict = attempt.locator.kind == PaymentAttemptKind::SubscriptionInitial
866 && attempt.resolution_code
867 == Some(PaymentResolutionCode::SubscriptionInitialCurrentGrantConflict);
868 if attempt.amount_cents > 0
869 && (role == ChargeRole::Additional
870 || terminal_external_reversal
871 || initial_grant_conflict
872 || (attempt.transaction_id.is_some() && !same_charge))
873 {
874 return Ok((
875 ChargeProgression::ExternalReversalRequired,
876 Some(
877 if role == ChargeRole::Additional {
878 "additional_approved_charge_identified"
879 } else {
880 "processor_charge_external_reversal_required"
881 }
882 .to_owned(),
883 ),
884 ));
885 }
886 if same_charge && attempt.status == PaymentAttemptStatus::Approved {
887 return Ok((ChargeProgression::Applied, None));
888 }
889 Ok((
890 ChargeProgression::ReconciliationRequired,
891 Some(
892 if role == ChargeRole::Additional {
893 "zero_amount_additional_approved_charge"
894 } else {
895 "approved_charge_waiting_for_application"
896 }
897 .to_owned(),
898 ),
899 ))
900}
901
902async fn transition_pending_charge(
903 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
904 charge_id: Uuid,
905 progression: ChargeProgression,
906 state_code: Option<&str>,
907) -> Result<(), sqlx::Error> {
908 let result = sqlx::query(
909 r#"
910 UPDATE billing_processor_charges
911 SET progression_state = $2,
912 state_code = $3,
913 reconciliation_required_at = CASE
914 WHEN $2 = 'reconciliation_required'
915 THEN COALESCE(reconciliation_required_at, clock_timestamp())
916 END,
917 external_reversal_required_at = CASE
918 WHEN $2 = 'external_reversal_required'
919 THEN COALESCE(external_reversal_required_at, clock_timestamp())
920 END,
921 applied_at = CASE WHEN $2 = 'applied'
922 THEN COALESCE(applied_at, clock_timestamp()) END,
923 externally_reversed_at = CASE WHEN $2 = 'externally_reversed'
924 THEN COALESCE(externally_reversed_at, clock_timestamp()) END,
925 updated_at = clock_timestamp()
926 WHERE id = $1 AND progression_state = 'pending'
927 AND (
928 $2 NOT IN ('external_reversal_required', 'externally_reversed')
929 OR (
930 billing_canonical_gateway_transaction_id(
931 gateway_transaction_id
932 ) IS NOT NULL
933 AND amount_cents > 0
934 AND attempt_kind <> 'subscription_payment_method_update'
935 )
936 )
937 AND (
938 $2 <> 'applied'
939 OR (
940 charge_role = 'primary'
941 AND billing_canonical_gateway_transaction_id(
942 gateway_transaction_id
943 ) IS NOT NULL
944 )
945 )
946 "#,
947 )
948 .bind(charge_id)
949 .bind(progression.as_str())
950 .bind(state_code)
951 .execute(&mut **transaction)
952 .await?;
953 if result.rows_affected() != 1 {
954 return Err(invalid_reconciliation_state());
955 }
956 Ok(())
957}
958
959fn invalid_reconciliation_state() -> sqlx::Error {
960 sqlx::Error::Protocol("canonical reconciliation state is invalid".to_owned())
961}
962
963#[cfg(test)]
964mod tests {
965 use std::error::Error;
966
967 use syrup_rail::{
968 BillingScopeId, GatewayAccountId, GatewayAccountRegistration, GatewayConfigurationId,
969 GatewayProviderKey, PaymentAttemptKind,
970 };
971 use uuid::Uuid;
972
973 use super::{
974 ExactQueryObservation, RECONCILIATION_PHASE_BATCH_SIZE, apply_exact_query_observation,
975 claim_exact_reconciliation_attempts, classify_pending_processor_charges,
976 fail_stale_unsubmitted_payment_method_replacements,
977 fail_stale_unsubmitted_subscription_enrollments, reconciliation_gateway_accounts,
978 };
979 use crate::{
980 register_gateway_account,
981 test_support::{TestDatabase, create_gateway_account},
982 };
983
984 #[tokio::test]
985 async fn reconciliation_candidate_scan_is_complete_unbounded_and_deterministic()
986 -> Result<(), Box<dyn Error>> {
987 let database = TestDatabase::start("sr_recon_scan").await?;
988 let result = async {
989 let provider = GatewayProviderKey::new("test_gateway")?;
990 let mut expected = Vec::new();
991 let mut transaction = database.pool.begin().await?;
992 for position in (0_u128..101).rev() {
993 let scope = BillingScopeId::new(Uuid::from_u128(1 + position));
994 let account = GatewayAccountId::new(Uuid::from_u128(2_000 - position));
995 register_gateway_account(
996 &mut transaction,
997 &GatewayAccountRegistration::new(
998 scope,
999 account,
1000 provider.clone(),
1001 GatewayConfigurationId::new(Uuid::from_u128(2_000 + position)),
1002 ),
1003 )
1004 .await?;
1005 expected.push((scope, account));
1006 }
1007 transaction.commit().await?;
1008 expected.sort_unstable();
1009
1010 let candidates = reconciliation_gateway_accounts(&database.pool).await?;
1011 let actual: Vec<_> = candidates
1012 .into_iter()
1013 .map(|candidate| (candidate.billing_scope_id(), candidate.gateway_account_id()))
1014 .collect();
1015
1016 assert_eq!(actual.len(), 101);
1017 assert_eq!(actual, expected);
1018 Ok::<_, Box<dyn Error>>(())
1019 }
1020 .await;
1021 let cleanup = database.cleanup().await;
1022 result?;
1023 cleanup
1024 }
1025
1026 #[tokio::test]
1027 async fn exact_attempt_claim_is_canonical_bounded_and_account_scoped()
1028 -> Result<(), Box<dyn Error>> {
1029 let database = TestDatabase::start("sr_exact_claim").await?;
1030 let result = async {
1031 let account = create_gateway_account(&database.pool, "test_gateway").await?;
1032 let sibling = create_gateway_account(&database.pool, "test_gateway").await?;
1033 let subscriber_id = Uuid::now_v7();
1034 let first =
1035 insert_stale_enrollment(&database.pool, account, subscriber_id, "plan_a").await?;
1036 let second =
1037 insert_stale_enrollment(&database.pool, account, subscriber_id, "plan_b").await?;
1038 sqlx::query(
1039 r#"
1040 UPDATE billing_payment_attempts
1041 SET status = 'review_required', review_required_at = created_at
1042 WHERE id = $1
1043 "#,
1044 )
1045 .bind(first)
1046 .execute(&database.pool)
1047 .await?;
1048 for position in 2..=RECONCILIATION_PHASE_BATCH_SIZE {
1049 insert_stale_enrollment(
1050 &database.pool,
1051 account,
1052 Uuid::now_v7(),
1053 &format!("plan_{position}"),
1054 )
1055 .await?;
1056 }
1057 let sibling_attempt =
1058 insert_stale_enrollment(&database.pool, sibling, Uuid::now_v7(), "sibling_plan")
1059 .await?;
1060
1061 let claimed = claim_exact_reconciliation_attempts(
1062 &database.pool,
1063 GatewayAccountId::new(account.gateway_account_id),
1064 )
1065 .await?;
1066 assert_eq!(claimed.len(), RECONCILIATION_PHASE_BATCH_SIZE as usize);
1067 assert_eq!(*claimed[0].identity().attempt_id().as_uuid(), first);
1068 assert_eq!(*claimed[1].identity().attempt_id().as_uuid(), second);
1069 assert_eq!(claimed[0].kind(), PaymentAttemptKind::SubscriptionInitial);
1070 assert_eq!(
1071 claimed[0]
1072 .request()
1073 .target()
1074 .plan_key()
1075 .map(|key| key.as_str()),
1076 Some("plan_a"),
1077 );
1078 assert!(claimed.iter().all(|attempt| {
1079 *attempt.identity().gateway_account_id().as_uuid() == account.gateway_account_id
1080 }));
1081 assert!(
1082 !apply_exact_query_observation(
1083 &database.pool,
1084 &claimed[0],
1085 ExactQueryObservation::NoTransaction,
1086 )
1087 .await?
1088 );
1089 assert_eq!(
1090 attempt_status(&database.pool, first).await?,
1091 "review_required"
1092 );
1093 assert!(
1094 apply_exact_query_observation(
1095 &database.pool,
1096 &claimed[1],
1097 ExactQueryObservation::NoTransaction,
1098 )
1099 .await?
1100 );
1101 assert_eq!(
1102 attempt_status(&database.pool, second).await?,
1103 "review_required"
1104 );
1105 assert!(
1106 apply_exact_query_observation(
1107 &database.pool,
1108 &claimed[2],
1109 ExactQueryObservation::MalformedResponse,
1110 )
1111 .await?
1112 );
1113
1114 let remainder = claim_exact_reconciliation_attempts(
1115 &database.pool,
1116 GatewayAccountId::new(account.gateway_account_id),
1117 )
1118 .await?;
1119 assert_eq!(remainder.len(), 1);
1120 assert_eq!(
1121 *claim_exact_reconciliation_attempts(
1122 &database.pool,
1123 GatewayAccountId::new(sibling.gateway_account_id),
1124 )
1125 .await?[0]
1126 .identity()
1127 .attempt_id()
1128 .as_uuid(),
1129 sibling_attempt,
1130 );
1131 Ok::<_, Box<dyn Error>>(())
1132 }
1133 .await;
1134 let cleanup = database.cleanup().await;
1135 result?;
1136 cleanup
1137 }
1138
1139 #[tokio::test]
1140 async fn stale_payment_method_replacement_cleanup_is_bounded_and_account_scoped()
1141 -> Result<(), Box<dyn Error>> {
1142 let database = TestDatabase::start("sr_recon_method").await?;
1143 let result = async {
1144 let account = create_gateway_account(&database.pool, "test_gateway").await?;
1145 let sibling = create_gateway_account(&database.pool, "test_gateway").await?;
1146 for position in 0..=RECONCILIATION_PHASE_BATCH_SIZE {
1147 insert_stale_payment_method_replacement(
1148 &database.pool,
1149 account.billing_scope_id,
1150 account.gateway_account_id,
1151 account.gateway_configuration_id,
1152 position,
1153 )
1154 .await?;
1155 }
1156 insert_stale_payment_method_replacement(
1157 &database.pool,
1158 sibling.billing_scope_id,
1159 sibling.gateway_account_id,
1160 sibling.gateway_configuration_id,
1161 10_000,
1162 )
1163 .await?;
1164
1165 assert_eq!(
1166 fail_stale_unsubmitted_payment_method_replacements(
1167 &database.pool,
1168 GatewayAccountId::new(account.gateway_account_id),
1169 )
1170 .await?,
1171 RECONCILIATION_PHASE_BATCH_SIZE as u64,
1172 );
1173 assert_eq!(
1174 fail_stale_unsubmitted_payment_method_replacements(
1175 &database.pool,
1176 GatewayAccountId::new(account.gateway_account_id),
1177 )
1178 .await?,
1179 1,
1180 );
1181 let sibling_status: String = sqlx::query_scalar(
1182 "SELECT status FROM billing_payment_attempts WHERE gateway_account_id = $1",
1183 )
1184 .bind(sibling.gateway_account_id)
1185 .fetch_one(&database.pool)
1186 .await?;
1187 assert_eq!(sibling_status, "pending");
1188 Ok::<_, Box<dyn Error>>(())
1189 }
1190 .await;
1191 let cleanup = database.cleanup().await;
1192 result?;
1193 cleanup
1194 }
1195
1196 async fn insert_stale_payment_method_replacement(
1197 pool: &sqlx::PgPool,
1198 billing_scope_id: Uuid,
1199 gateway_account_id: Uuid,
1200 gateway_configuration_id: Uuid,
1201 position: i64,
1202 ) -> Result<(), sqlx::Error> {
1203 let subscriber_id = Uuid::now_v7();
1204 let payment_method_id = Uuid::now_v7();
1205 let subscription_id = Uuid::now_v7();
1206 let attempt_id = Uuid::now_v7();
1207 let transaction_id = format!("txn{position}ref");
1208 sqlx::query(
1209 r#"
1210 INSERT INTO billing_payment_methods (
1211 id, billing_scope_id, subscriber_id, gateway_account_id,
1212 gateway_payment_method_reference, status
1213 ) VALUES ($1, $2, $3, $4, $5, 'active')
1214 "#,
1215 )
1216 .bind(payment_method_id)
1217 .bind(billing_scope_id)
1218 .bind(subscriber_id)
1219 .bind(gateway_account_id)
1220 .bind(format!("method{position}ref"))
1221 .execute(pool)
1222 .await?;
1223 sqlx::query(
1224 r#"
1225 WITH clock AS MATERIALIZED (
1226 SELECT clock_timestamp() AS observed_at
1227 )
1228 INSERT INTO billing_subscriptions (
1229 id, billing_scope_id, subscriber_id, plan_key, status,
1230 gateway_account_id, payment_method_id, amount_cents, currency,
1231 current_period_start_at, current_period_end_at, next_renewal_at,
1232 initial_transaction_id
1233 ) SELECT
1234 $1, $2, $3, 'test_plan', 'active', $4, $5, 100, 'USD',
1235 observed_at - interval '1 day',
1236 observed_at + interval '1 day',
1237 observed_at + interval '1 day', $6
1238 FROM clock
1239 "#,
1240 )
1241 .bind(subscription_id)
1242 .bind(billing_scope_id)
1243 .bind(subscriber_id)
1244 .bind(gateway_account_id)
1245 .bind(payment_method_id)
1246 .bind(&transaction_id)
1247 .execute(pool)
1248 .await?;
1249 sqlx::query(
1250 r#"
1251 INSERT INTO billing_payment_attempts (
1252 id, billing_scope_id, subscriber_id, plan_key, subscription_id,
1253 payment_method_id, attempt_kind, status, idempotency_key,
1254 request_fingerprint, amount_cents, currency, gateway_account_id,
1255 gateway_configuration_id, gateway_order_id,
1256 payment_method_update_expected_payment_method_id,
1257 payment_method_update_expected_initial_transaction_id, created_at,
1258 updated_at
1259 ) VALUES (
1260 $1, $2, $3, 'test_plan', $4, $5,
1261 'subscription_payment_method_update', 'pending', $6, $7, 0,
1262 'USD', $8, $9, $10, $5, $11,
1263 clock_timestamp() - interval '4 minutes',
1264 clock_timestamp() - interval '4 minutes'
1265 )
1266 "#,
1267 )
1268 .bind(attempt_id)
1269 .bind(billing_scope_id)
1270 .bind(subscriber_id)
1271 .bind(subscription_id)
1272 .bind(payment_method_id)
1273 .bind(format!("idem{position}"))
1274 .bind(format!("fingerprint{position}"))
1275 .bind(gateway_account_id)
1276 .bind(gateway_configuration_id)
1277 .bind(format!("order{position}ref"))
1278 .bind(transaction_id)
1279 .execute(pool)
1280 .await?;
1281 Ok(())
1282 }
1283
1284 #[tokio::test]
1285 async fn stale_enrollment_cleanup_uses_the_persisted_plan_lock_and_account_scope()
1286 -> Result<(), Box<dyn Error>> {
1287 let database = TestDatabase::start("sr_recon_initial").await?;
1288 let result = async {
1289 let account = create_gateway_account(&database.pool, "test_gateway").await?;
1290 let sibling = create_gateway_account(&database.pool, "test_gateway").await?;
1291 let subscriber_id = Uuid::now_v7();
1292 let plan_a = "plan_a";
1293 let plan_b = "plan_b";
1294 let plan_a_attempt =
1295 insert_stale_enrollment(&database.pool, account, subscriber_id, plan_a).await?;
1296 let plan_b_attempt =
1297 insert_stale_enrollment(&database.pool, account, subscriber_id, plan_b).await?;
1298 let sibling_attempt =
1299 insert_stale_enrollment(&database.pool, sibling, Uuid::now_v7(), "plan_c").await?;
1300
1301 let mut lock_holder = database.pool.begin().await?;
1302 sqlx::query(
1303 "SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':' || $2, 0))",
1304 )
1305 .bind(subscriber_id)
1306 .bind(plan_a)
1307 .execute(&mut *lock_holder)
1308 .await?;
1309
1310 assert_eq!(
1311 fail_stale_unsubmitted_subscription_enrollments(
1312 &database.pool,
1313 GatewayAccountId::new(account.gateway_account_id),
1314 )
1315 .await?,
1316 1,
1317 );
1318 assert_eq!(
1319 attempt_status(&database.pool, plan_a_attempt).await?,
1320 "pending"
1321 );
1322 assert_eq!(
1323 attempt_status(&database.pool, plan_b_attempt).await?,
1324 "failed"
1325 );
1326 assert_eq!(
1327 attempt_status(&database.pool, sibling_attempt).await?,
1328 "pending"
1329 );
1330
1331 lock_holder.rollback().await?;
1332 assert_eq!(
1333 fail_stale_unsubmitted_subscription_enrollments(
1334 &database.pool,
1335 GatewayAccountId::new(account.gateway_account_id),
1336 )
1337 .await?,
1338 1,
1339 );
1340 assert_eq!(
1341 attempt_status(&database.pool, plan_a_attempt).await?,
1342 "failed"
1343 );
1344 Ok::<_, Box<dyn Error>>(())
1345 }
1346 .await;
1347 let cleanup = database.cleanup().await;
1348 result?;
1349 cleanup
1350 }
1351
1352 #[tokio::test]
1353 async fn pending_charge_classification_skips_a_busy_persisted_plan_without_starvation()
1354 -> Result<(), Box<dyn Error>> {
1355 let database = TestDatabase::start("sr_charge_lock").await?;
1356 let result = async {
1357 let account = create_gateway_account(&database.pool, "test_gateway").await?;
1358 let sibling = create_gateway_account(&database.pool, "test_gateway").await?;
1359 let subscriber_id = Uuid::now_v7();
1360 let plan_a_charge = insert_pending_processor_charge(
1361 &database.pool,
1362 account,
1363 subscriber_id,
1364 "plan_a",
1365 30,
1366 )
1367 .await?;
1368 let plan_b_charge = insert_pending_processor_charge(
1369 &database.pool,
1370 account,
1371 subscriber_id,
1372 "plan_b",
1373 20,
1374 )
1375 .await?;
1376 let sibling_charge = insert_pending_processor_charge(
1377 &database.pool,
1378 sibling,
1379 Uuid::now_v7(),
1380 "plan_c",
1381 10,
1382 )
1383 .await?;
1384
1385 let mut lock_holder = database.pool.begin().await?;
1386 sqlx::query(
1387 "SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':' || $2, 0))",
1388 )
1389 .bind(subscriber_id)
1390 .bind("plan_a")
1391 .execute(&mut *lock_holder)
1392 .await?;
1393
1394 let summary = classify_pending_processor_charges(
1395 &database.pool,
1396 GatewayAccountId::new(account.gateway_account_id),
1397 1,
1398 )
1399 .await?;
1400 assert_eq!(summary.transitioned(), 1);
1401 assert_eq!(summary.skipped_locked(), 1);
1402 assert_eq!(summary.remaining_pending(), 1);
1403 assert_eq!(
1404 charge_progression(&database.pool, plan_a_charge).await?,
1405 "pending"
1406 );
1407 assert_eq!(
1408 charge_progression(&database.pool, plan_b_charge).await?,
1409 "reconciliation_required"
1410 );
1411 assert_eq!(
1412 charge_progression(&database.pool, sibling_charge).await?,
1413 "pending"
1414 );
1415
1416 lock_holder.rollback().await?;
1417 let summary = classify_pending_processor_charges(
1418 &database.pool,
1419 GatewayAccountId::new(account.gateway_account_id),
1420 1,
1421 )
1422 .await?;
1423 assert_eq!(summary.transitioned(), 1);
1424 assert_eq!(summary.skipped_locked(), 0);
1425 assert_eq!(summary.remaining_pending(), 0);
1426 assert_eq!(
1427 charge_progression(&database.pool, plan_a_charge).await?,
1428 "reconciliation_required"
1429 );
1430 Ok::<_, Box<dyn Error>>(())
1431 }
1432 .await;
1433 let cleanup = database.cleanup().await;
1434 result?;
1435 cleanup
1436 }
1437
1438 #[tokio::test]
1439 async fn pending_charge_classification_caps_each_account_pass_at_one_hundred()
1440 -> Result<(), Box<dyn Error>> {
1441 let database = TestDatabase::start("sr_charge_bound").await?;
1442 let result = async {
1443 let account = create_gateway_account(&database.pool, "test_gateway").await?;
1444 let sibling = create_gateway_account(&database.pool, "test_gateway").await?;
1445 for position in 0..=RECONCILIATION_PHASE_BATCH_SIZE {
1446 insert_pending_processor_charge(
1447 &database.pool,
1448 account,
1449 Uuid::now_v7(),
1450 "test_plan",
1451 position,
1452 )
1453 .await?;
1454 }
1455 let sibling_charge = insert_pending_processor_charge(
1456 &database.pool,
1457 sibling,
1458 Uuid::now_v7(),
1459 "test_plan",
1460 10_000,
1461 )
1462 .await?;
1463
1464 let first = classify_pending_processor_charges(
1465 &database.pool,
1466 GatewayAccountId::new(account.gateway_account_id),
1467 u64::MAX,
1468 )
1469 .await?;
1470 assert_eq!(first.transitioned(), RECONCILIATION_PHASE_BATCH_SIZE as u64);
1471 assert_eq!(first.remaining_pending(), 1);
1472 let second = classify_pending_processor_charges(
1473 &database.pool,
1474 GatewayAccountId::new(account.gateway_account_id),
1475 u64::MAX,
1476 )
1477 .await?;
1478 assert_eq!(second.transitioned(), 1);
1479 assert_eq!(second.remaining_pending(), 0);
1480 assert_eq!(
1481 charge_progression(&database.pool, sibling_charge).await?,
1482 "pending"
1483 );
1484 Ok::<_, Box<dyn Error>>(())
1485 }
1486 .await;
1487 let cleanup = database.cleanup().await;
1488 result?;
1489 cleanup
1490 }
1491
1492 async fn insert_pending_processor_charge(
1493 pool: &sqlx::PgPool,
1494 account: crate::test_support::GatewayAccountFixture,
1495 subscriber_id: Uuid,
1496 plan_key: &str,
1497 age_seconds: i64,
1498 ) -> Result<Uuid, sqlx::Error> {
1499 let attempt_id = Uuid::now_v7();
1500 let charge_id = Uuid::now_v7();
1501 let order_id = format!("order-{attempt_id}");
1502 sqlx::query(
1503 r#"
1504 INSERT INTO billing_payment_attempts (
1505 id, billing_scope_id, subscriber_id, plan_key, attempt_kind,
1506 status, idempotency_key, request_fingerprint, amount_cents,
1507 currency, gateway_account_id, gateway_configuration_id,
1508 gateway_order_id
1509 ) VALUES (
1510 $1, $2, $3, $4, 'subscription_initial', 'pending', $5, $6,
1511 100, 'USD', $7, $8, $9
1512 )
1513 "#,
1514 )
1515 .bind(attempt_id)
1516 .bind(account.billing_scope_id)
1517 .bind(subscriber_id)
1518 .bind(plan_key)
1519 .bind(format!("idem-{attempt_id}"))
1520 .bind(format!("fingerprint-{attempt_id}"))
1521 .bind(account.gateway_account_id)
1522 .bind(account.gateway_configuration_id)
1523 .bind(&order_id)
1524 .execute(pool)
1525 .await?;
1526 sqlx::query(
1527 r#"
1528 INSERT INTO billing_processor_charges (
1529 id, attempt_id, billing_scope_id, gateway_account_id,
1530 gateway_order_id, gateway_transaction_id, gateway_response,
1531 gateway_response_code, gateway_response_text,
1532 gateway_condition, charge_role, progression_state,
1533 observed_at, attempt_kind, plan_key, amount_cents, currency
1534 ) VALUES (
1535 $1, $2, $3, $4, $5, $6, '1', '100', 'Approved',
1536 'complete', 'primary', 'pending',
1537 clock_timestamp() - ($7::bigint * interval '1 second'),
1538 'subscription_initial', $8, 100, 'USD'
1539 )
1540 "#,
1541 )
1542 .bind(charge_id)
1543 .bind(attempt_id)
1544 .bind(account.billing_scope_id)
1545 .bind(account.gateway_account_id)
1546 .bind(order_id)
1547 .bind(format!("transaction-{attempt_id}"))
1548 .bind(age_seconds)
1549 .bind(plan_key)
1550 .execute(pool)
1551 .await?;
1552 Ok(charge_id)
1553 }
1554
1555 async fn charge_progression(
1556 pool: &sqlx::PgPool,
1557 charge_id: Uuid,
1558 ) -> Result<String, sqlx::Error> {
1559 sqlx::query_scalar("SELECT progression_state FROM billing_processor_charges WHERE id = $1")
1560 .bind(charge_id)
1561 .fetch_one(pool)
1562 .await
1563 }
1564
1565 async fn insert_stale_enrollment(
1566 pool: &sqlx::PgPool,
1567 account: crate::test_support::GatewayAccountFixture,
1568 subscriber_id: Uuid,
1569 plan_key: &str,
1570 ) -> Result<Uuid, sqlx::Error> {
1571 let attempt_id = Uuid::now_v7();
1572 sqlx::query(
1573 r#"
1574 INSERT INTO billing_payment_attempts (
1575 id, billing_scope_id, subscriber_id, plan_key, attempt_kind,
1576 status, idempotency_key, request_fingerprint, amount_cents,
1577 currency, gateway_account_id, gateway_configuration_id,
1578 gateway_order_id, created_at, updated_at
1579 ) VALUES (
1580 $1, $2, $3, $4, 'subscription_initial', 'pending', $5, $6,
1581 100, 'USD', $7, $8, $9,
1582 clock_timestamp() - interval '31 minutes',
1583 clock_timestamp() - interval '31 minutes'
1584 )
1585 "#,
1586 )
1587 .bind(attempt_id)
1588 .bind(account.billing_scope_id)
1589 .bind(subscriber_id)
1590 .bind(plan_key)
1591 .bind(format!("idem-{attempt_id}"))
1592 .bind(format!("fingerprint-{attempt_id}"))
1593 .bind(account.gateway_account_id)
1594 .bind(account.gateway_configuration_id)
1595 .bind(format!("order_{}", attempt_id.simple()))
1596 .execute(pool)
1597 .await?;
1598 Ok(attempt_id)
1599 }
1600
1601 async fn attempt_status(pool: &sqlx::PgPool, attempt_id: Uuid) -> Result<String, sqlx::Error> {
1602 sqlx::query_scalar("SELECT status FROM billing_payment_attempts WHERE id = $1")
1603 .bind(attempt_id)
1604 .fetch_one(pool)
1605 .await
1606 }
1607}