syrup_rail_postgres/
host_charge_reconciliation.rs1use chrono::{DateTime, Utc};
2use sqlx::PgPool;
3use syrup_rail::{
4 BillingScopeId, GatewayAccountId, HostChargeTargetId, HostChargeTargetTransition,
5 HostChargeTargetTransitionKind, PaymentAttemptId, PaymentAttemptKind, SubscriberId,
6};
7use uuid::Uuid;
8
9use crate::{
10 attempts::LocalAttemptPolicy,
11 enrollment_application::set_application_timeouts,
12 host_charge_application::HostChargeApplicationError,
13 host_charges::HostChargeTargetStore,
14 reconciliation::{RECONCILIATION_CLAIM_RETRY_AFTER_SECONDS, RECONCILIATION_PHASE_BATCH_SIZE},
15};
16
17const STALE_UNSUBMITTED_HOST_CHARGE_TEXT: &str =
18 "Host charge was abandoned before gateway submission.";
19
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
28pub struct StaleHostChargeCleanupSummary {
29 failed: u64,
30 skipped: u64,
31}
32
33impl StaleHostChargeCleanupSummary {
34 pub const fn failed(self) -> u64 {
36 self.failed
37 }
38
39 pub const fn skipped(self) -> u64 {
41 self.skipped
42 }
43}
44
45#[derive(Clone, Copy)]
46struct StaleHostChargeCandidate {
47 attempt_id: Uuid,
48 billing_scope_id: Uuid,
49 subscriber_id: Uuid,
50 target_id: Uuid,
51}
52
53pub async fn fail_stale_unsubmitted_host_charges(
65 pool: &PgPool,
66 targets: &dyn HostChargeTargetStore,
67 gateway_account_id: GatewayAccountId,
68) -> Result<StaleHostChargeCleanupSummary, HostChargeApplicationError> {
69 let policy = LocalAttemptPolicy::for_kind(PaymentAttemptKind::HostCharge);
70 let candidates = claim_stale_host_charge_candidates(pool, gateway_account_id).await?;
71
72 let mut summary = StaleHostChargeCleanupSummary::default();
73 for candidate in candidates {
74 let mut transaction = pool.begin().await?;
75 set_application_timeouts(&mut transaction).await?;
76 let effective_at: DateTime<Utc> = sqlx::query_scalar("SELECT clock_timestamp()")
77 .fetch_one(&mut *transaction)
78 .await?;
79 let target_outcome = targets
80 .apply_transition(
81 &mut transaction,
82 HostChargeTargetTransition::new(
83 BillingScopeId::new(candidate.billing_scope_id),
84 SubscriberId::new(candidate.subscriber_id),
85 PaymentAttemptId::new(candidate.attempt_id),
86 HostChargeTargetId::new(candidate.target_id),
87 HostChargeTargetTransitionKind::ReleasedBeforeSubmission,
88 effective_at,
89 ),
90 )
91 .await?;
92 if !target_outcome.is_applied() {
93 tracing::warn!(
94 target: "syrup_rail::host_charge_reconciliation",
95 billing_scope_id = %candidate.billing_scope_id,
96 subscriber_id = %candidate.subscriber_id,
97 attempt_id = %candidate.attempt_id,
98 target_id = %candidate.target_id,
99 ?target_outcome,
100 "host target refused stale unsubmitted charge release; leaving attempt unresolved"
101 );
102 transaction.rollback().await?;
103 summary.skipped += 1;
104 continue;
105 }
106
107 let result = sqlx::query(
108 r#"
109 UPDATE billing_payment_attempts
110 SET status = 'failed',
111 gateway_response_text = $6,
112 gateway_condition = COALESCE(gateway_condition, 'failed'),
113 resolved_at = COALESCE(resolved_at, clock_timestamp()),
114 updated_at = clock_timestamp()
115 WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
116 AND host_charge_target_id = $4 AND gateway_account_id = $5
117 AND attempt_kind = 'host_charge'
118 AND status = ANY($7::text[])
119 AND submitted_at IS NULL
120 AND created_at <= clock_timestamp()
121 - ($8::bigint * interval '1 second')
122 "#,
123 )
124 .bind(candidate.attempt_id)
125 .bind(candidate.billing_scope_id)
126 .bind(candidate.subscriber_id)
127 .bind(candidate.target_id)
128 .bind(gateway_account_id.as_uuid())
129 .bind(STALE_UNSUBMITTED_HOST_CHARGE_TEXT)
130 .bind(LocalAttemptPolicy::expirable_status_values())
131 .bind(policy.stale_after_seconds())
132 .execute(&mut *transaction)
133 .await;
134 let result = match result {
135 Ok(result) => result,
136 Err(error) if is_lock_not_available(&error) => {
137 transaction.rollback().await?;
138 summary.skipped += 1;
139 continue;
140 }
141 Err(error) => return Err(error.into()),
142 };
143 if result.rows_affected() == 0 {
144 transaction.rollback().await?;
145 summary.skipped += 1;
146 continue;
147 }
148 transaction.commit().await?;
149 summary.failed += 1;
150 }
151 Ok(summary)
152}
153
154async fn claim_stale_host_charge_candidates(
155 pool: &PgPool,
156 gateway_account_id: GatewayAccountId,
157) -> Result<Vec<StaleHostChargeCandidate>, sqlx::Error> {
158 let policy = LocalAttemptPolicy::for_kind(PaymentAttemptKind::HostCharge);
159 let mut transaction = pool.begin().await?;
160 set_application_timeouts(&mut transaction).await?;
161 let candidates = sqlx::query_as::<_, (Uuid, Uuid, Uuid, Uuid)>(
162 r#"
163 WITH candidate_attempts AS MATERIALIZED (
164 SELECT attempts.id AS attempt_id,
165 attempts.billing_scope_id,
166 attempts.subscriber_id,
167 attempts.host_charge_target_id AS target_id,
168 attempts.created_at,
169 attempts.updated_at AS claimed_order_at
170 FROM billing_payment_attempts AS attempts
171 WHERE attempts.gateway_account_id = $1
172 AND attempts.attempt_kind = 'host_charge'
173 AND attempts.status = ANY($2::text[])
174 AND attempts.submitted_at IS NULL
175 AND attempts.created_at <= clock_timestamp()
176 - ($3::bigint * interval '1 second')
177 AND attempts.updated_at <= clock_timestamp()
178 - ($4::bigint * interval '1 second')
179 ORDER BY attempts.updated_at, attempts.created_at, attempts.id
180 LIMIT $5
181 FOR UPDATE OF attempts SKIP LOCKED
182 ), claimed_attempts AS (
183 UPDATE billing_payment_attempts AS attempts
184 SET updated_at = clock_timestamp()
185 FROM candidate_attempts
186 WHERE attempts.id = candidate_attempts.attempt_id
187 RETURNING attempts.id
188 )
189 SELECT candidate_attempts.attempt_id,
190 candidate_attempts.billing_scope_id,
191 candidate_attempts.subscriber_id,
192 candidate_attempts.target_id
193 FROM candidate_attempts
194 INNER JOIN claimed_attempts
195 ON claimed_attempts.id = candidate_attempts.attempt_id
196 ORDER BY candidate_attempts.claimed_order_at,
197 candidate_attempts.created_at,
198 candidate_attempts.attempt_id
199 "#,
200 )
201 .bind(gateway_account_id.as_uuid())
202 .bind(LocalAttemptPolicy::expirable_status_values())
203 .bind(policy.stale_after_seconds())
204 .bind(RECONCILIATION_CLAIM_RETRY_AFTER_SECONDS)
205 .bind(RECONCILIATION_PHASE_BATCH_SIZE)
206 .fetch_all(&mut *transaction)
207 .await?
208 .into_iter()
209 .map(
210 |(attempt_id, billing_scope_id, subscriber_id, target_id)| StaleHostChargeCandidate {
211 attempt_id,
212 billing_scope_id,
213 subscriber_id,
214 target_id,
215 },
216 )
217 .collect::<Vec<_>>();
218 transaction.commit().await?;
219 Ok(candidates)
220}
221
222fn is_lock_not_available(error: &sqlx::Error) -> bool {
223 matches!(
224 error,
225 sqlx::Error::Database(error) if error.code().as_deref() == Some("55P03")
226 )
227}
228
229#[cfg(test)]
230mod tests;