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 LocalAttemptPolicy, STALE_UNSUBMITTED_RECOVERY_TEXT, STALE_UNSUBMITTED_RENEWAL_TEXT,
12 expire_stale_initial_attempts, lock_initial_attempt_rows, lock_initial_charge_rows,
13 lock_payment_attempt_by_id_on_connection, payment_attempt_from_row, set_enrollment_timeouts,
14 try_lock_subscription_aggregate,
15};
16
17use classification::{
18 attempt_locator, classify_pending_charge, count_pending_processor_charges,
19 invalid_reconciliation_state, lock_attempt_for_classification,
20 lock_pending_charge_for_classification, transition_pending_charge,
21};
22
23mod classification;
24
25pub(crate) const RECONCILIATION_PHASE_BATCH_SIZE: i64 = 100;
26pub(crate) const RECONCILIATION_CLAIM_RETRY_AFTER_SECONDS: i64 = 60;
27const STALE_PAYMENT_METHOD_REPLACEMENT_RESPONSE_TEXT: &str =
28 "Payment method update was abandoned before gateway submission.";
29const PROCESSOR_CHARGE_CANDIDATE_PAGE_SIZE: i64 = 128;
30const EXACT_STALE_AFTER_SECONDS: i64 = 30 * 60;
31const EXACT_RECENT_TERMINAL_SECONDS: i64 = 24 * 60 * 60;
32const EXACT_EMPTY_REVIEW_KEEPALIVE_TEXT: &str =
33 "Payment processor still has not returned a transaction during manual review.";
34const EXACT_EMPTY_STALE_PAYMENT_METHOD_TEXT: &str = "Payment method update was submitted locally but no processor transaction appeared before the reconciliation deadline.";
35const EXACT_EMPTY_STALE_REVIEW_TEXT: &str =
36 "Payment processor did not return a transaction before the reconciliation deadline.";
37const EXACT_EMPTY_UNKNOWN_TEXT: &str = "Payment processor has not returned a transaction yet.";
38const EXACT_MALFORMED_STALE_REVIEW_TEXT: &str = "Payment processor returned a malformed exact-query response after the reconciliation deadline.";
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum ExactQueryObservation {
42 NoTransaction,
43 MalformedResponse,
44}
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub struct ProcessorChargeClassificationSummary {
48 transitioned: u64,
49 skipped_locked: u64,
50 remaining_pending: u64,
51}
52
53impl ProcessorChargeClassificationSummary {
54 pub const fn transitioned(self) -> u64 {
55 self.transitioned
56 }
57
58 pub const fn skipped_locked(self) -> u64 {
59 self.skipped_locked
60 }
61
62 pub const fn remaining_pending(self) -> u64 {
63 self.remaining_pending
64 }
65}
66
67#[derive(Clone, Debug)]
68struct PendingChargeCandidate {
69 id: Uuid,
70 attempt_id: Uuid,
71 transaction_id: Option<String>,
72 observed_at: DateTime<Utc>,
73}
74
75#[derive(Clone, Debug, Eq, PartialEq)]
76struct AttemptLocator {
77 id: Uuid,
78 billing_scope_id: BillingScopeId,
79 subscriber_id: SubscriberId,
80 plan_key: Option<PlanKey>,
81 gateway_account_id: GatewayAccountId,
82 kind: PaymentAttemptKind,
83}
84
85#[derive(Clone, Debug)]
86struct LockedAttempt {
87 locator: AttemptLocator,
88 status: PaymentAttemptStatus,
89 resolution_code: Option<PaymentResolutionCode>,
90 amount_cents: i32,
91 transaction_id: Option<String>,
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95enum ChargeRole {
96 Primary,
97 Additional,
98}
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101enum ChargeProgression {
102 ReconciliationRequired,
103 ExternalReversalRequired,
104 Applied,
105 ExternallyReversed,
106}
107
108impl ChargeProgression {
109 const fn as_str(self) -> &'static str {
110 match self {
111 Self::ReconciliationRequired => "reconciliation_required",
112 Self::ExternalReversalRequired => "external_reversal_required",
113 Self::Applied => "applied",
114 Self::ExternallyReversed => "externally_reversed",
115 }
116 }
117}
118
119pub async fn reconciliation_gateway_accounts(
125 pool: &PgPool,
126) -> Result<Vec<GatewayAccountReconciliationCandidate>, sqlx::Error> {
127 let rows = sqlx::query_as::<_, (Uuid, Uuid)>(
128 r#"
129 SELECT billing_scope_id, id
130 FROM billing_gateway_accounts
131 ORDER BY billing_scope_id, id
132 "#,
133 )
134 .fetch_all(pool)
135 .await?;
136
137 Ok(rows
138 .into_iter()
139 .map(|(billing_scope_id, gateway_account_id)| {
140 GatewayAccountReconciliationCandidate::new(
141 BillingScopeId::new(billing_scope_id),
142 GatewayAccountId::new(gateway_account_id),
143 )
144 })
145 .collect())
146}
147
148pub async fn claim_exact_reconciliation_attempts(
154 pool: &PgPool,
155 gateway_account_id: GatewayAccountId,
156) -> Result<Vec<PaymentAttempt>, PaymentAttemptStoreError> {
157 let mut transaction = pool.begin().await?;
158 sqlx::query("SELECT set_config('lock_timeout', '250ms', true)")
159 .execute(&mut *transaction)
160 .await?;
161 let rows = sqlx::query(
162 r#"
163 WITH candidate_attempts AS MATERIALIZED (
164 SELECT attempts.id AS attempt_id, attempts.created_at
165 FROM billing_payment_attempts AS attempts
166 WHERE attempts.gateway_account_id = $1
167 AND attempts.submitted_at IS NOT NULL
168 AND (
169 (
170 attempts.status IN ('unknown', 'review_required')
171 AND attempts.updated_at <= clock_timestamp()
172 - ($2::bigint * interval '1 second')
173 )
174 OR (
175 attempts.status = 'pending'
176 AND attempts.submitted_at
177 <= clock_timestamp() - ($3::bigint * interval '1 second')
178 AND attempts.updated_at <= clock_timestamp()
179 - ($2::bigint * interval '1 second')
180 )
181 OR (
182 attempts.status IN ('declined', 'failed')
183 AND public.billing_canonical_gateway_transaction_id(
184 attempts.gateway_transaction_id
185 ) IS NOT NULL
186 AND attempts.resolved_at IS NOT NULL
187 AND attempts.resolved_at >= clock_timestamp()
188 - ($4::bigint * interval '1 second')
189 AND attempts.updated_at <= clock_timestamp()
190 - ($2::bigint * interval '1 second')
191 )
192 )
193 AND NOT (
194 attempts.attempt_kind = 'subscription_initial'
195 AND attempts.status = 'review_required'
196 AND (
197 attempts.resolution_code IS NOT DISTINCT FROM
198 'subscription_initial_current_subscription_conflict'
199 OR attempts.resolution_code IS NOT DISTINCT FROM
200 'subscription_initial_current_grant_conflict'
201 )
202 )
203 AND attempts.resolution_code IS DISTINCT FROM
204 'subscription_initial_externally_refunded'
205 AND attempts.resolution_code IS DISTINCT FROM
206 'subscription_initial_externally_voided'
207 ORDER BY attempts.created_at, attempts.id
208 LIMIT $5
209 FOR UPDATE OF attempts SKIP LOCKED
210 ), updated_attempts AS (
211 UPDATE billing_payment_attempts AS attempts
212 SET updated_at = clock_timestamp()
213 FROM candidate_attempts
214 WHERE attempts.id = candidate_attempts.attempt_id
215 RETURNING attempts.*
216 )
217 SELECT updated_attempts.*
218 FROM updated_attempts
219 INNER JOIN candidate_attempts
220 ON candidate_attempts.attempt_id = updated_attempts.id
221 ORDER BY candidate_attempts.created_at, candidate_attempts.attempt_id
222 "#,
223 )
224 .bind(gateway_account_id.as_uuid())
225 .bind(RECONCILIATION_CLAIM_RETRY_AFTER_SECONDS)
226 .bind(EXACT_STALE_AFTER_SECONDS)
227 .bind(EXACT_RECENT_TERMINAL_SECONDS)
228 .bind(RECONCILIATION_PHASE_BATCH_SIZE)
229 .fetch_all(&mut *transaction)
230 .await?;
231 let attempts = rows
232 .iter()
233 .map(payment_attempt_from_row)
234 .collect::<Result<Vec<_>, _>>()?;
235 transaction.commit().await?;
236 Ok(attempts)
237}
238
239pub async fn apply_exact_query_observation(
245 pool: &PgPool,
246 claimed: &PaymentAttempt,
247 observation: ExactQueryObservation,
248) -> Result<bool, PaymentAttemptStoreError> {
249 let mut transaction = pool.begin().await?;
250 sqlx::query("SELECT set_config('lock_timeout', '250ms', true)")
251 .execute(&mut *transaction)
252 .await?;
253 let current = lock_payment_attempt_by_id_on_connection(
254 &mut transaction,
255 claimed.identity().billing_scope_id(),
256 claimed.identity().attempt_id(),
257 )
258 .await?
259 .ok_or(PaymentAttemptStoreError::InvalidState(
260 "claimed exact-query attempt was not found",
261 ))?;
262 if current.identity() != claimed.identity() || current.request() != claimed.request() {
263 return Err(PaymentAttemptStoreError::InvalidState(
264 "claimed exact-query attempt identity changed",
265 ));
266 }
267 let Some(submitted_at) = current.state().timestamps().submitted_at() else {
268 transaction.commit().await?;
269 return Ok(false);
270 };
271 let now: DateTime<Utc> = sqlx::query_scalar("SELECT clock_timestamp()")
272 .fetch_one(&mut *transaction)
273 .await?;
274 let stale = submitted_at <= now - chrono::Duration::seconds(EXACT_STALE_AFTER_SECONDS);
275 let status = current.status();
276 let evidence = current.state().processor_evidence();
277
278 let (next_status, message, transitioned) = match observation {
279 ExactQueryObservation::NoTransaction
280 if status == PaymentAttemptStatus::ReviewRequired
281 && evidence.has_gateway_reference() =>
282 {
283 (status, EXACT_EMPTY_REVIEW_KEEPALIVE_TEXT, false)
284 }
285 ExactQueryObservation::NoTransaction
286 if stale
287 && current.kind() == PaymentAttemptKind::SubscriptionPaymentMethodUpdate
288 && matches!(
289 status,
290 PaymentAttemptStatus::Pending | PaymentAttemptStatus::ReviewRequired
291 )
292 && current.state().timestamps().submitted_at().is_some()
293 && evidence.transaction_id().is_none()
294 && evidence.condition().is_none() =>
295 {
296 (
297 PaymentAttemptStatus::Failed,
298 EXACT_EMPTY_STALE_PAYMENT_METHOD_TEXT,
299 true,
300 )
301 }
302 ExactQueryObservation::NoTransaction if stale => (
303 PaymentAttemptStatus::ReviewRequired,
304 EXACT_EMPTY_STALE_REVIEW_TEXT,
305 status != PaymentAttemptStatus::ReviewRequired,
306 ),
307 ExactQueryObservation::NoTransaction if status == PaymentAttemptStatus::Unknown => {
308 (status, EXACT_EMPTY_UNKNOWN_TEXT, false)
309 }
310 ExactQueryObservation::MalformedResponse if stale => (
311 PaymentAttemptStatus::ReviewRequired,
312 EXACT_MALFORMED_STALE_REVIEW_TEXT,
313 status != PaymentAttemptStatus::ReviewRequired,
314 ),
315 _ => {
316 transaction.commit().await?;
317 return Ok(false);
318 }
319 };
320
321 let result = if next_status == PaymentAttemptStatus::Failed {
322 sqlx::query(
323 r#"
324 UPDATE billing_payment_attempts
325 SET status = 'failed', gateway_response_text = $2,
326 gateway_condition = COALESCE(gateway_condition, 'failed'),
327 resolved_at = clock_timestamp(), updated_at = clock_timestamp()
328 WHERE id = $1 AND status IN ('pending', 'review_required')
329 "#,
330 )
331 .bind(current.identity().attempt_id().as_uuid())
332 .bind(message)
333 .execute(&mut *transaction)
334 .await?
335 } else if next_status == PaymentAttemptStatus::ReviewRequired {
336 sqlx::query(
337 r#"
338 UPDATE billing_payment_attempts
339 SET status = 'review_required',
340 gateway_response_text = CASE
341 WHEN status = 'review_required'
342 AND NULLIF(BTRIM(gateway_response_text), '') IS NOT NULL
343 THEN gateway_response_text ELSE $2
344 END,
345 updated_at = clock_timestamp()
346 WHERE id = $1 AND status IN ('pending', 'unknown', 'review_required')
347 "#,
348 )
349 .bind(current.identity().attempt_id().as_uuid())
350 .bind(message)
351 .execute(&mut *transaction)
352 .await?
353 } else {
354 sqlx::query(
355 r#"
356 UPDATE billing_payment_attempts
357 SET gateway_response_text = $2, updated_at = clock_timestamp()
358 WHERE id = $1 AND status = $3
359 "#,
360 )
361 .bind(current.identity().attempt_id().as_uuid())
362 .bind(message)
363 .bind(status.as_str())
364 .execute(&mut *transaction)
365 .await?
366 };
367 transaction.commit().await?;
368 Ok(transitioned && result.rows_affected() == 1)
369}
370
371pub async fn fail_stale_unsubmitted_payment_method_replacements(
376 pool: &PgPool,
377 gateway_account_id: GatewayAccountId,
378) -> Result<u64, sqlx::Error> {
379 let policy = LocalAttemptPolicy::for_kind(PaymentAttemptKind::SubscriptionPaymentMethodUpdate);
380 let mut transaction = pool.begin().await?;
381 sqlx::query("SELECT set_config('lock_timeout', '250ms', true)")
382 .execute(&mut *transaction)
383 .await?;
384 let result = sqlx::query(
385 r#"
386 WITH stale_attempts AS (
387 SELECT id
388 FROM billing_payment_attempts
389 WHERE attempt_kind = 'subscription_payment_method_update'
390 AND status = ANY($1::text[])
391 AND submitted_at IS NULL
392 AND created_at <= clock_timestamp()
393 - ($2::bigint * interval '1 second')
394 AND gateway_account_id = $3
395 ORDER BY created_at, id
396 LIMIT $4
397 FOR UPDATE SKIP LOCKED
398 )
399 UPDATE billing_payment_attempts AS attempts
400 SET status = 'failed',
401 gateway_response_text = COALESCE(gateway_response_text, $5),
402 gateway_condition = COALESCE(gateway_condition, 'failed'),
403 resolved_at = clock_timestamp(),
404 updated_at = clock_timestamp()
405 FROM stale_attempts
406 WHERE attempts.id = stale_attempts.id
407 "#,
408 )
409 .bind(LocalAttemptPolicy::expirable_status_values())
410 .bind(policy.stale_after_seconds())
411 .bind(gateway_account_id.as_uuid())
412 .bind(RECONCILIATION_PHASE_BATCH_SIZE)
413 .bind(STALE_PAYMENT_METHOD_REPLACEMENT_RESPONSE_TEXT)
414 .execute(&mut *transaction)
415 .await?;
416 transaction.commit().await?;
417 Ok(result.rows_affected())
418}
419
420pub async fn fail_stale_unsubmitted_subscription_charges(
426 pool: &PgPool,
427 gateway_account_id: GatewayAccountId,
428) -> Result<u64, sqlx::Error> {
429 let policy = LocalAttemptPolicy::for_kind(PaymentAttemptKind::SubscriptionRenewal);
430 let mut transaction = pool.begin().await?;
431 sqlx::query("SELECT set_config('lock_timeout', '250ms', true)")
432 .execute(&mut *transaction)
433 .await?;
434 let result = sqlx::query(
435 r#"
436 WITH stale_attempts AS (
437 SELECT id
438 FROM billing_payment_attempts
439 WHERE attempt_kind IN ('subscription_renewal', 'subscription_recovery')
440 AND status = ANY($1::text[])
441 AND submitted_at IS NULL
442 AND created_at <= clock_timestamp()
443 - ($2::bigint * interval '1 second')
444 AND gateway_account_id = $3
445 ORDER BY created_at, id
446 LIMIT $4
447 FOR UPDATE SKIP LOCKED
448 )
449 UPDATE billing_payment_attempts AS attempts
450 SET status = 'failed',
451 gateway_response_text = CASE attempts.attempt_kind
452 WHEN 'subscription_renewal' THEN $5
453 WHEN 'subscription_recovery' THEN $6
454 END,
455 gateway_condition = COALESCE(attempts.gateway_condition, 'failed'),
456 resolved_at = COALESCE(attempts.resolved_at, clock_timestamp()),
457 updated_at = clock_timestamp()
458 FROM stale_attempts
459 WHERE attempts.id = stale_attempts.id
460 "#,
461 )
462 .bind(LocalAttemptPolicy::expirable_status_values())
463 .bind(policy.stale_after_seconds())
464 .bind(gateway_account_id.as_uuid())
465 .bind(RECONCILIATION_PHASE_BATCH_SIZE)
466 .bind(STALE_UNSUBMITTED_RENEWAL_TEXT)
467 .bind(STALE_UNSUBMITTED_RECOVERY_TEXT)
468 .execute(&mut *transaction)
469 .await?;
470 transaction.commit().await?;
471 Ok(result.rows_affected())
472}
473
474pub async fn fail_stale_unsubmitted_subscription_enrollments(
480 pool: &PgPool,
481 gateway_account_id: GatewayAccountId,
482) -> Result<u64, sqlx::Error> {
483 let policy = LocalAttemptPolicy::for_kind(PaymentAttemptKind::SubscriptionInitial);
484 let candidates = sqlx::query_as::<_, (Uuid, Uuid, String)>(
485 r#"
486 SELECT DISTINCT billing_scope_id, subscriber_id, plan_key
487 FROM billing_payment_attempts
488 WHERE gateway_account_id = $1
489 AND attempt_kind = 'subscription_initial'
490 AND status = ANY($2::text[])
491 AND submitted_at IS NULL
492 AND created_at <= clock_timestamp()
493 - ($3::bigint * interval '1 second')
494 ORDER BY billing_scope_id, subscriber_id, plan_key
495 "#,
496 )
497 .bind(gateway_account_id.as_uuid())
498 .bind(LocalAttemptPolicy::expirable_status_values())
499 .bind(policy.stale_after_seconds())
500 .fetch_all(pool)
501 .await?;
502
503 let mut failed = 0;
504 for (billing_scope_id, subscriber_id, plan_key) in candidates {
505 let plan_key = PlanKey::new(plan_key)
506 .map_err(|_| sqlx::Error::Protocol("stored plan key is invalid".to_owned()))?;
507 let billing_scope_id = BillingScopeId::new(billing_scope_id);
508 let subscriber_id = SubscriberId::new(subscriber_id);
509 let mut transaction = pool.begin().await?;
510 set_enrollment_timeouts(&mut transaction).await?;
511 if !try_lock_subscription_aggregate(&mut transaction, subscriber_id, &plan_key).await? {
512 transaction.rollback().await?;
513 continue;
514 }
515 lock_initial_attempt_rows(&mut transaction, billing_scope_id, subscriber_id, &plan_key)
516 .await?;
517 lock_initial_charge_rows(&mut transaction, billing_scope_id, subscriber_id, &plan_key)
518 .await?;
519 failed += expire_stale_initial_attempts(
520 &mut transaction,
521 billing_scope_id,
522 subscriber_id,
523 &plan_key,
524 )
525 .await?;
526 transaction.commit().await?;
527 }
528 Ok(failed)
529}
530
531pub async fn classify_pending_processor_charges(
538 pool: &PgPool,
539 gateway_account_id: GatewayAccountId,
540 max_transitions: u64,
541) -> Result<ProcessorChargeClassificationSummary, sqlx::Error> {
542 let transition_limit = max_transitions.min(RECONCILIATION_PHASE_BATCH_SIZE as u64);
543 if transition_limit == 0 {
544 return Ok(ProcessorChargeClassificationSummary {
545 transitioned: 0,
546 skipped_locked: 0,
547 remaining_pending: count_pending_processor_charges(pool, gateway_account_id).await?,
548 });
549 }
550
551 let upper_bound = sqlx::query_as::<_, (DateTime<Utc>, Uuid)>(
552 r#"
553 SELECT observed_at, id
554 FROM billing_processor_charges
555 WHERE gateway_account_id = $1 AND progression_state = 'pending'
556 ORDER BY observed_at DESC, id DESC
557 LIMIT 1
558 "#,
559 )
560 .bind(gateway_account_id.as_uuid())
561 .fetch_optional(pool)
562 .await?;
563 let Some((upper_observed_at, upper_id)) = upper_bound else {
564 return Ok(ProcessorChargeClassificationSummary {
565 transitioned: 0,
566 skipped_locked: 0,
567 remaining_pending: 0,
568 });
569 };
570
571 let mut transitioned = 0;
572 let mut skipped_locked = 0;
573 let mut cursor: Option<(DateTime<Utc>, Uuid)> = None;
574 while transitioned < transition_limit {
575 let rows = sqlx::query(
576 r#"
577 SELECT charges.id, charges.attempt_id,
578 billing_canonical_gateway_transaction_id(
579 charges.gateway_transaction_id
580 ) AS transaction_id,
581 charges.observed_at
582 FROM billing_processor_charges charges
583 INNER JOIN billing_payment_attempts attempts
584 ON attempts.id = charges.attempt_id
585 AND attempts.gateway_account_id = $1
586 WHERE charges.gateway_account_id = $1
587 AND charges.progression_state = 'pending'
588 AND (
589 $2::timestamptz IS NULL
590 OR (charges.observed_at, charges.id)
591 > ($2::timestamptz, $3::uuid)
592 )
593 AND (charges.observed_at, charges.id) <= ($4, $5)
594 ORDER BY charges.observed_at, charges.id
595 LIMIT $6
596 "#,
597 )
598 .bind(gateway_account_id.as_uuid())
599 .bind(cursor.as_ref().map(|(observed_at, _)| observed_at))
600 .bind(cursor.as_ref().map(|(_, id)| id))
601 .bind(upper_observed_at)
602 .bind(upper_id)
603 .bind(PROCESSOR_CHARGE_CANDIDATE_PAGE_SIZE)
604 .fetch_all(pool)
605 .await?;
606 let candidates = rows
607 .into_iter()
608 .map(|row| {
609 Ok(PendingChargeCandidate {
610 id: row.try_get("id")?,
611 attempt_id: row.try_get("attempt_id")?,
612 transaction_id: row.try_get("transaction_id")?,
613 observed_at: row.try_get("observed_at")?,
614 })
615 })
616 .collect::<Result<Vec<_>, sqlx::Error>>()?;
617 let Some(last) = candidates.last() else {
618 break;
619 };
620 cursor = Some((last.observed_at, last.id));
621
622 for candidate in candidates {
623 if transitioned >= transition_limit {
624 break;
625 }
626 let mut transaction = pool.begin().await?;
627 set_enrollment_timeouts(&mut transaction).await?;
628 let Some(locator) = attempt_locator(&mut transaction, candidate.attempt_id).await?
629 else {
630 transaction.rollback().await?;
631 continue;
632 };
633 if locator.gateway_account_id != gateway_account_id {
634 return Err(invalid_reconciliation_state());
635 }
636 if let Some(plan_key) = locator.plan_key.as_ref()
637 && !try_lock_subscription_aggregate(
638 &mut transaction,
639 locator.subscriber_id,
640 plan_key,
641 )
642 .await?
643 {
644 skipped_locked += 1;
645 transaction.rollback().await?;
646 continue;
647 }
648 let Some(attempt) = lock_attempt_for_classification(&mut transaction, locator).await?
649 else {
650 skipped_locked += 1;
651 transaction.rollback().await?;
652 continue;
653 };
654 let Some((role, charge_transaction_id, same_charge, dimensions_match)) =
655 lock_pending_charge_for_classification(
656 &mut transaction,
657 candidate.id,
658 candidate.attempt_id,
659 )
660 .await?
661 else {
662 skipped_locked += 1;
663 transaction.rollback().await?;
664 continue;
665 };
666 if !dimensions_match || charge_transaction_id != candidate.transaction_id {
667 return Err(invalid_reconciliation_state());
668 }
669
670 let (progression, state_code) = classify_pending_charge(
671 &mut transaction,
672 &attempt,
673 candidate.id,
674 role,
675 charge_transaction_id.as_deref(),
676 same_charge,
677 )
678 .await?;
679 transition_pending_charge(
680 &mut transaction,
681 candidate.id,
682 progression,
683 state_code.as_deref(),
684 )
685 .await?;
686 transaction.commit().await?;
687 transitioned += 1;
688 }
689 }
690
691 Ok(ProcessorChargeClassificationSummary {
692 transitioned,
693 skipped_locked,
694 remaining_pending: count_pending_processor_charges(pool, gateway_account_id).await?,
695 })
696}
697
698#[cfg(test)]
699mod tests;