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
16use classification::{
17    attempt_locator, classify_pending_charge, count_pending_processor_charges,
18    invalid_reconciliation_state, lock_attempt_for_classification,
19    lock_pending_charge_for_classification, transition_pending_charge,
20};
21
22mod classification;
23
24const PAYMENT_METHOD_REPLACEMENT_STALE_AFTER_SECONDS: i64 = 3 * 60;
25const RECONCILIATION_PHASE_BATCH_SIZE: i64 = 100;
26const STALE_PAYMENT_METHOD_REPLACEMENT_RESPONSE_TEXT: &str =
27    "Payment method update was abandoned before gateway submission.";
28const PROCESSOR_CHARGE_CANDIDATE_PAGE_SIZE: i64 = 128;
29const EXACT_REQUERY_AFTER_SECONDS: i64 = 60;
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 (
168                    (
169                        attempts.status IN ('unknown', 'review_required')
170                        AND attempts.updated_at <= clock_timestamp()
171                            - ($2::bigint * interval '1 second')
172                    )
173                    OR (
174                        attempts.status = 'pending'
175                        AND COALESCE(attempts.submitted_at, attempts.created_at)
176                            <= clock_timestamp() - ($3::bigint * interval '1 second')
177                        AND attempts.updated_at <= clock_timestamp()
178                            - ($2::bigint * interval '1 second')
179                    )
180                    OR (
181                        attempts.status IN ('declined', 'failed')
182                        AND public.billing_canonical_gateway_transaction_id(
183                            attempts.gateway_transaction_id
184                        ) IS NOT NULL
185                        AND attempts.resolved_at IS NOT NULL
186                        AND attempts.resolved_at >= clock_timestamp()
187                            - ($4::bigint * interval '1 second')
188                        AND attempts.updated_at <= clock_timestamp()
189                            - ($2::bigint * interval '1 second')
190                    )
191                )
192                AND NOT (
193                    attempts.attempt_kind = 'subscription_initial'
194                    AND attempts.status = 'review_required'
195                    AND (
196                        attempts.resolution_code IS NOT DISTINCT FROM
197                            'subscription_initial_current_subscription_conflict'
198                        OR attempts.resolution_code IS NOT DISTINCT FROM
199                            'subscription_initial_current_grant_conflict'
200                    )
201                )
202                AND NOT (
203                    attempts.attempt_kind = 'subscription_payment_method_update'
204                    AND attempts.submitted_at IS NULL
205                )
206                AND attempts.resolution_code IS DISTINCT FROM
207                    'subscription_initial_externally_refunded'
208                AND attempts.resolution_code IS DISTINCT FROM
209                    'subscription_initial_externally_voided'
210            ORDER BY attempts.created_at, attempts.id
211            LIMIT $5
212            FOR UPDATE OF attempts SKIP LOCKED
213        ), updated_attempts AS (
214            UPDATE billing_payment_attempts AS attempts
215            SET updated_at = clock_timestamp()
216            FROM candidate_attempts
217            WHERE attempts.id = candidate_attempts.attempt_id
218            RETURNING attempts.*
219        )
220        SELECT updated_attempts.*
221        FROM updated_attempts
222        INNER JOIN candidate_attempts
223            ON candidate_attempts.attempt_id = updated_attempts.id
224        ORDER BY candidate_attempts.created_at, candidate_attempts.attempt_id
225        "#,
226    )
227    .bind(gateway_account_id.as_uuid())
228    .bind(EXACT_REQUERY_AFTER_SECONDS)
229    .bind(EXACT_STALE_AFTER_SECONDS)
230    .bind(EXACT_RECENT_TERMINAL_SECONDS)
231    .bind(RECONCILIATION_PHASE_BATCH_SIZE)
232    .fetch_all(&mut *transaction)
233    .await?;
234    let attempts = rows
235        .iter()
236        .map(payment_attempt_from_row)
237        .collect::<Result<Vec<_>, _>>()?;
238    transaction.commit().await?;
239    Ok(attempts)
240}
241
242/// Applies one authoritative negative exact-query observation.
243///
244/// The attempt is locked and its immutable shared locator is revalidated
245/// before any transition. `true` means this observation changed the lifecycle
246/// state and therefore counts against the exact-query transition budget.
247pub async fn apply_exact_query_observation(
248    pool: &PgPool,
249    claimed: &PaymentAttempt,
250    observation: ExactQueryObservation,
251) -> Result<bool, PaymentAttemptStoreError> {
252    let mut transaction = pool.begin().await?;
253    sqlx::query("SELECT set_config('lock_timeout', '250ms', true)")
254        .execute(&mut *transaction)
255        .await?;
256    let current = lock_payment_attempt_by_id_on_connection(
257        &mut transaction,
258        claimed.identity().billing_scope_id(),
259        claimed.identity().attempt_id(),
260    )
261    .await?
262    .ok_or(PaymentAttemptStoreError::InvalidState(
263        "claimed exact-query attempt was not found",
264    ))?;
265    if current.identity() != claimed.identity() || current.request() != claimed.request() {
266        return Err(PaymentAttemptStoreError::InvalidState(
267            "claimed exact-query attempt identity changed",
268        ));
269    }
270    let now: DateTime<Utc> = sqlx::query_scalar("SELECT clock_timestamp()")
271        .fetch_one(&mut *transaction)
272        .await?;
273    let stale = current.state().timestamps().submitted_or_created_at()
274        <= 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 mut transaction = pool.begin().await?;
380    sqlx::query("SELECT set_config('lock_timeout', '250ms', true)")
381        .execute(&mut *transaction)
382        .await?;
383    let result = sqlx::query(
384        r#"
385        WITH stale_attempts AS (
386            SELECT id
387            FROM billing_payment_attempts
388            WHERE attempt_kind = 'subscription_payment_method_update'
389                AND status = 'pending'
390                AND submitted_at IS NULL
391                AND created_at <= clock_timestamp()
392                    - ($1::bigint * interval '1 second')
393                AND gateway_account_id = $2
394            ORDER BY created_at, id
395            LIMIT $3
396            FOR UPDATE SKIP LOCKED
397        )
398        UPDATE billing_payment_attempts AS attempts
399        SET status = 'failed',
400            gateway_response_text = COALESCE(gateway_response_text, $4),
401            gateway_condition = COALESCE(gateway_condition, 'failed'),
402            resolved_at = clock_timestamp(),
403            updated_at = clock_timestamp()
404        FROM stale_attempts
405        WHERE attempts.id = stale_attempts.id
406        "#,
407    )
408    .bind(PAYMENT_METHOD_REPLACEMENT_STALE_AFTER_SECONDS)
409    .bind(gateway_account_id.as_uuid())
410    .bind(RECONCILIATION_PHASE_BATCH_SIZE)
411    .bind(STALE_PAYMENT_METHOD_REPLACEMENT_RESPONSE_TEXT)
412    .execute(&mut *transaction)
413    .await?;
414    transaction.commit().await?;
415    Ok(result.rows_affected())
416}
417
418/// Expires every stale prepared enrollment for one account.
419///
420/// Each candidate enters its persisted subscriber/plan aggregate without
421/// waiting for a busy aggregate. This preserves progress for unrelated plans
422/// while serializing with enrollment admission and charge observation.
423pub async fn fail_stale_unsubmitted_subscription_enrollments(
424    pool: &PgPool,
425    gateway_account_id: GatewayAccountId,
426) -> Result<u64, sqlx::Error> {
427    let candidates = sqlx::query_as::<_, (Uuid, Uuid, String)>(
428        r#"
429        SELECT DISTINCT billing_scope_id, subscriber_id, plan_key
430        FROM billing_payment_attempts
431        WHERE gateway_account_id = $1
432            AND attempt_kind = 'subscription_initial'
433            AND status = 'pending'
434            AND submitted_at IS NULL
435            AND created_at <= clock_timestamp() - interval '30 minutes'
436        ORDER BY billing_scope_id, subscriber_id, plan_key
437        "#,
438    )
439    .bind(gateway_account_id.as_uuid())
440    .fetch_all(pool)
441    .await?;
442
443    let mut failed = 0;
444    for (billing_scope_id, subscriber_id, plan_key) in candidates {
445        let plan_key = PlanKey::new(plan_key)
446            .map_err(|_| sqlx::Error::Protocol("stored plan key is invalid".to_owned()))?;
447        let billing_scope_id = BillingScopeId::new(billing_scope_id);
448        let subscriber_id = SubscriberId::new(subscriber_id);
449        let mut transaction = pool.begin().await?;
450        set_enrollment_timeouts(&mut transaction).await?;
451        if !try_lock_subscription_aggregate(&mut transaction, subscriber_id, &plan_key).await? {
452            transaction.rollback().await?;
453            continue;
454        }
455        lock_initial_attempt_rows(&mut transaction, billing_scope_id, subscriber_id, &plan_key)
456            .await?;
457        lock_initial_charge_rows(&mut transaction, billing_scope_id, subscriber_id, &plan_key)
458            .await?;
459        failed += expire_stale_initial_attempts(
460            &mut transaction,
461            billing_scope_id,
462            subscriber_id,
463            &plan_key,
464        )
465        .await?;
466        transaction.commit().await?;
467    }
468    Ok(failed)
469}
470
471/// Classifies one bounded batch of durable pending processor charges.
472///
473/// Candidate reads are account-scoped and stable for the duration of this
474/// pass. Each subscription candidate enters its persisted subscriber/plan
475/// aggregate before locking the attempt and charge. Busy aggregate or row
476/// locks are skipped without consuming the transition budget.
477pub async fn classify_pending_processor_charges(
478    pool: &PgPool,
479    gateway_account_id: GatewayAccountId,
480    max_transitions: u64,
481) -> Result<ProcessorChargeClassificationSummary, sqlx::Error> {
482    let transition_limit = max_transitions.min(RECONCILIATION_PHASE_BATCH_SIZE as u64);
483    if transition_limit == 0 {
484        return Ok(ProcessorChargeClassificationSummary {
485            transitioned: 0,
486            skipped_locked: 0,
487            remaining_pending: count_pending_processor_charges(pool, gateway_account_id).await?,
488        });
489    }
490
491    let upper_bound = sqlx::query_as::<_, (DateTime<Utc>, Uuid)>(
492        r#"
493        SELECT observed_at, id
494        FROM billing_processor_charges
495        WHERE gateway_account_id = $1 AND progression_state = 'pending'
496        ORDER BY observed_at DESC, id DESC
497        LIMIT 1
498        "#,
499    )
500    .bind(gateway_account_id.as_uuid())
501    .fetch_optional(pool)
502    .await?;
503    let Some((upper_observed_at, upper_id)) = upper_bound else {
504        return Ok(ProcessorChargeClassificationSummary {
505            transitioned: 0,
506            skipped_locked: 0,
507            remaining_pending: 0,
508        });
509    };
510
511    let mut transitioned = 0;
512    let mut skipped_locked = 0;
513    let mut cursor: Option<(DateTime<Utc>, Uuid)> = None;
514    while transitioned < transition_limit {
515        let rows = sqlx::query(
516            r#"
517            SELECT charges.id, charges.attempt_id,
518                billing_canonical_gateway_transaction_id(
519                    charges.gateway_transaction_id
520                ) AS transaction_id,
521                charges.observed_at
522            FROM billing_processor_charges charges
523            INNER JOIN billing_payment_attempts attempts
524                ON attempts.id = charges.attempt_id
525                AND attempts.gateway_account_id = $1
526            WHERE charges.gateway_account_id = $1
527                AND charges.progression_state = 'pending'
528                AND (
529                    $2::timestamptz IS NULL
530                    OR (charges.observed_at, charges.id)
531                        > ($2::timestamptz, $3::uuid)
532                )
533                AND (charges.observed_at, charges.id) <= ($4, $5)
534            ORDER BY charges.observed_at, charges.id
535            LIMIT $6
536            "#,
537        )
538        .bind(gateway_account_id.as_uuid())
539        .bind(cursor.as_ref().map(|(observed_at, _)| observed_at))
540        .bind(cursor.as_ref().map(|(_, id)| id))
541        .bind(upper_observed_at)
542        .bind(upper_id)
543        .bind(PROCESSOR_CHARGE_CANDIDATE_PAGE_SIZE)
544        .fetch_all(pool)
545        .await?;
546        let candidates = rows
547            .into_iter()
548            .map(|row| {
549                Ok(PendingChargeCandidate {
550                    id: row.try_get("id")?,
551                    attempt_id: row.try_get("attempt_id")?,
552                    transaction_id: row.try_get("transaction_id")?,
553                    observed_at: row.try_get("observed_at")?,
554                })
555            })
556            .collect::<Result<Vec<_>, sqlx::Error>>()?;
557        let Some(last) = candidates.last() else {
558            break;
559        };
560        cursor = Some((last.observed_at, last.id));
561
562        for candidate in candidates {
563            if transitioned >= transition_limit {
564                break;
565            }
566            let mut transaction = pool.begin().await?;
567            set_enrollment_timeouts(&mut transaction).await?;
568            let Some(locator) = attempt_locator(&mut transaction, candidate.attempt_id).await?
569            else {
570                transaction.rollback().await?;
571                continue;
572            };
573            if locator.gateway_account_id != gateway_account_id {
574                return Err(invalid_reconciliation_state());
575            }
576            if let Some(plan_key) = locator.plan_key.as_ref()
577                && !try_lock_subscription_aggregate(
578                    &mut transaction,
579                    locator.subscriber_id,
580                    plan_key,
581                )
582                .await?
583            {
584                skipped_locked += 1;
585                transaction.rollback().await?;
586                continue;
587            }
588            let Some(attempt) = lock_attempt_for_classification(&mut transaction, locator).await?
589            else {
590                skipped_locked += 1;
591                transaction.rollback().await?;
592                continue;
593            };
594            let Some((role, charge_transaction_id, same_charge, dimensions_match)) =
595                lock_pending_charge_for_classification(
596                    &mut transaction,
597                    candidate.id,
598                    candidate.attempt_id,
599                )
600                .await?
601            else {
602                skipped_locked += 1;
603                transaction.rollback().await?;
604                continue;
605            };
606            if !dimensions_match || charge_transaction_id != candidate.transaction_id {
607                return Err(invalid_reconciliation_state());
608            }
609
610            let (progression, state_code) = classify_pending_charge(
611                &mut transaction,
612                &attempt,
613                candidate.id,
614                role,
615                charge_transaction_id.as_deref(),
616                same_charge,
617            )
618            .await?;
619            transition_pending_charge(
620                &mut transaction,
621                candidate.id,
622                progression,
623                state_code.as_deref(),
624            )
625            .await?;
626            transaction.commit().await?;
627            transitioned += 1;
628        }
629    }
630
631    Ok(ProcessorChargeClassificationSummary {
632        transitioned,
633        skipped_locked,
634        remaining_pending: count_pending_processor_charges(pool, gateway_account_id).await?,
635    })
636}
637
638#[cfg(test)]
639mod tests;