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