Skip to main content

max / makenotwork

4.4 KB · 143 lines History Blame Raw
1 //! Fan+ consumer subscription queries.
2
3 use sqlx::PgPool;
4
5 use super::id_types::*;
6 use super::models::DbFanPlusSubscription;
7 use crate::error::Result;
8
9 /// Create or reactivate a Fan+ subscription record.
10 ///
11 /// Uses ON CONFLICT DO UPDATE on the user_id unique constraint to handle
12 /// both duplicate webhooks and re-subscription after cancellation.
13 #[tracing::instrument(skip_all)]
14 pub async fn create_fan_plus_subscription<'e>(
15 executor: impl sqlx::PgExecutor<'e>,
16 user_id: UserId,
17 stripe_subscription_id: &str,
18 stripe_customer_id: &str,
19 ) -> Result<Option<DbFanPlusSubscription>> {
20 let sub = sqlx::query_as::<_, DbFanPlusSubscription>(
21 r#"
22 INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id)
23 VALUES ($1, $2, $3)
24 ON CONFLICT (user_id) DO UPDATE
25 SET stripe_subscription_id = EXCLUDED.stripe_subscription_id,
26 stripe_customer_id = EXCLUDED.stripe_customer_id,
27 status = 'active',
28 canceled_at = NULL
29 RETURNING *
30 "#,
31 )
32 .bind(user_id)
33 .bind(stripe_subscription_id)
34 .bind(stripe_customer_id)
35 .fetch_optional(executor)
36 .await?;
37
38 Ok(sub)
39 }
40
41 /// Look up a Fan+ subscription by its Stripe subscription ID.
42 #[tracing::instrument(skip_all)]
43 pub async fn get_fan_plus_by_stripe_id(
44 pool: &PgPool,
45 stripe_subscription_id: &str,
46 ) -> Result<Option<DbFanPlusSubscription>> {
47 let sub = sqlx::query_as::<_, DbFanPlusSubscription>(
48 "SELECT * FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
49 )
50 .bind(stripe_subscription_id)
51 .fetch_optional(pool)
52 .await?;
53
54 Ok(sub)
55 }
56
57 // Apply a Stripe-driven status and/or period update in one guarded statement.
58 // Fan+ was the sibling the Run #11 revival guard missed (Run #12 SERIOUS), and
59 // its period setter then lacked the guard too. Writing status + period together
60 // here closes both: a canceled Fan+ sub can be neither revived nor period-
61 // refreshed by an out-of-order webhook. Reactivation runs through
62 // `create_fan_plus_subscription`'s ON CONFLICT DO UPDATE at checkout, never this
63 // path. See `crate::db::subscription_writer`.
64 crate::db::subscription_writer::define_stripe_subscription_writer!(
65 apply_stripe_update,
66 "fan_plus_subscriptions",
67 DbFanPlusSubscription
68 );
69
70 /// Cancel a Fan+ subscription (set status + canceled_at).
71 #[tracing::instrument(skip_all)]
72 pub async fn cancel_fan_plus(
73 pool: &PgPool,
74 stripe_subscription_id: &str,
75 ) -> Result<Option<DbFanPlusSubscription>> {
76 let sub = sqlx::query_as::<_, DbFanPlusSubscription>(
77 r#"
78 UPDATE fan_plus_subscriptions
79 SET status = 'canceled', canceled_at = COALESCE(canceled_at, NOW())
80 WHERE stripe_subscription_id = $1
81 RETURNING *
82 "#,
83 )
84 .bind(stripe_subscription_id)
85 .fetch_optional(pool)
86 .await?;
87
88 Ok(sub)
89 }
90
91 /// Check whether a user has an active Fan+ subscription.
92 #[tracing::instrument(skip_all)]
93 pub async fn is_fan_plus_active(pool: &PgPool, user_id: UserId) -> Result<bool> {
94 let exists = sqlx::query_scalar::<_, bool>(
95 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions WHERE user_id = $1 AND status = 'active')",
96 )
97 .bind(user_id)
98 .fetch_one(pool)
99 .await?;
100
101 Ok(exists)
102 }
103
104 /// Mark a Fan+ subscription as scheduled to cancel at period end (or undo).
105 ///
106 /// Sets the local flag; Stripe is the source of truth and re-asserts it via
107 /// the `customer.subscription.updated` webhook. Called from the dashboard
108 /// Cancel/Resume buttons and from the webhook handler.
109 #[tracing::instrument(skip_all)]
110 pub async fn set_cancel_at_period_end(
111 pool: &PgPool,
112 stripe_subscription_id: &str,
113 cancel: bool,
114 ) -> Result<Option<DbFanPlusSubscription>> {
115 let sub = sqlx::query_as::<_, DbFanPlusSubscription>(
116 "UPDATE fan_plus_subscriptions
117 SET cancel_at_period_end = $2
118 WHERE stripe_subscription_id = $1
119 RETURNING *",
120 )
121 .bind(stripe_subscription_id)
122 .bind(cancel)
123 .fetch_optional(pool)
124 .await?;
125 Ok(sub)
126 }
127
128 /// Get a user's Fan+ subscription (any status).
129 #[tracing::instrument(skip_all)]
130 pub async fn get_fan_plus_by_user(
131 pool: &PgPool,
132 user_id: UserId,
133 ) -> Result<Option<DbFanPlusSubscription>> {
134 let sub = sqlx::query_as::<_, DbFanPlusSubscription>(
135 "SELECT * FROM fan_plus_subscriptions WHERE user_id = $1",
136 )
137 .bind(user_id)
138 .fetch_optional(pool)
139 .await?;
140
141 Ok(sub)
142 }
143