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
119pub async fn reconciliation_gateway_accounts(
125 pool: &PgPool,
126) -> Result<Vec<GatewayAccountReconciliationCandidate>, sqlx::Error> {
127 let rows = sqlx::query_as::<_, (Uuid, Uuid)>(
128 r#"
129 SELECT billing_scope_id, id
130 FROM billing_gateway_accounts
131 ORDER BY billing_scope_id, id
132 "#,
133 )
134 .fetch_all(pool)
135 .await?;
136
137 Ok(rows
138 .into_iter()
139 .map(|(billing_scope_id, gateway_account_id)| {
140 GatewayAccountReconciliationCandidate::new(
141 BillingScopeId::new(billing_scope_id),
142 GatewayAccountId::new(gateway_account_id),
143 )
144 })
145 .collect())
146}
147
148pub async fn claim_exact_reconciliation_attempts(
154 pool: &PgPool,
155 gateway_account_id: GatewayAccountId,
156) -> Result<Vec<PaymentAttempt>, PaymentAttemptStoreError> {
157 let mut transaction = pool.begin().await?;
158 sqlx::query("SELECT set_config('lock_timeout', '250ms', true)")
159 .execute(&mut *transaction)
160 .await?;
161 let rows = sqlx::query(
162 r#"
163 WITH candidate_attempts AS MATERIALIZED (
164 SELECT attempts.id AS attempt_id, attempts.created_at
165 FROM billing_payment_attempts AS attempts
166 WHERE attempts.gateway_account_id = $1
167 AND (
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
242pub 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
371pub 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
418pub 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
471pub 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;