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