Skip to main content

syrup_rail_postgres/
entitlement.rs

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
59/// A top-level PostgreSQL transaction awaiting entitlement admission.
60///
61/// Create this transaction directly from a pool with [`Self::begin`]. It can
62/// carry host preparatory writes, but deliberately has no commit operation.
63/// Passing it to [`require_entitlement_for_update`] either rolls it back or
64/// transforms it into an [`AdmittedEntitlementWriteTransaction`].
65pub 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    /// Starts a top-level transaction reserved for an entitlement-protected write.
79    pub async fn begin(pool: &PgPool) -> Result<Self, sqlx::Error> {
80        Ok(Self {
81            inner: pool.begin().await?,
82        })
83    }
84
85    /// Borrows the transaction connection for preparatory host database work.
86    ///
87    /// Callers must finish any nested savepoint before returning this value to
88    /// Syrup Rail. Leaking a savepoint would violate SQLx's transaction
89    /// lifecycle contract.
90    pub fn connection(&mut self) -> &mut PgConnection {
91        &mut self.inner
92    }
93
94    /// Explicitly rolls back the pending transaction.
95    pub async fn rollback(self) -> Result<(), sqlx::Error> {
96        self.inner.rollback().await
97    }
98}
99
100/// A top-level transaction that passed entitlement admission.
101///
102/// Its entitlement rows and aggregate advisory lock remain held until this
103/// value is committed or rolled back. The protected host mutation must use
104/// [`Self::connection`] on this value.
105pub 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    /// Borrows the admitted transaction connection for the host-owned protected mutation.
119    ///
120    /// Callers must finish any nested savepoint before committing or rolling
121    /// back this outer transaction. Leaking a savepoint would violate SQLx's
122    /// transaction lifecycle contract.
123    pub fn connection(&mut self) -> &mut PgConnection {
124        &mut self.inner
125    }
126
127    /// Commits the protected transaction and releases its entitlement locks.
128    pub async fn commit(self) -> Result<(), sqlx::Error> {
129        self.inner.commit().await
130    }
131
132    /// Rolls back the protected transaction and releases its entitlement locks.
133    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
149/// Locks and revalidates one exact entitlement inside a top-level transaction.
150///
151/// This function consumes the transaction and returns it only after successful
152/// admission. The returned transaction retains the accepted paid or grant rows
153/// and their aggregate advisory domain until the caller commits or rolls back
154/// its protected mutation. Every completed denial or storage failure awaits a
155/// full rollback. Canceling the future drops the owned transaction and queues a
156/// full rollback, so a caller cannot continue an unguarded write.
157///
158/// The guard temporarily applies a 250 millisecond `lock_timeout` and restores
159/// the caller's prior transaction-local value before returning successfully.
160pub 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
332/// Loads one exact scope/subscriber/plan entitlement from a single database snapshot.
333///
334/// Gateway availability and host authentication are intentionally outside this query.
335pub async fn entitlement(
336    pool: &PgPool,
337    query: &EntitlementQuery,
338) -> Result<Entitlement, EntitlementQueryError> {
339    entitlement_on_executor(pool, query).await
340}
341
342/// Runs the canonical entitlement projection on a caller-owned connection.
343///
344/// This is crate-visible so a composite read can retain the exact entitlement
345/// semantics while sharing one PostgreSQL snapshot with its other projections.
346pub(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;