1use std::fmt;
2
3use chrono::{DateTime, Utc};
4use sqlx::{Executor, PgConnection, PgPool, Postgres, Row, Transaction, postgres::PgRow};
5use syrup_rail::{
6 ActorId, AppliedSubscriptionDiscount, BillingPeriod, ChargeAmount, CurrencyCode,
7 DiscountClaimId, Entitlement, EntitlementGuard, EntitlementQuery, LimitedDiscountMonths,
8 MissingSubscriptionAction, PastDueAccess, PastDueAccessPolicy, PastDueAction, PaymentMethodId,
9 PercentOffBasisPoints, PositiveDiscountCents, SavedSubscriptionDiscount, Subscription,
10 SubscriptionDiscountCode, SubscriptionDiscountDuration, SubscriptionDiscountKind,
11 SubscriptionDiscountSnapshot, SubscriptionGrant, SubscriptionGrantId, SubscriptionGrantKind,
12 SubscriptionId, SubscriptionPhase, SubscriptionStatus, classify_past_due_access,
13};
14use thiserror::Error;
15use uuid::Uuid;
16
17use crate::subscription_persistence::{
18 RenewalFailurePolicyScalars, SubscriptionPeriodRuleScalars, SubscriptionPersistenceCodecError,
19 renewal_failure_policy_from_scalars, subscription_period_rule_from_scalars,
20};
21
22const INVALID_ENTITLEMENT_STATE: &str =
23 "canonical subscription state cannot be represented as one entitlement";
24const ENTITLEMENT_GUARD_LOCK_TIMEOUT: &str = "250ms";
25
26type GuardGrantTimeState = (DateTime<Utc>, DateTime<Utc>, Option<DateTime<Utc>>);
27type GuardSubscriptionState = (String, DateTime<Utc>, String, Option<DateTime<Utc>>);
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30enum GuardAccess {
31 Missing,
32 Paid,
33 PaidThroughCancellation,
34 PastDue,
35 Granted,
36 Invalid,
37}
38
39#[derive(Debug, Error)]
40pub enum EntitlementQueryError {
41 #[error("subscription entitlement query failed")]
42 Sql(#[from] sqlx::Error),
43 #[error("{0}")]
44 InvalidState(&'static str),
45}
46
47#[derive(Debug, Error)]
48pub enum EntitlementGuardError {
49 #[error("subscription entitlement guard failed")]
50 Sql(#[from] sqlx::Error),
51 #[error("subscription entitlement is required")]
52 Required,
53 #[error("subscription entitlement is past due")]
54 PastDue,
55 #[error("{0}")]
56 InvalidState(&'static str),
57}
58
59pub struct EntitlementWriteTransaction {
66 inner: Transaction<'static, Postgres>,
67}
68
69impl fmt::Debug for EntitlementWriteTransaction {
70 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71 formatter
72 .debug_struct("EntitlementWriteTransaction")
73 .finish_non_exhaustive()
74 }
75}
76
77impl EntitlementWriteTransaction {
78 pub async fn begin(pool: &PgPool) -> Result<Self, sqlx::Error> {
80 Ok(Self {
81 inner: pool.begin().await?,
82 })
83 }
84
85 pub fn connection(&mut self) -> &mut PgConnection {
91 &mut self.inner
92 }
93
94 pub async fn rollback(self) -> Result<(), sqlx::Error> {
96 self.inner.rollback().await
97 }
98}
99
100pub struct AdmittedEntitlementWriteTransaction {
106 inner: Transaction<'static, Postgres>,
107}
108
109impl fmt::Debug for AdmittedEntitlementWriteTransaction {
110 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111 formatter
112 .debug_struct("AdmittedEntitlementWriteTransaction")
113 .finish_non_exhaustive()
114 }
115}
116
117impl AdmittedEntitlementWriteTransaction {
118 pub fn connection(&mut self) -> &mut PgConnection {
124 &mut self.inner
125 }
126
127 pub async fn commit(self) -> Result<(), sqlx::Error> {
129 self.inner.commit().await
130 }
131
132 pub async fn rollback(self) -> Result<(), sqlx::Error> {
134 self.inner.rollback().await
135 }
136}
137
138fn map_subscription_persistence_error(
139 error: SubscriptionPersistenceCodecError,
140) -> EntitlementQueryError {
141 match error {
142 SubscriptionPersistenceCodecError::RowRead(error) => EntitlementQueryError::Sql(error),
143 SubscriptionPersistenceCodecError::InvalidState => {
144 EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE)
145 }
146 }
147}
148
149pub async fn require_entitlement_for_update(
161 transaction: EntitlementWriteTransaction,
162 guard: &EntitlementGuard,
163) -> Result<AdmittedEntitlementWriteTransaction, EntitlementGuardError> {
164 require_entitlement_for_update_with_lock_timeout(
165 transaction,
166 guard,
167 ENTITLEMENT_GUARD_LOCK_TIMEOUT,
168 )
169 .await
170}
171
172async fn require_entitlement_for_update_with_lock_timeout(
173 transaction: EntitlementWriteTransaction,
174 guard: &EntitlementGuard,
175 lock_timeout: &str,
176) -> Result<AdmittedEntitlementWriteTransaction, EntitlementGuardError> {
177 let mut transaction = transaction.inner;
178 let admission = async {
179 let previous_lock_timeout: String =
180 sqlx::query_scalar("SELECT current_setting('lock_timeout', true)")
181 .fetch_one(&mut *transaction)
182 .await?;
183 sqlx::query("SELECT set_config('lock_timeout', $1, true)")
184 .bind(lock_timeout)
185 .execute(&mut *transaction)
186 .await?;
187 let access = lock_and_classify_entitlement(&mut transaction, guard).await?;
188 Ok::<_, sqlx::Error>((access, previous_lock_timeout))
189 }
190 .await;
191 let (access, previous_lock_timeout) = match admission {
192 Ok(admission) => admission,
193 Err(error) => {
194 return rollback_guard_failure(transaction, EntitlementGuardError::Sql(error)).await;
195 }
196 };
197
198 match access {
199 GuardAccess::Paid | GuardAccess::PaidThroughCancellation | GuardAccess::Granted => {
200 if let Err(error) = sqlx::query("SELECT set_config('lock_timeout', $1, true)")
201 .bind(previous_lock_timeout)
202 .execute(&mut *transaction)
203 .await
204 {
205 return rollback_guard_failure(transaction, EntitlementGuardError::Sql(error))
206 .await;
207 }
208 Ok(AdmittedEntitlementWriteTransaction { inner: transaction })
209 }
210 GuardAccess::PastDue => {
211 rollback_guard_failure(transaction, EntitlementGuardError::PastDue).await
212 }
213 GuardAccess::Missing => {
214 rollback_guard_failure(transaction, EntitlementGuardError::Required).await
215 }
216 GuardAccess::Invalid => {
217 rollback_guard_failure(
218 transaction,
219 EntitlementGuardError::InvalidState(INVALID_ENTITLEMENT_STATE),
220 )
221 .await
222 }
223 }
224}
225
226async fn rollback_guard_failure(
227 transaction: Transaction<'static, Postgres>,
228 error: EntitlementGuardError,
229) -> Result<AdmittedEntitlementWriteTransaction, EntitlementGuardError> {
230 match transaction.rollback().await {
231 Ok(()) => Err(error),
232 Err(rollback_error) => Err(EntitlementGuardError::Sql(rollback_error)),
233 }
234}
235
236async fn lock_and_classify_entitlement(
237 connection: &mut PgConnection,
238 guard: &EntitlementGuard,
239) -> Result<GuardAccess, sqlx::Error> {
240 sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':' || $2, 0))")
241 .bind(guard.subscriber_id().as_uuid())
242 .bind(guard.plan_key().as_str())
243 .execute(&mut *connection)
244 .await?;
245
246 let subscriptions = sqlx::query_as::<_, GuardSubscriptionState>(
247 r#"
248 SELECT status, current_period_end_at, past_due_access, next_payment_attempt_at
249 FROM billing_subscriptions
250 WHERE billing_scope_id = $1
251 AND subscriber_id = $2
252 AND plan_key = $3
253 ORDER BY id
254 FOR SHARE
255 "#,
256 )
257 .bind(guard.billing_scope_id().as_uuid())
258 .bind(guard.subscriber_id().as_uuid())
259 .bind(guard.plan_key().as_str())
260 .fetch_all(&mut *connection)
261 .await?;
262 let grants = sqlx::query_as::<_, GuardGrantTimeState>(
263 r#"
264 SELECT starts_at, ends_at, revoked_at
265 FROM billing_subscription_grants
266 WHERE billing_scope_id = $1
267 AND subscriber_id = $2
268 AND plan_key = $3
269 ORDER BY id
270 FOR SHARE
271 "#,
272 )
273 .bind(guard.billing_scope_id().as_uuid())
274 .bind(guard.subscriber_id().as_uuid())
275 .bind(guard.plan_key().as_str())
276 .fetch_all(&mut *connection)
277 .await?;
278 let access_at: DateTime<Utc> = sqlx::query_scalar("SELECT clock_timestamp()")
279 .fetch_one(&mut *connection)
280 .await?;
281
282 Ok(classify_guard_access(&subscriptions, &grants, access_at))
283}
284
285fn classify_guard_access(
286 subscriptions: &[GuardSubscriptionState],
287 grants: &[GuardGrantTimeState],
288 access_at: DateTime<Utc>,
289) -> GuardAccess {
290 let current_paid = subscriptions
291 .iter()
292 .filter(|(status, current_period_end_at, _, _)| {
293 matches!(status.as_str(), "active" | "past_due")
294 || (status == "canceled" && *current_period_end_at > access_at)
295 })
296 .collect::<Vec<_>>();
297 let active_grant_count = grants
298 .iter()
299 .filter(|(starts_at, ends_at, revoked_at)| {
300 *starts_at <= access_at && *ends_at > access_at && revoked_at.is_none()
301 })
302 .count();
303 if current_paid.len() > 1
304 || active_grant_count > 1
305 || (!current_paid.is_empty() && active_grant_count > 0)
306 {
307 return GuardAccess::Invalid;
308 }
309 if active_grant_count == 1 {
310 return GuardAccess::Granted;
311 }
312 let Some((status, _, past_due_access, next_payment_attempt_at)) = current_paid.first() else {
313 return GuardAccess::Missing;
314 };
315 match status.as_str() {
316 "active" => GuardAccess::Paid,
317 "canceled" => GuardAccess::PaidThroughCancellation,
318 "past_due" => match past_due_access.parse::<PastDueAccessPolicy>() {
319 Ok(policy)
320 if classify_past_due_access(policy, next_payment_attempt_at.is_some())
321 == PastDueAccess::AllowedDuringDunning =>
322 {
323 GuardAccess::Paid
324 }
325 Ok(_) => GuardAccess::PastDue,
326 Err(_) => GuardAccess::Invalid,
327 },
328 _ => GuardAccess::Invalid,
329 }
330}
331
332pub async fn entitlement(
336 pool: &PgPool,
337 query: &EntitlementQuery,
338) -> Result<Entitlement, EntitlementQueryError> {
339 entitlement_on_executor(pool, query).await
340}
341
342pub(crate) async fn entitlement_on_connection(
347 connection: &mut PgConnection,
348 query: &EntitlementQuery,
349) -> Result<Entitlement, EntitlementQueryError> {
350 entitlement_on_executor(connection, query).await
351}
352
353async fn entitlement_on_executor<'e, E>(
354 executor: E,
355 query: &EntitlementQuery,
356) -> Result<Entitlement, EntitlementQueryError>
357where
358 E: Executor<'e, Database = Postgres>,
359{
360 let row = sqlx::query(
361 r#"
362 WITH clock AS MATERIALIZED (
363 SELECT clock_timestamp() AS observed_at
364 ),
365 paid_candidates AS MATERIALIZED (
366 SELECT subscriptions.*
367 FROM billing_subscriptions subscriptions
368 CROSS JOIN clock
369 WHERE subscriptions.billing_scope_id = $1
370 AND subscriptions.subscriber_id = $2
371 AND subscriptions.plan_key = $3
372 AND (
373 subscriptions.status IN ('active', 'past_due')
374 OR (
375 subscriptions.status = 'canceled'
376 AND subscriptions.current_period_end_at > clock.observed_at
377 )
378 )
379 ),
380 active_grants AS MATERIALIZED (
381 SELECT grants.*
382 FROM billing_subscription_grants grants
383 CROSS JOIN clock
384 WHERE grants.billing_scope_id = $1
385 AND grants.subscriber_id = $2
386 AND grants.plan_key = $3
387 AND grants.starts_at <= clock.observed_at
388 AND grants.ends_at > clock.observed_at
389 AND grants.revoked_at IS NULL
390 ),
391 paid AS MATERIALIZED (
392 SELECT *
393 FROM paid_candidates
394 ORDER BY
395 CASE status WHEN 'active' THEN 0 WHEN 'past_due' THEN 1 ELSE 2 END,
396 updated_at DESC,
397 id DESC
398 LIMIT 1
399 ),
400 active_grant AS MATERIALIZED (
401 SELECT *
402 FROM active_grants
403 ORDER BY ends_at DESC, created_at DESC, id DESC
404 LIMIT 1
405 )
406 SELECT
407 (SELECT count(*) FROM paid_candidates) AS paid_count,
408 (SELECT count(*) FROM active_grants) AS grant_count,
409 paid.id AS paid_id,
410 paid.plan_key AS paid_plan_key,
411 paid.status AS paid_status,
412 paid.payment_method_id AS paid_payment_method_id,
413 paid.amount_cents AS paid_amount_cents,
414 paid.currency AS paid_currency,
415 paid.current_period_start_at AS paid_period_start_at,
416 paid.current_period_end_at AS paid_period_end_at,
417 paid.next_renewal_at AS paid_next_renewal_at,
418 paid.phase AS paid_phase,
419 paid.recurring_period_kind AS paid_recurring_period_kind,
420 paid.recurring_period_count AS paid_recurring_period_count,
421 paid.dunning_retry_delays_seconds AS paid_dunning_retry_delays_seconds,
422 paid.dunning_exhaustion AS paid_dunning_exhaustion,
423 paid.past_due_access AS paid_past_due_access,
424 paid.next_payment_attempt_at AS paid_next_payment_attempt_at,
425 active_grant.id AS grant_id,
426 active_grant.plan_key AS grant_plan_key,
427 active_grant.grant_kind AS grant_kind,
428 active_grant.starts_at AS grant_starts_at,
429 active_grant.ends_at AS grant_ends_at,
430 active_grant.granted_by_actor_id AS grant_actor_id,
431 EXISTS (
432 SELECT 1
433 FROM billing_payment_attempts attempts
434 WHERE attempts.billing_scope_id = $1
435 AND attempts.subscriber_id = $2
436 AND attempts.plan_key = $3
437 AND attempts.attempt_kind = 'subscription_initial'
438 AND (
439 attempts.status IN ('pending', 'unknown')
440 OR (
441 attempts.status = 'review_required'
442 AND attempts.resolution_code IS DISTINCT FROM
443 'subscription_initial_current_subscription_conflict'
444 )
445 )
446 AND NOT EXISTS (
447 SELECT 1
448 FROM billing_subscriptions later_subscription
449 WHERE later_subscription.billing_scope_id = attempts.billing_scope_id
450 AND later_subscription.subscriber_id = attempts.subscriber_id
451 AND later_subscription.plan_key = attempts.plan_key
452 AND later_subscription.created_at >= attempts.created_at
453 )
454 ) AS blocking_initial_attempt,
455 EXISTS (
456 SELECT 1
457 FROM billing_payment_attempts attempts
458 WHERE attempts.subscription_id = paid.id
459 AND attempts.attempt_kind IN (
460 'subscription_renewal',
461 'subscription_recovery'
462 )
463 AND attempts.status IN ('pending', 'unknown', 'review_required')
464 ) AS pending_recovery_confirmation,
465 saved.id AS saved_claim_id,
466 saved.code_snapshot AS saved_code,
467 saved.label_snapshot AS saved_label,
468 saved.discount_kind AS saved_kind,
469 saved.amount_off_cents AS saved_amount_off_cents,
470 saved.percent_off_bps AS saved_percent_off_bps,
471 saved.currency AS saved_currency,
472 saved.duration AS saved_duration,
473 saved.duration_months AS saved_duration_months,
474 saved.base_amount_cents AS saved_base_amount_cents,
475 saved.discounted_amount_cents AS saved_discounted_amount_cents,
476 applied.discount_claim_id AS applied_claim_id,
477 applied.code_snapshot AS applied_code,
478 applied.label_snapshot AS applied_label,
479 applied.discount_kind AS applied_kind,
480 applied.amount_off_cents AS applied_amount_off_cents,
481 applied.percent_off_bps AS applied_percent_off_bps,
482 applied.currency AS applied_currency,
483 applied.duration AS applied_duration,
484 applied.duration_months AS applied_duration_months,
485 applied.base_amount_cents AS applied_base_amount_cents,
486 applied.discounted_amount_cents AS applied_discounted_amount_cents,
487 applied.periods_total AS applied_periods_total,
488 applied.periods_applied AS applied_periods_applied
489 FROM (SELECT 1) seed
490 LEFT JOIN paid ON true
491 LEFT JOIN active_grant ON true
492 LEFT JOIN LATERAL (
493 SELECT claims.*
494 FROM billing_subscription_discount_claims claims
495 WHERE claims.billing_scope_id = $1
496 AND claims.subscriber_id = $2
497 AND claims.plan_key = $3
498 AND claims.status = 'saved'
499 ORDER BY claims.claimed_at DESC, claims.id DESC
500 LIMIT 1
501 ) saved ON true
502 LEFT JOIN LATERAL (
503 SELECT discounts.*
504 FROM billing_subscription_discounts discounts
505 WHERE discounts.subscription_id = paid.id
506 AND discounts.billing_scope_id = $1
507 AND discounts.subscriber_id = $2
508 AND discounts.plan_key = $3
509 AND discounts.status = 'active'
510 LIMIT 1
511 ) applied ON true
512 "#,
513 )
514 .bind(query.billing_scope_id().as_uuid())
515 .bind(query.subscriber_id().as_uuid())
516 .bind(query.plan_key().as_str())
517 .fetch_one(executor)
518 .await?;
519
520 entitlement_from_row(&row)
521}
522
523fn entitlement_from_row(row: &PgRow) -> Result<Entitlement, EntitlementQueryError> {
524 let paid_count: i64 = row.try_get("paid_count")?;
525 let grant_count: i64 = row.try_get("grant_count")?;
526 if paid_count > 1 || grant_count > 1 || (paid_count > 0 && grant_count > 0) {
527 return Err(EntitlementQueryError::InvalidState(
528 INVALID_ENTITLEMENT_STATE,
529 ));
530 }
531
532 if grant_count == 1 {
533 return Ok(Entitlement::Granted {
534 grant: grant_from_row(row)?,
535 });
536 }
537
538 let Some(subscription_id) = row.try_get::<Option<Uuid>, _>("paid_id")? else {
539 let next_action = if row.try_get("blocking_initial_attempt")? {
540 MissingSubscriptionAction::ConfirmInitialPayment
541 } else {
542 MissingSubscriptionAction::StartSubscription
543 };
544 return Ok(Entitlement::Missing {
545 next_action,
546 saved_discount: saved_discount_from_row(row)?,
547 });
548 };
549
550 let status = row
551 .try_get::<String, _>("paid_status")?
552 .parse::<SubscriptionStatus>()
553 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?;
554 let phase = row
555 .try_get::<String, _>("paid_phase")?
556 .parse::<SubscriptionPhase>()
557 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?;
558 let recurring_period_kind: String = row.try_get("paid_recurring_period_kind")?;
559 let recurring_period_count: i32 = row.try_get("paid_recurring_period_count")?;
560 let recurring_period = subscription_period_rule_from_scalars(
561 SubscriptionPeriodRuleScalars::new(&recurring_period_kind, recurring_period_count),
562 )
563 .map_err(map_subscription_persistence_error)?;
564 let retry_delays_seconds: Vec<i64> = row.try_get("paid_dunning_retry_delays_seconds")?;
565 let exhaustion: String = row.try_get("paid_dunning_exhaustion")?;
566 let past_due_access: String = row.try_get("paid_past_due_access")?;
567 let renewal_failure = renewal_failure_policy_from_scalars(RenewalFailurePolicyScalars::new(
568 retry_delays_seconds,
569 &exhaustion,
570 &past_due_access,
571 ))
572 .map_err(map_subscription_persistence_error)?;
573 let next_payment_attempt_at = row.try_get("paid_next_payment_attempt_at")?;
574 let subscription = Subscription::new(
575 SubscriptionId::new(subscription_id),
576 row.try_get::<String, _>("paid_plan_key")?
577 .parse()
578 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
579 status,
580 phase,
581 PaymentMethodId::new(row.try_get("paid_payment_method_id")?),
582 ChargeAmount::new(
583 row.try_get("paid_amount_cents")?,
584 CurrencyCode::new(&row.try_get::<String, _>("paid_currency")?)
585 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
586 )
587 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
588 recurring_period,
589 renewal_failure,
590 BillingPeriod::new(
591 row.try_get("paid_period_start_at")?,
592 row.try_get("paid_period_end_at")?,
593 )
594 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
595 row.try_get("paid_next_renewal_at")?,
596 next_payment_attempt_at,
597 );
598 let applied_discount = applied_discount_from_row(row)?;
599
600 Ok(match status {
601 SubscriptionStatus::Active => Entitlement::PaidActive {
602 subscription,
603 applied_discount,
604 },
605 SubscriptionStatus::PastDue => Entitlement::PastDue {
606 access: classify_past_due_access(
607 subscription.renewal_failure().past_due_access(),
608 subscription.next_payment_attempt_at().is_some(),
609 ),
610 subscription,
611 next_action: if row.try_get("pending_recovery_confirmation")? {
612 PastDueAction::ConfirmRecoveryPayment
613 } else {
614 PastDueAction::RecoverPayment
615 },
616 applied_discount,
617 },
618 SubscriptionStatus::Canceled => Entitlement::PaidThroughCancellation {
619 subscription,
620 applied_discount,
621 },
622 SubscriptionStatus::Unpaid => {
623 return Err(EntitlementQueryError::InvalidState(
624 INVALID_ENTITLEMENT_STATE,
625 ));
626 }
627 })
628}
629
630fn grant_from_row(row: &PgRow) -> Result<SubscriptionGrant, EntitlementQueryError> {
631 SubscriptionGrant::new(
632 SubscriptionGrantId::new(required(row, "grant_id")?),
633 required::<String>(row, "grant_plan_key")?
634 .parse()
635 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
636 required::<String>(row, "grant_kind")?
637 .parse::<SubscriptionGrantKind>()
638 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
639 required(row, "grant_starts_at")?,
640 required(row, "grant_ends_at")?,
641 ActorId::new(required(row, "grant_actor_id")?),
642 )
643 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))
644}
645
646fn saved_discount_from_row(
647 row: &PgRow,
648) -> Result<Option<SavedSubscriptionDiscount>, EntitlementQueryError> {
649 let Some(claim_id) = row.try_get::<Option<Uuid>, _>("saved_claim_id")? else {
650 return Ok(None);
651 };
652 Ok(Some(SavedSubscriptionDiscount::new(
653 DiscountClaimId::new(claim_id),
654 discount_snapshot(row, "saved")?,
655 )))
656}
657
658fn applied_discount_from_row(
659 row: &PgRow,
660) -> Result<Option<AppliedSubscriptionDiscount>, EntitlementQueryError> {
661 let Some(code) = row.try_get::<Option<String>, _>("applied_code")? else {
662 return Ok(None);
663 };
664 let duration = discount_duration(
665 &required::<String>(row, "applied_duration")?,
666 row.try_get("applied_duration_months")?,
667 )?;
668 let periods_remaining = match duration {
669 SubscriptionDiscountDuration::Indefinite => None,
670 SubscriptionDiscountDuration::LimitedMonths(_) => {
671 let total: i32 = required(row, "applied_periods_total")?;
672 let applied: i32 = required(row, "applied_periods_applied")?;
673 Some(
674 u8::try_from(total - applied)
675 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
676 )
677 }
678 };
679 let snapshot = discount_snapshot_from_values(row, "applied", code, duration)?;
680 AppliedSubscriptionDiscount::new(
681 row.try_get::<Option<Uuid>, _>("applied_claim_id")?
682 .map(DiscountClaimId::new),
683 snapshot,
684 periods_remaining,
685 )
686 .map(Some)
687 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))
688}
689
690fn discount_snapshot(
691 row: &PgRow,
692 prefix: &str,
693) -> Result<SubscriptionDiscountSnapshot, EntitlementQueryError> {
694 let code = required::<String>(row, &format!("{prefix}_code"))?;
695 let duration = discount_duration(
696 &required::<String>(row, &format!("{prefix}_duration"))?,
697 row.try_get(format!("{prefix}_duration_months").as_str())?,
698 )?;
699 discount_snapshot_from_values(row, prefix, code, duration)
700}
701
702fn discount_snapshot_from_values(
703 row: &PgRow,
704 prefix: &str,
705 code: String,
706 duration: SubscriptionDiscountDuration,
707) -> Result<SubscriptionDiscountSnapshot, EntitlementQueryError> {
708 let kind = match required::<String>(row, &format!("{prefix}_kind"))?.as_str() {
709 "amount_off" => SubscriptionDiscountKind::AmountOffCents(
710 PositiveDiscountCents::new(required(row, &format!("{prefix}_amount_off_cents"))?)
711 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
712 ),
713 "percent_off" => SubscriptionDiscountKind::PercentOffBasisPoints(
714 PercentOffBasisPoints::new(
715 u16::try_from(required::<i32>(row, &format!("{prefix}_percent_off_bps"))?)
716 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
717 )
718 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
719 ),
720 _ => {
721 return Err(EntitlementQueryError::InvalidState(
722 INVALID_ENTITLEMENT_STATE,
723 ));
724 }
725 };
726 let currency = CurrencyCode::new(&required::<String>(row, &format!("{prefix}_currency"))?)
727 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?;
728 SubscriptionDiscountSnapshot::new(
729 SubscriptionDiscountCode::new(&code)
730 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
731 row.try_get(format!("{prefix}_label").as_str())?,
732 kind,
733 duration,
734 ChargeAmount::new(
735 required(row, &format!("{prefix}_base_amount_cents"))?,
736 currency,
737 )
738 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
739 ChargeAmount::new(
740 required(row, &format!("{prefix}_discounted_amount_cents"))?,
741 currency,
742 )
743 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
744 )
745 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))
746}
747
748fn discount_duration(
749 duration: &str,
750 duration_months: Option<i32>,
751) -> Result<SubscriptionDiscountDuration, EntitlementQueryError> {
752 match (duration, duration_months) {
753 ("indefinite", None) => Ok(SubscriptionDiscountDuration::Indefinite),
754 ("limited_months", Some(months)) => Ok(SubscriptionDiscountDuration::LimitedMonths(
755 LimitedDiscountMonths::new(
756 u8::try_from(months)
757 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
758 )
759 .map_err(|_| EntitlementQueryError::InvalidState(INVALID_ENTITLEMENT_STATE))?,
760 )),
761 _ => Err(EntitlementQueryError::InvalidState(
762 INVALID_ENTITLEMENT_STATE,
763 )),
764 }
765}
766
767fn required<T>(row: &PgRow, column: &str) -> Result<T, EntitlementQueryError>
768where
769 for<'r> T: sqlx::Decode<'r, sqlx::Postgres> + sqlx::Type<sqlx::Postgres>,
770{
771 row.try_get::<Option<T>, _>(column)?
772 .ok_or(EntitlementQueryError::InvalidState(
773 INVALID_ENTITLEMENT_STATE,
774 ))
775}
776
777#[cfg(test)]
778mod tests;