1use chrono::{DateTime, Utc};
2use sqlx::{PgPool, Postgres, Row, Transaction, postgres::PgArguments, query::Query};
3use syrup_rail::{
4 BillingScopeId, GatewayAccountMode, PaymentAttemptId, PaymentAttemptKind,
5 PaymentResolutionCode, RenewalAttemptState, RenewalDispatch, RenewalDispatchPage,
6 RenewalDispatchPageCursor, SubscriptionId,
7};
8use thiserror::Error;
9
10use crate::attempts::LocalAttemptPolicy;
11
12const DUE_RENEWALS_ALL_FIRST_PAGE_SQL: &str = concat!(
18 include_str!("renewal/due_renewals_page_head.sql"),
19 include_str!("renewal/due_renewals_page_body.sql"),
20 "LIMIT $13\n"
21);
22const DUE_RENEWALS_ALL_CONTINUATION_SQL: &str = concat!(
23 include_str!("renewal/due_renewals_page_head.sql"),
24 " AND (subscriptions.next_payment_attempt_at, subscriptions.id)\n",
25 " > ($13::timestamptz, $14::uuid)\n",
26 include_str!("renewal/due_renewals_page_body.sql"),
27 "LIMIT $15\n"
28);
29const DUE_RENEWALS_MODE_FIRST_PAGE_SQL: &str = concat!(
30 include_str!("renewal/due_renewals_page_head.sql"),
31 " AND subscriptions.required_gateway_account_mode = $13::text\n",
32 include_str!("renewal/due_renewals_page_body.sql"),
33 "LIMIT $14\n"
34);
35const DUE_RENEWALS_MODE_CONTINUATION_SQL: &str = concat!(
36 include_str!("renewal/due_renewals_page_head.sql"),
37 " AND subscriptions.required_gateway_account_mode = $13::text\n",
38 " AND (subscriptions.next_payment_attempt_at, subscriptions.id)\n",
39 " > ($14::timestamptz, $15::uuid)\n",
40 include_str!("renewal/due_renewals_page_body.sql"),
41 "LIMIT $16\n"
42);
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45enum DueRenewalPageQuery {
46 First(DateTime<Utc>),
47 Continuation(RenewalDispatchPageCursor),
48}
49
50impl DueRenewalPageQuery {
51 async fn load(
52 pool: &PgPool,
53 cursor: Option<&RenewalDispatchPageCursor>,
54 ) -> Result<Self, sqlx::Error> {
55 match cursor {
56 Some(cursor) => Ok(Self::Continuation(*cursor)),
57 None => sqlx::query_scalar::<_, DateTime<Utc>>("SELECT clock_timestamp()")
58 .fetch_one(pool)
59 .await
60 .map(Self::First),
61 }
62 }
63
64 const fn sql(self, required_mode: Option<GatewayAccountMode>) -> &'static str {
65 match (self, required_mode) {
66 (Self::First(_), None) => DUE_RENEWALS_ALL_FIRST_PAGE_SQL,
67 (Self::Continuation(_), None) => DUE_RENEWALS_ALL_CONTINUATION_SQL,
68 (Self::First(_), Some(_)) => DUE_RENEWALS_MODE_FIRST_PAGE_SQL,
69 (Self::Continuation(_), Some(_)) => DUE_RENEWALS_MODE_CONTINUATION_SQL,
70 }
71 }
72
73 const fn observed_at(self) -> DateTime<Utc> {
74 match self {
75 Self::First(observed_at) => observed_at,
76 Self::Continuation(cursor) => cursor.observed_at(),
77 }
78 }
79
80 fn bind<'args>(
81 self,
82 infrastructure_retry_codes: &'args [&'static str],
83 infrastructure_pacing_codes: &'args [&'static str],
84 rate_limit_pacing_codes: &'args [&'static str],
85 required_gateway_account_mode: Option<GatewayAccountMode>,
86 ) -> Query<'args, Postgres, PgArguments> {
87 let payment_method_update_policy =
88 LocalAttemptPolicy::for_kind(PaymentAttemptKind::SubscriptionPaymentMethodUpdate);
89 let subscription_charge_policy =
90 LocalAttemptPolicy::for_kind(PaymentAttemptKind::SubscriptionRenewal);
91 let query = sqlx::query(self.sql(required_gateway_account_mode))
92 .bind(infrastructure_retry_codes)
93 .bind(infrastructure_pacing_codes)
94 .bind(rate_limit_pacing_codes)
95 .bind(payment_method_update_policy.stale_after_seconds())
96 .bind(syrup_rail::MAX_RENEWAL_INFRASTRUCTURE_ATTEMPTS_PER_PERIOD_CONFIGURATION)
97 .bind(syrup_rail::RENEWAL_INFRASTRUCTURE_RETRY_AFTER_SECONDS)
98 .bind(syrup_rail::RENEWAL_RATE_LIMIT_FAST_RETRY_ATTEMPTS)
99 .bind(syrup_rail::RENEWAL_RATE_LIMIT_SLOW_RETRY_AFTER_SECONDS)
100 .bind(syrup_rail::GATEWAY_MUTATION_RATE_LIMIT_RETRY_AFTER_SECONDS)
101 .bind(self.observed_at())
102 .bind(subscription_charge_policy.stale_after_seconds())
103 .bind(LocalAttemptPolicy::expirable_status_values());
104 match (required_gateway_account_mode, self) {
105 (None, Self::First(_)) => query.bind(syrup_rail::RENEWAL_DISPATCH_LIMIT + 1),
106 (None, Self::Continuation(cursor)) => query
107 .bind(cursor.next_payment_attempt_at())
108 .bind(cursor.subscription_id().into_uuid())
109 .bind(syrup_rail::RENEWAL_DISPATCH_LIMIT + 1),
110 (Some(mode), Self::First(_)) => query
111 .bind(mode.as_str())
112 .bind(syrup_rail::RENEWAL_DISPATCH_LIMIT + 1),
113 (Some(mode), Self::Continuation(cursor)) => query
114 .bind(mode.as_str())
115 .bind(cursor.next_payment_attempt_at())
116 .bind(cursor.subscription_id().into_uuid())
117 .bind(syrup_rail::RENEWAL_DISPATCH_LIMIT + 1),
118 }
119 }
120}
121
122#[derive(Debug, Error)]
123pub enum RenewalStoreError {
124 #[error("renewal storage operation failed")]
125 Sql(#[from] sqlx::Error),
126 #[error("a gateway provider has no canonical cooldown row")]
127 MissingProviderCooldown,
128 #[error("renewal page cursor belongs to a different gateway account mode scan")]
129 CursorModeMismatch,
130}
131
132pub async fn due_renewals(pool: &PgPool) -> Result<Vec<RenewalDispatch>, RenewalStoreError> {
138 Ok(load_due_renewals_page(pool, None, None)
139 .await?
140 .into_dispatches())
141}
142
143pub async fn due_renewals_for_mode(
147 pool: &PgPool,
148 required_gateway_account_mode: GatewayAccountMode,
149) -> Result<Vec<RenewalDispatch>, RenewalStoreError> {
150 Ok(
151 load_due_renewals_page(pool, None, Some(required_gateway_account_mode))
152 .await?
153 .into_dispatches(),
154 )
155}
156
157pub async fn due_renewals_page(
168 pool: &PgPool,
169 cursor: Option<&RenewalDispatchPageCursor>,
170) -> Result<RenewalDispatchPage, RenewalStoreError> {
171 load_due_renewals_page(pool, cursor, None).await
172}
173
174pub async fn due_renewals_page_for_mode(
183 pool: &PgPool,
184 required_gateway_account_mode: GatewayAccountMode,
185 cursor: Option<&RenewalDispatchPageCursor>,
186) -> Result<RenewalDispatchPage, RenewalStoreError> {
187 load_due_renewals_page(pool, cursor, Some(required_gateway_account_mode)).await
188}
189
190async fn load_due_renewals_page(
191 pool: &PgPool,
192 cursor: Option<&RenewalDispatchPageCursor>,
193 required_gateway_account_mode: Option<GatewayAccountMode>,
194) -> Result<RenewalDispatchPage, RenewalStoreError> {
195 if cursor.is_some_and(|cursor| {
196 cursor.required_gateway_account_mode() != required_gateway_account_mode
197 }) {
198 return Err(RenewalStoreError::CursorModeMismatch);
199 }
200 let page_query = DueRenewalPageQuery::load(pool, cursor).await?;
201 let observed_at = page_query.observed_at();
202 let missing_provider_cooldown = sqlx::query_scalar::<_, bool>(
203 r#"
204 SELECT EXISTS (
205 SELECT 1
206 FROM billing_gateway_accounts AS accounts
207 LEFT JOIN billing_gateway_provider_rate_limits AS provider_limits
208 ON provider_limits.provider_key = accounts.provider_key
209 WHERE provider_limits.provider_key IS NULL
210 )
211 "#,
212 )
213 .fetch_one(pool)
214 .await?;
215 if missing_provider_cooldown {
216 return Err(RenewalStoreError::MissingProviderCooldown);
217 }
218
219 let infrastructure_retry_codes =
220 resolution_strings(PaymentResolutionCode::RENEWAL_INFRASTRUCTURE_RETRY_CODES);
221 let infrastructure_pacing_codes =
222 resolution_strings(PaymentResolutionCode::RENEWAL_INFRASTRUCTURE_PACING_CODES);
223 let rate_limit_pacing_codes =
224 resolution_strings(PaymentResolutionCode::RENEWAL_RATE_LIMIT_PACING_CODES);
225 let rows = page_query
226 .bind(
227 &infrastructure_retry_codes,
228 &infrastructure_pacing_codes,
229 &rate_limit_pacing_codes,
230 required_gateway_account_mode,
231 )
232 .fetch_all(pool)
233 .await?;
234 let mut candidates = rows
235 .iter()
236 .map(|row| {
237 let subscription_id = SubscriptionId::new(row.try_get("id")?);
238 Ok(DueRenewalCandidate {
239 dispatch: RenewalDispatch::new(
240 BillingScopeId::new(row.try_get("billing_scope_id")?),
241 subscription_id,
242 row.try_get::<String, _>("required_gateway_account_mode")?
243 .parse::<GatewayAccountMode>()
244 .map_err(|_| sqlx::Error::ColumnDecode {
245 index: "required_gateway_account_mode".to_owned(),
246 source: Box::new(std::io::Error::new(
247 std::io::ErrorKind::InvalidData,
248 "invalid gateway account mode",
249 )),
250 })?,
251 row.try_get("next_renewal_at")?,
252 row.try_get("attempt_sequence_count")?,
253 ),
254 next_payment_attempt_at: row.try_get("next_payment_attempt_at")?,
255 })
256 })
257 .collect::<Result<Vec<_>, sqlx::Error>>()?;
258 let has_more = candidates.len() > syrup_rail::RENEWAL_DISPATCH_LIMIT as usize;
259 if has_more {
260 candidates.pop();
261 }
262 let next_cursor = has_more.then(|| {
263 let last = candidates
264 .last()
265 .expect("a renewal page with an extra row always retains one item");
266 RenewalDispatchPageCursor::new(
267 observed_at,
268 last.next_payment_attempt_at,
269 last.dispatch.subscription_id(),
270 required_gateway_account_mode,
271 )
272 });
273 Ok(RenewalDispatchPage::new(
274 candidates
275 .into_iter()
276 .map(|candidate| candidate.dispatch)
277 .collect(),
278 next_cursor,
279 ))
280}
281
282struct DueRenewalCandidate {
283 dispatch: RenewalDispatch,
284 next_payment_attempt_at: DateTime<Utc>,
285}
286
287pub async fn renewal_attempt_state(
289 transaction: &mut Transaction<'_, Postgres>,
290 subscription_id: SubscriptionId,
291 period_start_at: DateTime<Utc>,
292 excluded_attempt_id: Option<PaymentAttemptId>,
293) -> Result<RenewalAttemptState, RenewalStoreError> {
294 let policy = LocalAttemptPolicy::for_kind(PaymentAttemptKind::SubscriptionRenewal);
295 let infrastructure_retry_codes =
296 resolution_strings(PaymentResolutionCode::RENEWAL_INFRASTRUCTURE_RETRY_CODES);
297 let infrastructure_pacing_codes =
298 resolution_strings(PaymentResolutionCode::RENEWAL_INFRASTRUCTURE_PACING_CODES);
299 let rate_limit_pacing_codes =
300 resolution_strings(PaymentResolutionCode::RENEWAL_RATE_LIMIT_PACING_CODES);
301 let row = sqlx::query(
302 r#"
303 SELECT
304 COUNT(*) AS attempt_sequence_count,
305 COUNT(*) FILTER (
306 WHERE attempt_kind = 'subscription_renewal'
307 AND status = 'failed'
308 AND resolution_code = ANY($4::text[])
309 AND gateway_configuration_id = (
310 SELECT accounts.gateway_configuration_id
311 FROM billing_subscriptions AS subscriptions
312 JOIN billing_gateway_accounts AS accounts
313 ON accounts.id = subscriptions.gateway_account_id
314 AND accounts.billing_scope_id = subscriptions.billing_scope_id
315 WHERE subscriptions.id = $1
316 )
317 ) AS automatic_infrastructure_attempt_count,
318 MAX(resolved_at) FILTER (
319 WHERE attempt_kind = 'subscription_renewal'
320 AND status = 'failed'
321 AND resolution_code = ANY($5::text[])
322 AND gateway_configuration_id = (
323 SELECT accounts.gateway_configuration_id
324 FROM billing_subscriptions AS subscriptions
325 JOIN billing_gateway_accounts AS accounts
326 ON accounts.id = subscriptions.gateway_account_id
327 AND accounts.billing_scope_id = subscriptions.billing_scope_id
328 WHERE subscriptions.id = $1
329 )
330 ) AS last_automatic_infrastructure_failure_at,
331 COUNT(*) FILTER (
332 WHERE attempt_kind = 'subscription_renewal'
333 AND status = 'failed'
334 AND resolution_code = ANY($6::text[])
335 ) AS rate_limited_attempt_count,
336 MAX(resolved_at) FILTER (
337 WHERE attempt_kind = 'subscription_renewal'
338 AND status = 'failed'
339 AND resolution_code = ANY($6::text[])
340 ) AS last_rate_limited_at,
341 COALESCE(
342 BOOL_OR(
343 status IN ('pending', 'unknown', 'review_required', 'approved')
344 AND NOT (
345 status = ANY($7::text[])
346 AND submitted_at IS NULL
347 AND created_at <= clock_timestamp()
348 - ($8::bigint * interval '1 second')
349 )
350 ),
351 false
352 ) AS has_blocking_attempt
353 FROM billing_payment_attempts
354 WHERE attempt_kind IN ('subscription_renewal', 'subscription_recovery')
355 AND subscription_id = $1
356 AND billing_period_start_at = $2
357 AND ($3::uuid IS NULL OR id <> $3)
358 "#,
359 )
360 .bind(subscription_id.as_uuid())
361 .bind(period_start_at)
362 .bind(excluded_attempt_id.map(PaymentAttemptId::into_uuid))
363 .bind(&infrastructure_retry_codes)
364 .bind(&infrastructure_pacing_codes)
365 .bind(&rate_limit_pacing_codes)
366 .bind(LocalAttemptPolicy::expirable_status_values())
367 .bind(policy.stale_after_seconds())
368 .fetch_one(&mut **transaction)
369 .await?;
370 Ok(RenewalAttemptState {
371 attempt_sequence_count: row.try_get("attempt_sequence_count")?,
372 automatic_infrastructure_attempt_count: row
373 .try_get("automatic_infrastructure_attempt_count")?,
374 last_automatic_infrastructure_failure_at: row
375 .try_get("last_automatic_infrastructure_failure_at")?,
376 rate_limited_attempt_count: row.try_get("rate_limited_attempt_count")?,
377 last_rate_limited_at: row.try_get("last_rate_limited_at")?,
378 has_blocking_attempt: row.try_get("has_blocking_attempt")?,
379 })
380}
381
382fn resolution_strings(codes: &[PaymentResolutionCode]) -> Vec<&'static str> {
383 codes.iter().map(|code| code.as_str()).collect()
384}
385
386#[cfg(test)]
387mod tests;