Skip to main content

syrup_rail_postgres/
renewal.rs

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
12// SQL composition contract: the head uses $4/$10/$12 and opens
13// eligible_subscriptions; the body uses $1-$3/$5-$12 and closes it. The shared
14// local expirable-status policy is $12. Filtered queries add an unconditional
15// mode predicate at $13. Continuations then add their keyset predicate, and the
16// final placeholder is always LIMIT.
17const 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
132/// Returns the first deterministic, provider-neutral renewal dispatch page.
133///
134/// This compatibility wrapper keeps the historical fixed one-hundred-item
135/// first-page limit and ascending order. Hosts that need to drain one stable
136/// observed scan should use [`due_renewals_page`] and retain its cursor.
137pub 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
143/// Returns the first deterministic renewal page for one deployment mode.
144///
145/// Filtering happens in PostgreSQL before the fixed page limit.
146pub 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
157/// Returns one deterministic page of renewal work due in a stable scan.
158///
159/// On the first page PostgreSQL supplies one observed timestamp. Every
160/// continuation preserves it for every time-dependent eligibility gate, while
161/// a strict ascending `(next_payment_attempt_at, subscription_id)` key avoids
162/// offset and timestamp-tie gaps or repeats for unchanged candidates. This is
163/// not a cross-page MVCC snapshot: concurrently inserted, retimed, or
164/// unblocked candidates behind the continuation key wait for a fresh scan.
165/// Nor is it a dispatch lease: hosts own queue/outbox persistence and the
166/// eventual renewal operation revalidates mutable state.
167pub 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
174/// Returns one deterministic page of renewal work for one deployment mode.
175///
176/// Use this from a mode-specific scheduler so other-mode subscriptions do not
177/// consume the fixed page limit. The returned cursor records
178/// `required_gateway_account_mode`; passing it to another mode or an all-mode
179/// scan returns [`RenewalStoreError::CursorModeMismatch`]. A central router
180/// serving both modes can use [`due_renewals_page`] and route each dispatch by
181/// its durable mode.
182pub 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
287/// Computes the one shared renewal/recovery period ledger state.
288pub 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;