Skip to main content

syrup_rail_postgres/
reconciliation.rs

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
119/// Returns every registered gateway account in deterministic locator order.
120///
121/// This operation intentionally has no caller-selected or implicit limit.
122/// Hosts must either dispatch the complete result or introduce a separately
123/// designed durable progress cursor.
124pub 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
148/// Claims one bounded, account-scoped batch for authoritative exact queries.
149///
150/// Updating `updated_at` is the durable requery claim. The returned value is
151/// the canonical shared attempt, so subscription reconciliation never passes
152/// through a host persistence projection.
153pub 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
239/// Applies one authoritative negative exact-query observation.
240///
241/// The attempt is locked and its immutable shared locator is revalidated
242/// before any transition. `true` means this observation changed the lifecycle
243/// state and therefore counts against the exact-query transition budget.
244pub 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
371/// Fails one bounded batch of stale payment-method replacements for an account.
372///
373/// These attempts have never crossed the provider boundary, so expiring them
374/// is a local reconciliation phase and performs no gateway I/O.
375pub 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
420/// Fails one bounded batch of stale local renewal and recovery attempts.
421///
422/// Both `pending` reservations and `review_required` rows parked by older
423/// exact-query behavior are eligible only when `submitted_at` proves that no
424/// provider boundary was crossed. This phase performs no gateway I/O.
425pub 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
474/// Expires every stale prepared enrollment for one account.
475///
476/// Each candidate enters its persisted subscriber/plan aggregate without
477/// waiting for a busy aggregate. This preserves progress for unrelated plans
478/// while serializing with enrollment admission and charge observation.
479pub 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
531/// Classifies one bounded batch of durable pending processor charges.
532///
533/// Candidate reads are account-scoped and stable for the duration of this
534/// pass. Each subscription candidate enters its persisted subscriber/plan
535/// aggregate before locking the attempt and charge. Busy aggregate or row
536/// locks are skipped without consuming the transition budget.
537pub 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;