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    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
111/// Returns every registered gateway account in deterministic locator order.
112///
113/// This operation intentionally has no caller-selected or implicit limit.
114/// Hosts must either dispatch the complete result or introduce a separately
115/// designed durable progress cursor.
116pub 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
140/// Claims one bounded, account-scoped batch for authoritative exact queries.
141///
142/// Updating `updated_at` is the durable requery claim. The returned value is
143/// the canonical shared attempt, so subscription reconciliation never passes
144/// through a host persistence projection.
145pub 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
234/// Applies one authoritative negative exact-query observation.
235///
236/// The attempt is locked and its immutable shared locator is revalidated
237/// before any transition. `true` means this observation changed the lifecycle
238/// state and therefore counts against the exact-query transition budget.
239pub 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
363/// Fails one bounded batch of stale payment-method replacements for an account.
364///
365/// These attempts have never crossed the provider boundary, so expiring them
366/// is a local reconciliation phase and performs no gateway I/O.
367pub 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
410/// Expires every stale prepared enrollment for one account.
411///
412/// Each candidate enters its persisted subscriber/plan aggregate without
413/// waiting for a busy aggregate. This preserves progress for unrelated plans
414/// while serializing with enrollment admission and charge observation.
415pub 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
463/// Classifies one bounded batch of durable pending processor charges.
464///
465/// Candidate reads are account-scoped and stable for the duration of this
466/// pass. Each subscription candidate enters its persisted subscriber/plan
467/// aggregate before locking the attempt and charge. Busy aggregate or row
468/// locks are skipped without consuming the transition budget.
469pub 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}