1use std::fmt;
2
3use async_trait::async_trait;
4use sqlx::{PgConnection, PgPool, Postgres, Row, Transaction, postgres::PgRow};
5use syrup_rail::{
6 BillingScopeId, ChargeAmount, CurrencyCode, DiscountClaimId, DiscountCodeId, IdempotencyKey,
7 LimitedDiscountMonths, PaymentAttemptId, PercentOffBasisPoints, PlanKey, PositiveDiscountCents,
8 SubscriberId, SubscriptionDiscountClaim, SubscriptionDiscountClaimOutcome,
9 SubscriptionDiscountClaimRecord, SubscriptionDiscountClaimStatus,
10 SubscriptionDiscountClearOutcome, SubscriptionDiscountCode, SubscriptionDiscountCodeCreation,
11 SubscriptionDiscountCodeQuote, SubscriptionDiscountCodeRecord, SubscriptionDiscountCodeStatus,
12 SubscriptionDiscountCodeUpdate, SubscriptionDiscountDuration, SubscriptionDiscountError,
13 SubscriptionDiscountKind, SubscriptionDiscountSnapshot, SubscriptionEnrollmentReservation,
14 SubscriptionId, SubscriptionOffer,
15};
16use thiserror::Error;
17use uuid::Uuid;
18
19pub use persistence::saved_subscription_discount_claim_in_transaction;
20use persistence::{
21 blocking_initial_attempt, blocking_initial_attempt_exists, claim_from_row, code_by_id,
22 code_from_row, current_subscription_exists, discount_value, duration_months,
23 expire_saved_claims_for_code, find_active_code, lock_initial_attempt_rows,
24 lock_initial_attempts, lock_offer, lock_subscription_aggregate, quote_for_offer,
25 quote_from_row, saved_subscription_discount_claim_on_connection, set_lock_timeout,
26 validate_discount_cadence,
27};
28
29mod persistence;
30
31const BILLING_ROW_LOCK_TIMEOUT: &str = "250ms";
32const BILLING_OPERATION_TIMEOUT: &str = "5s";
33const INVALID_DISCOUNT_STATE: &str = "canonical subscription discount state is invalid";
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum SubscriptionEnrollmentOfferStage {
42 Reservation,
44 SubmissionAdmission,
46}
47
48#[derive(Clone, Copy)]
57pub struct SubscriptionEnrollmentOfferContext<'a> {
58 billing_scope_id: BillingScopeId,
59 subscriber_id: SubscriberId,
60 plan_key: &'a PlanKey,
61 attempt_id: PaymentAttemptId,
62 idempotency_key: &'a IdempotencyKey,
63 stage: SubscriptionEnrollmentOfferStage,
64}
65
66impl<'a> SubscriptionEnrollmentOfferContext<'a> {
67 pub(crate) const fn from_reservation(
68 reservation: &'a SubscriptionEnrollmentReservation,
69 stage: SubscriptionEnrollmentOfferStage,
70 ) -> Self {
71 let identity = reservation.identity();
72 Self {
73 billing_scope_id: identity.billing_scope_id(),
74 subscriber_id: identity.subscriber_id(),
75 plan_key: reservation.plan_key(),
76 attempt_id: identity.attempt_id(),
77 idempotency_key: reservation.idempotency_key(),
78 stage,
79 }
80 }
81
82 pub const fn billing_scope_id(self) -> BillingScopeId {
83 self.billing_scope_id
84 }
85
86 pub const fn subscriber_id(self) -> SubscriberId {
87 self.subscriber_id
88 }
89
90 pub const fn plan_key(self) -> &'a PlanKey {
91 self.plan_key
92 }
93
94 pub const fn attempt_id(self) -> PaymentAttemptId {
95 self.attempt_id
96 }
97
98 pub const fn idempotency_key(self) -> &'a IdempotencyKey {
99 self.idempotency_key
100 }
101
102 pub const fn stage(self) -> SubscriptionEnrollmentOfferStage {
103 self.stage
104 }
105}
106
107impl fmt::Debug for SubscriptionEnrollmentOfferContext<'_> {
108 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109 formatter
110 .debug_struct("SubscriptionEnrollmentOfferContext")
111 .field("billing_scope_id", &self.billing_scope_id)
112 .field("subscriber_id", &self.subscriber_id)
113 .field("plan_key", &self.plan_key)
114 .field("attempt_id", &self.attempt_id)
115 .field("has_idempotency_key", &true)
116 .field("stage", &self.stage)
117 .finish()
118 }
119}
120
121#[derive(Error)]
122pub enum SubscriptionDiscountOperationError {
123 #[error("subscription discount storage operation failed")]
124 Sql(#[from] sqlx::Error),
125 #[error("current subscription offer is unavailable")]
126 OfferUnavailable,
127 #[error("current subscription offer does not match the requested plan")]
128 OfferPlanMismatch,
129 #[error("subscription discount configuration is invalid for the current offer")]
130 InvalidConfiguration,
131 #[error("limited-month discounts require a one-calendar-month recurring period")]
132 LimitedDiscountCadence,
133 #[error("{0}")]
134 InvalidState(&'static str),
135}
136
137impl SubscriptionDiscountOperationError {
138 pub fn is_unique_violation(&self) -> bool {
139 matches!(self, Self::Sql(sqlx::Error::Database(error)) if error.is_unique_violation())
140 }
141
142 pub fn constraint(&self) -> Option<&str> {
143 match self {
144 Self::Sql(sqlx::Error::Database(error)) => error.constraint(),
145 _ => None,
146 }
147 }
148}
149
150impl fmt::Debug for SubscriptionDiscountOperationError {
151 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152 match self {
153 Self::Sql(_) => formatter.write_str("SubscriptionDiscountOperationError::Sql"),
154 Self::OfferUnavailable => {
155 formatter.write_str("SubscriptionDiscountOperationError::OfferUnavailable")
156 }
157 Self::OfferPlanMismatch => {
158 formatter.write_str("SubscriptionDiscountOperationError::OfferPlanMismatch")
159 }
160 Self::InvalidConfiguration => {
161 formatter.write_str("SubscriptionDiscountOperationError::InvalidConfiguration")
162 }
163 Self::LimitedDiscountCadence => {
164 formatter.write_str("SubscriptionDiscountOperationError::LimitedDiscountCadence")
165 }
166 Self::InvalidState(detail) => formatter
167 .debug_tuple("SubscriptionDiscountOperationError::InvalidState")
168 .field(detail)
169 .finish(),
170 }
171 }
172}
173
174#[async_trait]
175pub trait SubscriptionOfferStore: Send + Sync {
176 async fn lock_current_offer(
182 &self,
183 connection: &mut PgConnection,
184 billing_scope_id: BillingScopeId,
185 plan_key: &PlanKey,
186 ) -> Result<Option<SubscriptionOffer>, sqlx::Error>;
187
188 async fn lock_enrollment_offer(
201 &self,
202 connection: &mut PgConnection,
203 context: SubscriptionEnrollmentOfferContext<'_>,
204 ) -> Result<Option<SubscriptionOffer>, sqlx::Error> {
205 self.lock_current_offer(connection, context.billing_scope_id(), context.plan_key())
206 .await
207 }
208}
209
210pub async fn list_subscription_discount_codes(
211 pool: &PgPool,
212 billing_scope_id: BillingScopeId,
213 plan_key: &PlanKey,
214) -> Result<Vec<SubscriptionDiscountCodeRecord>, SubscriptionDiscountOperationError> {
215 let mut transaction = pool.begin().await?;
216 let records = list_subscription_discount_codes_in_transaction(
217 &mut transaction,
218 billing_scope_id,
219 plan_key,
220 )
221 .await?;
222 transaction.commit().await?;
223 Ok(records)
224}
225
226pub async fn list_subscription_discount_codes_in_transaction(
230 transaction: &mut Transaction<'_, Postgres>,
231 billing_scope_id: BillingScopeId,
232 plan_key: &PlanKey,
233) -> Result<Vec<SubscriptionDiscountCodeRecord>, SubscriptionDiscountOperationError> {
234 set_lock_timeout(transaction).await?;
235 let rows = sqlx::query(
236 r#"
237 SELECT id, billing_scope_id, plan_key, code_normalized, display_code,
238 label, status, discount_kind, amount_off_cents, percent_off_bps,
239 currency, duration, duration_months, created_at, updated_at
240 FROM billing_subscription_discount_codes
241 WHERE billing_scope_id = $1 AND plan_key = $2
242 ORDER BY status, code_normalized, created_at DESC
243 "#,
244 )
245 .bind(billing_scope_id.as_uuid())
246 .bind(plan_key.as_str())
247 .fetch_all(&mut **transaction)
248 .await?;
249 rows.iter().map(code_from_row).collect()
250}
251
252pub async fn validate_subscription_discount_code(
253 pool: &PgPool,
254 offers: &dyn SubscriptionOfferStore,
255 billing_scope_id: BillingScopeId,
256 plan_key: &PlanKey,
257 code: &SubscriptionDiscountCode,
258) -> Result<Option<SubscriptionDiscountCodeQuote>, SubscriptionDiscountOperationError> {
259 let mut transaction = pool.begin().await?;
260 let quote = validate_subscription_discount_code_in_transaction(
261 &mut transaction,
262 offers,
263 billing_scope_id,
264 plan_key,
265 code,
266 )
267 .await?;
268 transaction.commit().await?;
269 Ok(quote)
270}
271
272pub async fn validate_subscription_discount_code_in_transaction(
273 transaction: &mut Transaction<'_, Postgres>,
274 offers: &dyn SubscriptionOfferStore,
275 billing_scope_id: BillingScopeId,
276 plan_key: &PlanKey,
277 code: &SubscriptionDiscountCode,
278) -> Result<Option<SubscriptionDiscountCodeQuote>, SubscriptionDiscountOperationError> {
279 set_lock_timeout(transaction).await?;
280 let offer = lock_offer(transaction, offers, billing_scope_id, plan_key).await?;
281 let row = find_active_code(transaction, billing_scope_id, plan_key, code, false).await?;
282 row.as_ref()
283 .map(|row| quote_from_row(row, &offer))
284 .transpose()
285}
286
287pub async fn create_subscription_discount_code_in_transaction(
291 transaction: &mut Transaction<'_, Postgres>,
292 offers: &dyn SubscriptionOfferStore,
293 creation: &SubscriptionDiscountCodeCreation,
294) -> Result<SubscriptionDiscountCodeRecord, SubscriptionDiscountOperationError> {
295 set_lock_timeout(transaction).await?;
296 let offer = lock_offer(
297 transaction,
298 offers,
299 creation.billing_scope_id(),
300 creation.plan_key(),
301 )
302 .await?;
303 validate_discount_cadence(creation.duration(), &offer)?;
304 syrup_rail::discounted_charge(
305 offer.recurring().charge(),
306 creation.currency(),
307 creation.kind(),
308 )
309 .map_err(|_| SubscriptionDiscountOperationError::InvalidConfiguration)?;
310 let (amount_off_cents, percent_off_bps) = discount_value(creation.kind());
311 let duration_months = duration_months(creation.duration());
312 sqlx::query(
313 r#"
314 INSERT INTO billing_subscription_discount_codes (
315 id, billing_scope_id, plan_key, code_normalized, display_code,
316 label, status, discount_kind, amount_off_cents, percent_off_bps,
317 currency, duration, duration_months
318 )
319 VALUES ($1, $2, $3, $4, $4, $5, 'active', $6, $7, $8, $9, $10, $11)
320 "#,
321 )
322 .bind(creation.id().as_uuid())
323 .bind(creation.billing_scope_id().as_uuid())
324 .bind(creation.plan_key().as_str())
325 .bind(creation.code().as_str())
326 .bind(creation.label())
327 .bind(creation.kind().as_str())
328 .bind(amount_off_cents)
329 .bind(percent_off_bps)
330 .bind(creation.currency().as_str())
331 .bind(creation.duration().as_str())
332 .bind(duration_months)
333 .execute(&mut **transaction)
334 .await?;
335 let row = code_by_id(
336 transaction,
337 creation.billing_scope_id(),
338 creation.plan_key(),
339 creation.id(),
340 )
341 .await?
342 .ok_or(SubscriptionDiscountOperationError::InvalidState(
343 INVALID_DISCOUNT_STATE,
344 ))?;
345 code_from_row(&row)
346}
347
348pub async fn create_subscription_discount_code(
349 pool: &PgPool,
350 offers: &dyn SubscriptionOfferStore,
351 creation: &SubscriptionDiscountCodeCreation,
352) -> Result<SubscriptionDiscountCodeRecord, SubscriptionDiscountOperationError> {
353 let mut transaction = pool.begin().await?;
354 let record =
355 create_subscription_discount_code_in_transaction(&mut transaction, offers, creation)
356 .await?;
357 transaction.commit().await?;
358 Ok(record)
359}
360
361pub async fn update_subscription_discount_code_in_transaction(
365 transaction: &mut Transaction<'_, Postgres>,
366 offers: &dyn SubscriptionOfferStore,
367 update: &SubscriptionDiscountCodeUpdate,
368) -> Result<Option<SubscriptionDiscountCodeRecord>, SubscriptionDiscountOperationError> {
369 set_lock_timeout(transaction).await?;
370 if update.status() == SubscriptionDiscountCodeStatus::Active {
371 let offer = lock_offer(
372 transaction,
373 offers,
374 update.billing_scope_id(),
375 update.plan_key(),
376 )
377 .await?;
378 validate_discount_cadence(update.duration(), &offer)?;
379 syrup_rail::discounted_charge(offer.recurring().charge(), update.currency(), update.kind())
380 .map_err(|_| SubscriptionDiscountOperationError::InvalidConfiguration)?;
381 }
382 let (amount_off_cents, percent_off_bps) = discount_value(update.kind());
383 let row = sqlx::query(
384 r#"
385 UPDATE billing_subscription_discount_codes
386 SET label = $4, status = $5, discount_kind = $6,
387 amount_off_cents = $7, percent_off_bps = $8, currency = $9,
388 duration = $10, duration_months = $11, updated_at = now()
389 WHERE id = $1 AND billing_scope_id = $2 AND plan_key = $3
390 RETURNING id, billing_scope_id, plan_key, code_normalized, display_code,
391 label, status, discount_kind, amount_off_cents, percent_off_bps,
392 currency, duration, duration_months, created_at, updated_at
393 "#,
394 )
395 .bind(update.id().as_uuid())
396 .bind(update.billing_scope_id().as_uuid())
397 .bind(update.plan_key().as_str())
398 .bind(update.label())
399 .bind(update.status().as_str())
400 .bind(update.kind().as_str())
401 .bind(amount_off_cents)
402 .bind(percent_off_bps)
403 .bind(update.currency().as_str())
404 .bind(update.duration().as_str())
405 .bind(duration_months(update.duration()))
406 .fetch_optional(&mut **transaction)
407 .await?;
408 if row.is_some() && update.status() == SubscriptionDiscountCodeStatus::Disabled {
409 expire_saved_claims_for_code(
410 transaction,
411 update.billing_scope_id(),
412 update.plan_key(),
413 update.id(),
414 )
415 .await?;
416 }
417 row.as_ref().map(code_from_row).transpose()
418}
419
420pub async fn update_subscription_discount_code(
421 pool: &PgPool,
422 offers: &dyn SubscriptionOfferStore,
423 update: &SubscriptionDiscountCodeUpdate,
424) -> Result<Option<SubscriptionDiscountCodeRecord>, SubscriptionDiscountOperationError> {
425 let mut transaction = pool.begin().await?;
426 let record =
427 update_subscription_discount_code_in_transaction(&mut transaction, offers, update).await?;
428 transaction.commit().await?;
429 Ok(record)
430}
431
432pub async fn disable_subscription_discount_code_in_transaction(
435 transaction: &mut Transaction<'_, Postgres>,
436 billing_scope_id: BillingScopeId,
437 plan_key: &PlanKey,
438 discount_code_id: DiscountCodeId,
439) -> Result<Option<SubscriptionDiscountCodeRecord>, SubscriptionDiscountOperationError> {
440 set_lock_timeout(transaction).await?;
441 let row = sqlx::query(
442 r#"
443 UPDATE billing_subscription_discount_codes
444 SET status = 'disabled', updated_at = now()
445 WHERE id = $1 AND billing_scope_id = $2 AND plan_key = $3
446 RETURNING id, billing_scope_id, plan_key, code_normalized, display_code,
447 label, status, discount_kind, amount_off_cents, percent_off_bps,
448 currency, duration, duration_months, created_at, updated_at
449 "#,
450 )
451 .bind(discount_code_id.as_uuid())
452 .bind(billing_scope_id.as_uuid())
453 .bind(plan_key.as_str())
454 .fetch_optional(&mut **transaction)
455 .await?;
456 if row.is_some() {
457 expire_saved_claims_for_code(transaction, billing_scope_id, plan_key, discount_code_id)
458 .await?;
459 }
460 row.as_ref().map(code_from_row).transpose()
461}
462
463pub async fn disable_subscription_discount_code(
464 pool: &PgPool,
465 billing_scope_id: BillingScopeId,
466 plan_key: &PlanKey,
467 discount_code_id: DiscountCodeId,
468) -> Result<Option<SubscriptionDiscountCodeRecord>, SubscriptionDiscountOperationError> {
469 let mut transaction = pool.begin().await?;
470 let record = disable_subscription_discount_code_in_transaction(
471 &mut transaction,
472 billing_scope_id,
473 plan_key,
474 discount_code_id,
475 )
476 .await?;
477 transaction.commit().await?;
478 Ok(record)
479}
480
481pub async fn claim_subscription_discount(
482 pool: &PgPool,
483 offers: &dyn SubscriptionOfferStore,
484 claim: &SubscriptionDiscountClaim,
485) -> Result<SubscriptionDiscountClaimOutcome, SubscriptionDiscountOperationError> {
486 let mut transaction = pool.begin().await?;
487 let outcome =
488 claim_subscription_discount_in_transaction(&mut transaction, offers, claim).await?;
489 transaction.commit().await?;
490 Ok(outcome)
491}
492
493pub async fn claim_subscription_discount_in_transaction(
494 transaction: &mut Transaction<'_, Postgres>,
495 offers: &dyn SubscriptionOfferStore,
496 claim: &SubscriptionDiscountClaim,
497) -> Result<SubscriptionDiscountClaimOutcome, SubscriptionDiscountOperationError> {
498 claim_subscription_discount_on_connection(transaction, offers, claim).await
499}
500
501pub(crate) async fn claim_subscription_discount_on_connection(
507 connection: &mut PgConnection,
508 offers: &dyn SubscriptionOfferStore,
509 claim: &SubscriptionDiscountClaim,
510) -> Result<SubscriptionDiscountClaimOutcome, SubscriptionDiscountOperationError> {
511 set_lock_timeout(connection).await?;
512 lock_subscription_aggregate(connection, claim.subscriber_id(), claim.plan_key()).await?;
513 let offer = lock_offer(
514 connection,
515 offers,
516 claim.billing_scope_id(),
517 claim.plan_key(),
518 )
519 .await?;
520 if current_subscription_exists(connection, claim).await? {
521 return Ok(SubscriptionDiscountClaimOutcome::BlockedBySubscription);
522 }
523 let Some(code_row) = find_active_code(
524 connection,
525 claim.billing_scope_id(),
526 claim.plan_key(),
527 claim.code(),
528 true,
529 )
530 .await?
531 else {
532 return Ok(SubscriptionDiscountClaimOutcome::NotFound);
533 };
534 let code = code_from_row(&code_row)?;
535 let quote = quote_for_offer(code, &offer)?;
536 let existing = saved_subscription_discount_claim_on_connection(
537 connection,
538 claim.billing_scope_id(),
539 claim.subscriber_id(),
540 claim.plan_key(),
541 )
542 .await?;
543 if let Some(existing) = existing.as_ref()
544 && existing.snapshot().code() == claim.code()
545 {
546 return Ok(SubscriptionDiscountClaimOutcome::Existing(Box::new(
547 existing.clone(),
548 )));
549 }
550 lock_initial_attempts(connection, claim).await?;
551 if blocking_initial_attempt_exists(connection, claim).await? {
552 return Ok(SubscriptionDiscountClaimOutcome::BlockedByInitialAttempt);
553 }
554 if let Some(existing) = existing {
555 let result = sqlx::query(
556 "UPDATE billing_subscription_discount_claims SET status = 'superseded', superseded_at = now() WHERE id = $1 AND status = 'saved'",
557 )
558 .bind(existing.id().as_uuid())
559 .execute(&mut *connection)
560 .await?;
561 if result.rows_affected() != 1 {
562 return Err(SubscriptionDiscountOperationError::InvalidState(
563 INVALID_DISCOUNT_STATE,
564 ));
565 }
566 }
567 let quoted_code = quote.code();
568 let (amount_off_cents, percent_off_bps) = discount_value(quoted_code.kind());
569 let row = sqlx::query(
570 r#"
571 INSERT INTO billing_subscription_discount_claims (
572 id, billing_scope_id, subscriber_id, plan_key, discount_code_id,
573 code_snapshot, label_snapshot, discount_kind, amount_off_cents,
574 percent_off_bps, currency, duration, duration_months,
575 base_amount_cents, discounted_amount_cents, status
576 )
577 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'saved')
578 RETURNING id, billing_scope_id, subscriber_id, plan_key,
579 discount_code_id, code_snapshot, label_snapshot, discount_kind,
580 amount_off_cents, percent_off_bps, currency, duration,
581 duration_months, base_amount_cents, discounted_amount_cents,
582 status, claimed_at, applied_at, applied_subscription_id,
583 applied_payment_attempt_id, superseded_at
584 "#,
585 )
586 .bind(claim.id().as_uuid())
587 .bind(claim.billing_scope_id().as_uuid())
588 .bind(claim.subscriber_id().as_uuid())
589 .bind(claim.plan_key().as_str())
590 .bind(quoted_code.id().as_uuid())
591 .bind(quoted_code.code().as_str())
592 .bind(quoted_code.label())
593 .bind(quoted_code.kind().as_str())
594 .bind(amount_off_cents)
595 .bind(percent_off_bps)
596 .bind(quoted_code.currency().as_str())
597 .bind(quoted_code.duration().as_str())
598 .bind(duration_months(quoted_code.duration()))
599 .bind(quote.base_charge().cents())
600 .bind(quote.discounted_charge().cents())
601 .fetch_one(&mut *connection)
602 .await?;
603 Ok(SubscriptionDiscountClaimOutcome::Saved(Box::new(
604 claim_from_row(&row)?,
605 )))
606}
607
608pub async fn clear_subscription_discount(
609 pool: &PgPool,
610 billing_scope_id: BillingScopeId,
611 subscriber_id: SubscriberId,
612 plan_key: &PlanKey,
613) -> Result<SubscriptionDiscountClearOutcome, SubscriptionDiscountOperationError> {
614 let mut transaction = pool.begin().await?;
615 let outcome = clear_subscription_discount_in_transaction(
616 &mut transaction,
617 billing_scope_id,
618 subscriber_id,
619 plan_key,
620 )
621 .await?;
622 transaction.commit().await?;
623 Ok(outcome)
624}
625
626pub async fn clear_subscription_discount_in_transaction(
627 transaction: &mut Transaction<'_, Postgres>,
628 billing_scope_id: BillingScopeId,
629 subscriber_id: SubscriberId,
630 plan_key: &PlanKey,
631) -> Result<SubscriptionDiscountClearOutcome, SubscriptionDiscountOperationError> {
632 clear_subscription_discount_on_connection(
633 transaction,
634 billing_scope_id,
635 subscriber_id,
636 plan_key,
637 )
638 .await
639}
640
641pub(crate) async fn clear_subscription_discount_on_connection(
644 connection: &mut PgConnection,
645 billing_scope_id: BillingScopeId,
646 subscriber_id: SubscriberId,
647 plan_key: &PlanKey,
648) -> Result<SubscriptionDiscountClearOutcome, SubscriptionDiscountOperationError> {
649 set_lock_timeout(connection).await?;
650 lock_subscription_aggregate(connection, subscriber_id, plan_key).await?;
651 let existing = saved_subscription_discount_claim_on_connection(
652 connection,
653 billing_scope_id,
654 subscriber_id,
655 plan_key,
656 )
657 .await?;
658 lock_initial_attempt_rows(connection, billing_scope_id, subscriber_id, plan_key).await?;
659 if blocking_initial_attempt(connection, billing_scope_id, subscriber_id, plan_key).await? {
660 return Ok(SubscriptionDiscountClearOutcome::BlockedByInitialAttempt);
661 }
662 let Some(existing) = existing else {
663 return Ok(SubscriptionDiscountClearOutcome::NotFound);
664 };
665 let row = sqlx::query(
666 r#"
667 UPDATE billing_subscription_discount_claims
668 SET status = 'expired'
669 WHERE id = $1 AND billing_scope_id = $2 AND subscriber_id = $3
670 AND plan_key = $4 AND status = 'saved'
671 RETURNING id, billing_scope_id, subscriber_id, plan_key,
672 discount_code_id, code_snapshot, label_snapshot, discount_kind,
673 amount_off_cents, percent_off_bps, currency, duration,
674 duration_months, base_amount_cents, discounted_amount_cents,
675 status, claimed_at, applied_at, applied_subscription_id,
676 applied_payment_attempt_id, superseded_at
677 "#,
678 )
679 .bind(existing.id().as_uuid())
680 .bind(billing_scope_id.as_uuid())
681 .bind(subscriber_id.as_uuid())
682 .bind(plan_key.as_str())
683 .fetch_one(&mut *connection)
684 .await?;
685 Ok(SubscriptionDiscountClearOutcome::Cleared(Box::new(
686 claim_from_row(&row)?,
687 )))
688}
689
690pub async fn saved_subscription_discount_claim(
691 pool: &PgPool,
692 billing_scope_id: BillingScopeId,
693 subscriber_id: SubscriberId,
694 plan_key: &PlanKey,
695) -> Result<Option<SubscriptionDiscountClaimRecord>, SubscriptionDiscountOperationError> {
696 let row = sqlx::query(
697 r#"
698 SELECT id, billing_scope_id, subscriber_id, plan_key,
699 discount_code_id, code_snapshot, label_snapshot, discount_kind,
700 amount_off_cents, percent_off_bps, currency, duration,
701 duration_months, base_amount_cents, discounted_amount_cents,
702 status, claimed_at, applied_at, applied_subscription_id,
703 applied_payment_attempt_id, superseded_at
704 FROM billing_subscription_discount_claims
705 WHERE billing_scope_id = $1 AND subscriber_id = $2 AND plan_key = $3
706 AND status = 'saved'
707 ORDER BY claimed_at DESC, id DESC LIMIT 1
708 "#,
709 )
710 .bind(billing_scope_id.as_uuid())
711 .bind(subscriber_id.as_uuid())
712 .bind(plan_key.as_str())
713 .fetch_optional(pool)
714 .await?;
715 row.as_ref().map(claim_from_row).transpose()
716}
717
718pub async fn mark_subscription_discount_claim_applied_in_transaction(
719 transaction: &mut Transaction<'_, Postgres>,
720 claim_id: DiscountClaimId,
721 billing_scope_id: BillingScopeId,
722 subscriber_id: SubscriberId,
723 plan_key: &PlanKey,
724 subscription_id: SubscriptionId,
725 payment_attempt_id: PaymentAttemptId,
726) -> Result<SubscriptionDiscountClaimRecord, SubscriptionDiscountOperationError> {
727 set_lock_timeout(transaction).await?;
728 let row = sqlx::query(
729 r#"
730 UPDATE billing_subscription_discount_claims claims
731 SET status = 'applied', applied_at = now(),
732 applied_subscription_id = $5, applied_payment_attempt_id = $6
733 WHERE claims.id = $1
734 AND claims.billing_scope_id = $2
735 AND claims.subscriber_id = $3
736 AND claims.plan_key = $4
737 AND claims.status IN ('saved', 'expired')
738 AND EXISTS (
739 SELECT 1 FROM billing_payment_attempts attempts
740 WHERE attempts.id = $6
741 AND attempts.billing_scope_id = claims.billing_scope_id
742 AND attempts.subscriber_id = claims.subscriber_id
743 AND attempts.plan_key = claims.plan_key
744 AND attempts.subscription_initial_discount_claim_id = claims.id
745 AND attempts.attempt_kind = 'subscription_initial'
746 AND attempts.submitted_at IS NOT NULL
747 )
748 RETURNING claims.id, claims.billing_scope_id, claims.subscriber_id,
749 claims.plan_key, claims.discount_code_id, claims.code_snapshot,
750 claims.label_snapshot, claims.discount_kind, claims.amount_off_cents,
751 claims.percent_off_bps, claims.currency, claims.duration,
752 claims.duration_months, claims.base_amount_cents,
753 claims.discounted_amount_cents, claims.status, claims.claimed_at,
754 claims.applied_at, claims.applied_subscription_id,
755 claims.applied_payment_attempt_id, claims.superseded_at
756 "#,
757 )
758 .bind(claim_id.as_uuid())
759 .bind(billing_scope_id.as_uuid())
760 .bind(subscriber_id.as_uuid())
761 .bind(plan_key.as_str())
762 .bind(subscription_id.as_uuid())
763 .bind(payment_attempt_id.as_uuid())
764 .fetch_optional(&mut **transaction)
765 .await?
766 .ok_or(sqlx::Error::RowNotFound)?;
767 claim_from_row(&row)
768}
769
770#[cfg(test)]
771mod tests;