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
61pub 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
80pub(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;