Skip to main content

syrup_rail_postgres/
cancellation.rs

1use chrono::{DateTime, Utc};
2use sqlx::{PgConnection, Postgres, Row, Transaction};
3use syrup_rail::{
4    BillingEvent, CancelSubscription, CancelSubscriptionOutcome, PastDueAccessPolicy,
5    PaymentAttemptKind, PlanKey, Subscription, SubscriptionId, SubscriptionStatus,
6};
7use thiserror::Error;
8use uuid::Uuid;
9
10use crate::{
11    attempts::{
12        LocalAttemptPolicy, blocking_payment_method_update_exists,
13        fail_stale_unsubmitted_subscription_charges,
14    },
15    renewal_failure::{RenewalFailureStoreError, past_due_causal_history},
16    subscription_persistence::{
17        SubscriptionPersistenceCodecError, subscription_from_row as decode_subscription_row,
18    },
19};
20
21const BILLING_ROW_LOCK_TIMEOUT: &str = "250ms";
22const CURRENT_SUBSCRIPTION_LOCK_MAX_ATTEMPTS: usize = 2;
23const UNSUBMITTED_PAYMENT_METHOD_UPDATE_FAILED_RESPONSE_TEXT: &str =
24    "Payment method update was abandoned before gateway submission.";
25const INVALID_SUBSCRIPTION_STATE: &str = "canonical subscription state is invalid";
26const UNSTABLE_CURRENT_SUBSCRIPTION: &str =
27    "current subscription ranking did not stabilize while acquiring the row lock";
28
29#[derive(Debug, Error)]
30pub enum SubscriptionCancellationError {
31    #[error("subscription cancellation failed")]
32    Sql(#[from] sqlx::Error),
33    #[error("{0}")]
34    InvalidState(&'static str),
35}
36
37impl From<RenewalFailureStoreError> for SubscriptionCancellationError {
38    fn from(error: RenewalFailureStoreError) -> Self {
39        match error {
40            RenewalFailureStoreError::Sql(error) => Self::Sql(error),
41            RenewalFailureStoreError::Attempt(_) | RenewalFailureStoreError::InvalidState(_) => {
42                Self::InvalidState(INVALID_SUBSCRIPTION_STATE)
43            }
44        }
45    }
46}
47
48fn map_subscription_persistence_error(
49    error: SubscriptionPersistenceCodecError,
50) -> SubscriptionCancellationError {
51    match error {
52        SubscriptionPersistenceCodecError::RowRead(error) => {
53            SubscriptionCancellationError::Sql(error)
54        }
55        SubscriptionPersistenceCodecError::InvalidState => {
56            SubscriptionCancellationError::InvalidState(INVALID_SUBSCRIPTION_STATE)
57        }
58    }
59}
60
61/// Cancels one exact scope/subscriber/plan subscription inside the caller's transaction.
62///
63/// The caller must acquire any host recipient lock before invoking this operation and append the
64/// returned event before committing. Cancellation never changes the stored payment method.
65/// Active and past-due subscriptions return `Canceled` after their in-flight fences pass. The
66/// newest exact canceled lifecycle returns `AlreadyCanceled`, including after paid-through access
67/// expires. A newest terminal `Unpaid` lifecycle and an owner/plan with no subscription both return
68/// `NotFound`; `NotFound` therefore means that no cancelable lifecycle exists, not necessarily that
69/// no financial history exists. A past-due row must have either qualifying automatic-renewal
70/// failure history or the operator-reviewed, manually failed active-snapshot recovery that version
71/// 1 could use to enter `past_due`; cancellation never fabricates a financial timestamp to repair
72/// corrupt causal history.
73pub async fn cancel_subscription_in_transaction(
74    transaction: &mut Transaction<'_, Postgres>,
75    command: &CancelSubscription,
76) -> Result<CancelSubscriptionOutcome, SubscriptionCancellationError> {
77    cancel_subscription_on_connection(transaction, command).await
78}
79
80/// Executes cancellation on a connection that is already inside the caller's
81/// transaction.
82///
83/// This is crate-visible for the host-prepared billing transaction facade.
84/// The caller owns transaction completion and must append a returned event
85/// before committing.
86pub(crate) async fn cancel_subscription_on_connection(
87    connection: &mut PgConnection,
88    command: &CancelSubscription,
89) -> Result<CancelSubscriptionOutcome, SubscriptionCancellationError> {
90    set_lock_timeout(connection).await?;
91    lock_subscription_aggregate(
92        connection,
93        command.subscriber_id().as_uuid(),
94        command.plan_key(),
95    )
96    .await?;
97
98    let Some(subscription) = current_subscription(connection, command).await? else {
99        return Ok(CancelSubscriptionOutcome::NotFound);
100    };
101    match subscription.status() {
102        SubscriptionStatus::Canceled => {
103            Ok(CancelSubscriptionOutcome::AlreadyCanceled(subscription))
104        }
105        SubscriptionStatus::Active | SubscriptionStatus::PastDue => {
106            fail_stale_unsubmitted_subscription_charges(connection, subscription.id()).await?;
107            if has_blocking_renewal(connection, &subscription).await? {
108                return Ok(CancelSubscriptionOutcome::BlockedByRenewal);
109            }
110            expire_stale_payment_method_updates(connection, subscription.id()).await?;
111            if blocking_payment_method_update_exists(connection, subscription.id()).await? {
112                return Ok(CancelSubscriptionOutcome::BlockedByPaymentMethodUpdate);
113            }
114
115            let prior_status = subscription.status();
116            let access_ends_at_before_cancel = match prior_status {
117                SubscriptionStatus::Active => Some(*subscription.current_period().end_at()),
118                SubscriptionStatus::PastDue
119                    if subscription.renewal_failure().past_due_access()
120                        == PastDueAccessPolicy::ContinueUntilDunningExhausted
121                        && subscription.next_payment_attempt_at().is_some() =>
122                {
123                    None
124                }
125                SubscriptionStatus::PastDue => {
126                    let history = past_due_causal_history(
127                        connection,
128                        subscription.id(),
129                        *subscription.next_renewal_at(),
130                    )
131                    .await?;
132                    Some(
133                        history
134                            .access_ended_at(subscription.renewal_failure().past_due_access())
135                            .ok_or(SubscriptionCancellationError::InvalidState(
136                                INVALID_SUBSCRIPTION_STATE,
137                            ))?,
138                    )
139                }
140                SubscriptionStatus::Canceled | SubscriptionStatus::Unpaid => {
141                    return Err(SubscriptionCancellationError::InvalidState(
142                        INVALID_SUBSCRIPTION_STATE,
143                    ));
144                }
145            };
146            let (subscription, canceled_at) =
147                cancel_current_subscription(connection, command, subscription.id(), prior_status)
148                    .await?
149                    .ok_or(SubscriptionCancellationError::InvalidState(
150                        INVALID_SUBSCRIPTION_STATE,
151                    ))?;
152            let access_ends_at = access_ends_at_before_cancel.unwrap_or(canceled_at);
153            let event = BillingEvent::SubscriptionCanceled {
154                subscription_id: subscription.id(),
155                plan_key: subscription.plan_key().clone(),
156                access_ends_at,
157            };
158            Ok(CancelSubscriptionOutcome::Canceled {
159                subscription,
160                event,
161            })
162        }
163        SubscriptionStatus::Unpaid => Ok(CancelSubscriptionOutcome::NotFound),
164    }
165}
166
167async fn set_lock_timeout(connection: &mut PgConnection) -> Result<(), sqlx::Error> {
168    sqlx::query("SELECT set_config('lock_timeout', $1, true)")
169        .bind(BILLING_ROW_LOCK_TIMEOUT)
170        .execute(connection)
171        .await?;
172    Ok(())
173}
174
175async fn lock_subscription_aggregate(
176    connection: &mut PgConnection,
177    subscriber_id: &Uuid,
178    plan_key: &PlanKey,
179) -> Result<(), sqlx::Error> {
180    sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':' || $2, 0))")
181        .bind(subscriber_id)
182        .bind(plan_key.as_str())
183        .execute(connection)
184        .await?;
185    Ok(())
186}
187
188async fn current_subscription(
189    connection: &mut PgConnection,
190    command: &CancelSubscription,
191) -> Result<Option<Subscription>, SubscriptionCancellationError> {
192    for attempt in 0..CURRENT_SUBSCRIPTION_LOCK_MAX_ATTEMPTS {
193        let Some(candidate_id) = selected_subscription_id(connection, command).await? else {
194            return Ok(None);
195        };
196        let row = sqlx::query(
197            r#"
198            SELECT id, plan_key, status, payment_method_id, amount_cents, currency,
199                current_period_start_at, current_period_end_at, next_renewal_at,
200                phase, recurring_period_kind, recurring_period_count,
201                dunning_retry_delays_seconds, dunning_exhaustion, past_due_access,
202                next_payment_attempt_at, required_gateway_account_mode
203            FROM billing_subscriptions
204            WHERE id = $1
205                AND billing_scope_id = $2
206                AND subscriber_id = $3
207                AND plan_key = $4
208            FOR NO KEY UPDATE
209            "#,
210        )
211        .bind(candidate_id)
212        .bind(command.billing_scope_id().as_uuid())
213        .bind(command.subscriber_id().as_uuid())
214        .bind(command.plan_key().as_str())
215        .fetch_optional(&mut *connection)
216        .await?;
217        let Some(row) = row else {
218            require_stabilization_retry(attempt)?;
219            continue;
220        };
221        match selected_subscription_id(connection, command).await? {
222            Some(current_id) if current_id == candidate_id => {
223                return decode_subscription_row(&row)
224                    .map_err(map_subscription_persistence_error)
225                    .map(Some);
226            }
227            Some(_) => require_stabilization_retry(attempt)?,
228            None => return Ok(None),
229        }
230    }
231    Err(SubscriptionCancellationError::InvalidState(
232        UNSTABLE_CURRENT_SUBSCRIPTION,
233    ))
234}
235
236fn require_stabilization_retry(attempt: usize) -> Result<(), SubscriptionCancellationError> {
237    if attempt + 1 < CURRENT_SUBSCRIPTION_LOCK_MAX_ATTEMPTS {
238        Ok(())
239    } else {
240        Err(SubscriptionCancellationError::InvalidState(
241            UNSTABLE_CURRENT_SUBSCRIPTION,
242        ))
243    }
244}
245
246async fn current_subscription_id(
247    connection: &mut PgConnection,
248    command: &CancelSubscription,
249) -> Result<Option<Uuid>, sqlx::Error> {
250    sqlx::query_scalar(
251        r#"
252        SELECT id
253        FROM billing_current_subscriptions
254        WHERE billing_scope_id = $1
255            AND subscriber_id = $2
256            AND plan_key = $3
257        ORDER BY current_subscription_rank, updated_at DESC, id DESC
258        LIMIT 1
259        "#,
260    )
261    .bind(command.billing_scope_id().as_uuid())
262    .bind(command.subscriber_id().as_uuid())
263    .bind(command.plan_key().as_str())
264    .fetch_optional(&mut *connection)
265    .await
266}
267
268async fn selected_subscription_id(
269    connection: &mut PgConnection,
270    command: &CancelSubscription,
271) -> Result<Option<Uuid>, sqlx::Error> {
272    if let Some(current) = current_subscription_id(connection, command).await? {
273        return Ok(Some(current));
274    }
275    sqlx::query_scalar(
276        r#"
277        SELECT id
278        FROM billing_subscriptions
279        WHERE billing_scope_id = $1
280            AND subscriber_id = $2
281            AND plan_key = $3
282        ORDER BY created_at DESC, id DESC
283        LIMIT 1
284        "#,
285    )
286    .bind(command.billing_scope_id().as_uuid())
287    .bind(command.subscriber_id().as_uuid())
288    .bind(command.plan_key().as_str())
289    .fetch_optional(&mut *connection)
290    .await
291}
292
293async fn has_blocking_renewal(
294    connection: &mut PgConnection,
295    subscription: &Subscription,
296) -> Result<bool, sqlx::Error> {
297    sqlx::query_scalar(
298        r#"
299        SELECT EXISTS (
300            SELECT 1
301            FROM billing_payment_attempts
302            WHERE subscription_id = $1
303                AND attempt_kind IN ('subscription_renewal', 'subscription_recovery')
304                AND billing_period_start_at = $2
305                AND status IN ('pending', 'unknown', 'review_required', 'approved')
306        )
307        "#,
308    )
309    .bind(subscription.id().as_uuid())
310    .bind(subscription.next_renewal_at())
311    .fetch_one(&mut *connection)
312    .await
313}
314
315async fn expire_stale_payment_method_updates(
316    connection: &mut PgConnection,
317    subscription_id: SubscriptionId,
318) -> Result<(), sqlx::Error> {
319    let policy = LocalAttemptPolicy::for_kind(PaymentAttemptKind::SubscriptionPaymentMethodUpdate);
320    sqlx::query(
321        r#"
322        WITH stale_attempts AS (
323            SELECT id
324            FROM billing_payment_attempts
325            WHERE subscription_id = $1
326                AND attempt_kind = 'subscription_payment_method_update'
327                AND status = ANY($2::text[])
328                AND submitted_at IS NULL
329                AND created_at <= now() - ($3::bigint * interval '1 second')
330            FOR UPDATE SKIP LOCKED
331        )
332        UPDATE billing_payment_attempts attempts
333        SET status = 'failed',
334            gateway_response_text = COALESCE(gateway_response_text, $4),
335            gateway_condition = COALESCE(gateway_condition, 'failed'),
336            resolved_at = now(),
337            updated_at = now()
338        FROM stale_attempts
339        WHERE attempts.id = stale_attempts.id
340        "#,
341    )
342    .bind(subscription_id.as_uuid())
343    .bind(LocalAttemptPolicy::expirable_status_values())
344    .bind(policy.stale_after_seconds())
345    .bind(UNSUBMITTED_PAYMENT_METHOD_UPDATE_FAILED_RESPONSE_TEXT)
346    .execute(connection)
347    .await?;
348    Ok(())
349}
350
351async fn cancel_current_subscription(
352    connection: &mut PgConnection,
353    command: &CancelSubscription,
354    subscription_id: SubscriptionId,
355    expected_status: SubscriptionStatus,
356) -> Result<Option<(Subscription, DateTime<Utc>)>, SubscriptionCancellationError> {
357    let row = sqlx::query(
358        r#"
359        UPDATE billing_subscriptions
360        SET status = 'canceled',
361            canceled_at = now(),
362            next_payment_attempt_at = NULL,
363            updated_at = now()
364        WHERE id = $1
365            AND billing_scope_id = $2
366            AND subscriber_id = $3
367            AND plan_key = $4
368            AND status = $5
369        RETURNING id, plan_key, status, payment_method_id, amount_cents, currency,
370            current_period_start_at, current_period_end_at, next_renewal_at,
371            phase, recurring_period_kind, recurring_period_count,
372            dunning_retry_delays_seconds, dunning_exhaustion, past_due_access,
373            next_payment_attempt_at, required_gateway_account_mode, canceled_at
374        "#,
375    )
376    .bind(subscription_id.as_uuid())
377    .bind(command.billing_scope_id().as_uuid())
378    .bind(command.subscriber_id().as_uuid())
379    .bind(command.plan_key().as_str())
380    .bind(expected_status.as_str())
381    .fetch_optional(&mut *connection)
382    .await?;
383    row.as_ref()
384        .map(|row| {
385            Ok((
386                decode_subscription_row(row).map_err(map_subscription_persistence_error)?,
387                row.try_get::<DateTime<Utc>, _>("canceled_at")?,
388            ))
389        })
390        .transpose()
391}
392
393#[cfg(test)]
394mod tests;