Skip to main content

max / makenotwork

Fan+ self-service: cancel/resume + Stripe billing portal Adds dashboard-driven cancellation that leaves Fan+ active through the current paid period (no proration), a Resume button to undo before period end, and a Manage Billing button that hands off to the Stripe-hosted customer portal for payment-method changes and invoice history. - Migration 114: cancel_at_period_end BOOLEAN on fan_plus_subscriptions - PaymentProvider: set_platform_cancel_at_period_end, create_billing_portal_session - Routes: POST /stripe/fan-plus/{cancel,resume}, POST /stripe/billing-portal - Webhook customer.subscription.updated syncs cancel_at_period_end so cancellations made via the customer portal flow back into the DB - Dashboard account tab: compact Fan+ pane (period end + Cancel/Resume/ Manage billing). Non-subscribers see a one-line link, no upsell. - Tests: 149 lines added to workflows/fan_plus.rs covering the new flows - Template copy: pricing/Fan+ pages use "membership" instead of "subscription" to match the project's membership-vs-subscription rule Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-15 13:33 UTC
Commit: a63168b54cc4eb8bd2ad42d3b303310ee8629789
Parent: d3ff18a
16 files changed, +428 insertions, -10 deletions
@@ -0,0 +1,9 @@
1 + -- Fan+ self-service cancellation: track "cancel pending" distinctly from
2 + -- "canceled" so the dashboard can show "active until X (cancel scheduled)".
3 + --
4 + -- Set when the user clicks Cancel; cleared if they click Resume before the
5 + -- period ends. Stripe's `customer.subscription.updated` webhook keeps this
6 + -- in sync, so external changes (e.g., via the customer portal) flow back.
7 +
8 + ALTER TABLE fan_plus_subscriptions
9 + ADD COLUMN cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE;
@@ -142,6 +142,30 @@ pub async fn is_fan_plus_active(pool: &PgPool, user_id: UserId) -> Result<bool>
142 142 Ok(exists)
143 143 }
144 144
145 + /// Mark a Fan+ subscription as scheduled to cancel at period end (or undo).
146 + ///
147 + /// Sets the local flag; Stripe is the source of truth and re-asserts it via
148 + /// the `customer.subscription.updated` webhook. Called from the dashboard
149 + /// Cancel/Resume buttons and from the webhook handler.
150 + #[tracing::instrument(skip_all)]
151 + pub async fn set_cancel_at_period_end(
152 + pool: &PgPool,
153 + stripe_subscription_id: &str,
154 + cancel: bool,
155 + ) -> Result<Option<DbFanPlusSubscription>> {
156 + let sub = sqlx::query_as::<_, DbFanPlusSubscription>(
157 + "UPDATE fan_plus_subscriptions
158 + SET cancel_at_period_end = $2
159 + WHERE stripe_subscription_id = $1
160 + RETURNING *",
161 + )
162 + .bind(stripe_subscription_id)
163 + .bind(cancel)
164 + .fetch_optional(pool)
165 + .await?;
166 + Ok(sub)
167 + }
168 +
145 169 /// Get a user's Fan+ subscription (any status).
146 170 #[tracing::instrument(skip_all)]
147 171 pub async fn get_fan_plus_by_user(
@@ -164,6 +164,10 @@ pub struct DbFanPlusSubscription {
164 164 pub created_at: DateTime<Utc>,
165 165 /// When the subscription was canceled.
166 166 pub canceled_at: Option<DateTime<Utc>>,
167 + /// Whether the subscription is scheduled to cancel at `current_period_end`.
168 + /// True after the user clicks Cancel on the dashboard or in Stripe's
169 + /// customer portal; cleared if they click Resume before the period ends.
170 + pub cancel_at_period_end: bool,
167 171 }
168 172
169 173 /// A creator tier subscription (platform billing for creator features).
@@ -348,6 +348,79 @@ impl StripeClient {
348 348 Ok(())
349 349 }
350 350
351 + /// Set or clear `cancel_at_period_end` on a platform-level subscription
352 + /// (Fan+, creator tier). Used by Fan+ self-service cancel/resume on the
353 + /// dashboard. No `Stripe-Account` header — the subscription belongs to
354 + /// the platform.
355 + #[tracing::instrument(skip_all, name = "payments::set_platform_cancel_at_period_end")]
356 + pub async fn set_platform_cancel_at_period_end(
357 + &self,
358 + stripe_sub_id: &str,
359 + cancel: bool,
360 + ) -> Result<()> {
361 + let url = format!("https://api.stripe.com/v1/subscriptions/{}", stripe_sub_id);
362 + let resp = reqwest::Client::new()
363 + .post(&url)
364 + .header("Authorization", format!("Bearer {}", self.config.secret_key))
365 + .form(&[("cancel_at_period_end", if cancel { "true" } else { "false" })])
366 + .timeout(std::time::Duration::from_secs(30))
367 + .send()
368 + .await
369 + .map_err(|e| {
370 + tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set platform cancel_at_period_end");
371 + AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation"))
372 + })?;
373 +
374 + if !resp.status().is_success() {
375 + let body = resp.text().await.unwrap_or_default();
376 + tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, body = %body, "Stripe set platform cancel_at_period_end returned error");
377 + return Err(AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation")));
378 + }
379 +
380 + Ok(())
381 + }
382 +
383 + /// Create a Stripe Billing Portal session for a customer. The returned URL
384 + /// is a Stripe-hosted page where the customer can update payment methods,
385 + /// view invoices, and (if portal config permits) cancel subscriptions.
386 + ///
387 + /// Requires Customer Portal to be configured in the Stripe dashboard.
388 + #[tracing::instrument(skip_all, name = "payments::create_billing_portal_session")]
389 + pub async fn create_billing_portal_session(
390 + &self,
391 + stripe_customer_id: &str,
392 + return_url: &str,
393 + ) -> Result<String> {
394 + let resp = reqwest::Client::new()
395 + .post("https://api.stripe.com/v1/billing_portal/sessions")
396 + .header("Authorization", format!("Bearer {}", self.config.secret_key))
397 + .form(&[
398 + ("customer", stripe_customer_id),
399 + ("return_url", return_url),
400 + ])
401 + .timeout(std::time::Duration::from_secs(30))
402 + .send()
403 + .await
404 + .map_err(|e| {
405 + tracing::error!(error = ?e, "failed to create billing portal session");
406 + AppError::Internal(anyhow::anyhow!("Failed to create billing portal session"))
407 + })?;
408 +
409 + if !resp.status().is_success() {
410 + let body = resp.text().await.unwrap_or_default();
411 + tracing::error!(body = %body, "Stripe billing portal returned error");
412 + return Err(AppError::Internal(anyhow::anyhow!("Failed to create billing portal session")));
413 + }
414 +
415 + #[derive(serde::Deserialize)]
416 + struct PortalResp { url: String }
417 + let parsed: PortalResp = resp.json().await.map_err(|e| {
418 + tracing::error!(error = ?e, "billing portal parse failed");
419 + AppError::Internal(anyhow::anyhow!("Billing portal response parse error"))
420 + })?;
421 + Ok(parsed.url)
422 + }
423 +
351 424 /// Set or clear `cancel_at_period_end` on a subscription (connected account).
352 425 ///
353 426 /// Used for creator pause (cancel=true: fans keep access through their paid
@@ -84,6 +84,10 @@ pub trait PaymentProvider: Send + Sync {
84 84 async fn update_app_sync_subscription_tier(&self, stripe_sub_id: &str, product_name: &str, price_cents: i64, interval: &str) -> crate::error::Result<()>;
85 85 /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account.
86 86 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()>;
87 + /// Set or clear `cancel_at_period_end` on a platform subscription (Fan+, creator tier).
88 + async fn set_platform_cancel_at_period_end(&self, stripe_sub_id: &str, cancel: bool) -> crate::error::Result<()>;
89 + /// Create a Stripe-hosted billing portal session. Returns the URL to redirect to.
90 + async fn create_billing_portal_session(&self, stripe_customer_id: &str, return_url: &str) -> crate::error::Result<String>;
87 91
88 92 // Refunds
89 93 async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()>;
@@ -192,6 +196,14 @@ impl PaymentProvider for StripeClient {
192 196 StripeClient::cancel_platform_subscription(self, stripe_sub_id).await
193 197 }
194 198
199 + async fn set_platform_cancel_at_period_end(&self, stripe_sub_id: &str, cancel: bool) -> crate::error::Result<()> {
200 + StripeClient::set_platform_cancel_at_period_end(self, stripe_sub_id, cancel).await
201 + }
202 +
203 + async fn create_billing_portal_session(&self, stripe_customer_id: &str, return_url: &str) -> crate::error::Result<String> {
204 + StripeClient::create_billing_portal_session(self, stripe_customer_id, return_url).await
205 + }
206 +
195 207 async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()> {
196 208 StripeClient::create_refund(self, payment_intent_id, connected_account_id).await
197 209 }
@@ -211,6 +211,16 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account(
211 211 })
212 212 .collect();
213 213
214 + let fan_plus = db::fan_plus::get_fan_plus_by_user(&state.db, session_user.id)
215 + .await?
216 + .filter(|sub| matches!(sub.status, db::SubscriptionStatus::Active | db::SubscriptionStatus::PastDue))
217 + .map(|sub| crate::templates::FanPlusPaneView {
218 + period_end: sub.current_period_end.map(|d| d.format("%b %-d, %Y").to_string()),
219 + cancel_at_period_end: sub.cancel_at_period_end,
220 + });
221 +
222 + let csrf_token = crate::csrf::get_or_create_token(&session).await.ok();
223 +
214 224 Ok(UserAccountTabTemplate {
215 225 user,
216 226 sessions,
@@ -220,6 +230,8 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account(
220 230 moderation_active,
221 231 moderation_history,
222 232 creator_paused: db_user.is_creator_paused(),
233 + fan_plus,
234 + csrf_token,
223 235 })
224 236 }
225 237
@@ -10,7 +10,8 @@ pub(in crate::routes::stripe) use item::create_checkout;
10 10 pub(crate) use item::grant_bundle_items;
11 11 pub(in crate::routes::stripe) use project::create_project_checkout;
12 12 pub(in crate::routes::stripe) use subscriptions::{
13 - create_creator_tier_checkout, create_fan_plus_checkout, create_subscription_checkout,
13 + cancel_fan_plus, create_creator_tier_checkout, create_fan_plus_checkout,
14 + create_subscription_checkout, open_billing_portal, resume_fan_plus,
14 15 };
15 16 pub(in crate::routes::stripe) use tips::create_tip_checkout;
16 17 pub(in crate::routes::stripe) use cart::{create_cart_checkout, create_cart_checkout_all};
@@ -51,6 +51,77 @@ pub(in crate::routes::stripe) async fn create_fan_plus_checkout(
51 51 Ok(Redirect::to(&checkout_url).into_response())
52 52 }
53 53
54 + /// POST /stripe/fan-plus/cancel -- Schedule Fan+ to cancel at period end.
55 + ///
56 + /// Self-service: leaves the subscription active through the current paid
57 + /// period (no proration). The user can resume before period end to undo.
58 + /// Stripe's `customer.subscription.updated` webhook keeps the local flag in
59 + /// sync if the user later cancels via the customer portal instead.
60 + #[tracing::instrument(skip_all, name = "stripe::fan_plus_cancel")]
61 + pub(in crate::routes::stripe) async fn cancel_fan_plus(
62 + State(state): State<AppState>,
63 + AuthUser(user): AuthUser,
64 + ) -> Result<Redirect> {
65 + let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id)
66 + .await?
67 + .ok_or_else(|| AppError::BadRequest("No active Fan+ subscription".to_string()))?;
68 +
69 + let stripe = state.stripe.as_ref()
70 + .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
71 +
72 + stripe
73 + .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, true)
74 + .await?;
75 + db::fan_plus::set_cancel_at_period_end(&state.db, &sub.stripe_subscription_id, true).await?;
76 +
77 + Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+cancellation+scheduled"))
78 + }
79 +
80 + /// POST /stripe/fan-plus/resume -- Undo a scheduled cancellation.
81 + #[tracing::instrument(skip_all, name = "stripe::fan_plus_resume")]
82 + pub(in crate::routes::stripe) async fn resume_fan_plus(
83 + State(state): State<AppState>,
84 + AuthUser(user): AuthUser,
85 + ) -> Result<Redirect> {
86 + let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id)
87 + .await?
88 + .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?;
89 +
90 + let stripe = state.stripe.as_ref()
91 + .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
92 +
93 + stripe
94 + .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, false)
95 + .await?;
96 + db::fan_plus::set_cancel_at_period_end(&state.db, &sub.stripe_subscription_id, false).await?;
97 +
98 + Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+resumed"))
99 + }
100 +
101 + /// POST /stripe/billing-portal -- Open the Stripe customer portal.
102 + ///
103 + /// Stripe-hosted: handles payment method updates, invoice history, and
104 + /// (if configured in the dashboard) subscription cancellation. Routes from
105 + /// the dashboard Fan+ pane.
106 + #[tracing::instrument(skip_all, name = "stripe::billing_portal")]
107 + pub(in crate::routes::stripe) async fn open_billing_portal(
108 + State(state): State<AppState>,
109 + AuthUser(user): AuthUser,
110 + ) -> Result<Redirect> {
111 + let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id)
112 + .await?
113 + .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?;
114 +
115 + let stripe = state.stripe.as_ref()
116 + .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
117 +
118 + let return_url = format!("{}/dashboard?tab=account", state.config.host_url);
119 + let url = stripe
120 + .create_billing_portal_session(&sub.stripe_customer_id, &return_url)
121 + .await?;
122 + Ok(Redirect::to(&url))
123 + }
124 +
54 125 /// Form data for creator tier checkout.
55 126 #[derive(Debug, Deserialize)]
56 127 pub(in crate::routes::stripe) struct CreatorTierForm {
@@ -27,6 +27,9 @@ pub fn stripe_routes() -> Router<AppState> {
27 27 .route("/stripe/connect/refresh", get(connect::stripe_connect_refresh))
28 28 // Checkout flow (idempotency handled by global middleware in metrics.rs)
29 29 .route("/stripe/fan-plus", post(checkout::create_fan_plus_checkout))
30 + .route("/stripe/fan-plus/cancel", post(checkout::cancel_fan_plus))
31 + .route("/stripe/fan-plus/resume", post(checkout::resume_fan_plus))
32 + .route("/stripe/billing-portal", post(checkout::open_billing_portal))
30 33 .route("/stripe/creator-tier", post(checkout::create_creator_tier_checkout))
31 34 .route("/stripe/checkout/{item_id}", post(checkout::create_checkout))
32 35 .route("/stripe/checkout/project/{project_id}", post(checkout::create_project_checkout))
@@ -27,6 +27,12 @@ pub(super) async fn handle_subscription_updated(
27 27 let period_end = stripe_timestamp(sub.current_period_end);
28 28 db::fan_plus::update_fan_plus_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update fan plus period")?;
29 29
30 + // Keep the dashboard flag in sync with Stripe — covers cancellation
31 + // initiated via the customer portal as well as our dashboard route.
32 + db::fan_plus::set_cancel_at_period_end(&state.db, &stripe_sub_id, sub.cancel_at_period_end)
33 + .await
34 + .context("sync fan plus cancel_at_period_end")?;
35 +
30 36 if let Err(e) = db::subscriptions::log_subscription_event(
31 37 &state.db, None, event_id, "customer.subscription.updated.fan_plus",
32 38 &serde_json::json!({"status": status_str}),
@@ -205,6 +205,25 @@ pub struct UserAccountTabTemplate {
205 205 pub moderation_history: Vec<ModerationActionView>,
206 206 /// Whether this creator has voluntarily paused their account.
207 207 pub creator_paused: bool,
208 + /// Compact Fan+ pane state for the account tab. `None` = the user is not
209 + /// a Fan+ subscriber; the tab renders a one-line "Support the platform"
210 + /// link instead of the active pane.
211 + pub fan_plus: Option<FanPlusPaneView>,
212 + /// CSRF token for the Fan+ cancel/resume/billing-portal form posts. The
213 + /// rest of the tab uses HTMX (which sends `X-CSRF-Token` automatically),
214 + /// but these are vanilla form POSTs that redirect.
215 + pub csrf_token: super::CsrfTokenOption,
216 + }
217 +
218 + /// Compact dashboard view of the user's Fan+ subscription. Lives under the
219 + /// account tab; intentionally small, no upsell copy.
220 + pub struct FanPlusPaneView {
221 + /// Current period end as a formatted date (e.g., "Dec 14, 2026"). `None`
222 + /// when Stripe hasn't reported a period yet (rare; just after checkout).
223 + pub period_end: Option<String>,
224 + /// Subscription is scheduled to cancel at `period_end`. Drives the Resume
225 + /// affordance.
226 + pub cancel_at_period_end: bool,
208 227 }
209 228
210 229 /// View model for a moderation action displayed on the settings page.
@@ -96,11 +96,11 @@
96 96 {% if let Some(token) = csrf_token %}
97 97 <input type="hidden" name="_csrf" value="{{ token }}">
98 98 {% endif %}
99 - <button type="submit" class="subscribe-btn">Subscribe to Fan+</button>
99 + <button type="submit" class="subscribe-btn">Join Fan+</button>
100 100 </form>
101 101 </div>
102 102 {% else %}
103 - <p class="login-link"><a href="/login">Log in</a> to subscribe.</p>
103 + <p class="login-link"><a href="/login">Log in</a> to join.</p>
104 104 {% endif %}
105 105 {% endif %}
106 106 </div>
@@ -52,7 +52,7 @@
52 52 <h2 class="section-label">You keep</h2>
53 53 <div class="mnw-summary">
54 54 <div class="mnw-keep" id="mnw-keep">$940.00</div>
55 - <div class="mnw-detail" id="mnw-detail">of every $1,000 after processing fees (~3%) and $10/mo subscription</div>
55 + <div class="mnw-detail" id="mnw-detail">of every $1,000 after processing fees (~3%) and $10/mo membership</div>
56 56 </div>
57 57 </div>
58 58
@@ -230,13 +230,13 @@
230 230 }
231 231 }
232 232
233 - // MNW net: revenue minus processing fees minus monthly subscription
233 + // MNW net: revenue minus processing fees minus monthly membership
234 234 var mnwNet = stripeNet(revenue) - tierCost;
235 235 if (revenue <= 0) mnwNet = -tierCost;
236 236
237 237 document.getElementById('mnw-keep').textContent = fmt(Math.max(0, mnwNet));
238 238 document.getElementById('mnw-detail').textContent =
239 - 'of every ' + fmtWhole(revenue) + ' after processing fees (~3%) and ' + fmt(tierCost) + '/mo subscription';
239 + 'of every ' + fmtWhole(revenue) + ' after processing fees (~3%) and ' + fmt(tierCost) + '/mo membership';
240 240
241 241 // Build comparison rows sorted by net (descending)
242 242 var rows = [];
@@ -121,6 +121,40 @@
121 121 <input type="text" id="username" value="{{ user.username }}" disabled>
122 122 <div class="hint">Username cannot be changed</div>
123 123 </div>
124 + <div class="form-group">
125 + <label>Fan+ membership</label>
126 + {% if let Some(fp) = fan_plus %}
127 + <div class="hint" style="margin-bottom: 0.5rem;">
128 + {% if fp.cancel_at_period_end %}
129 + Active{% if let Some(end) = fp.period_end %} until {{ end }}{% endif %} — cancellation scheduled.
130 + {% else %}
131 + Active{% if let Some(end) = fp.period_end %} until {{ end }}{% endif %}.
132 + {% endif %}
133 + </div>
134 + <div class="form-inline-row">
135 + {% if fp.cancel_at_period_end %}
136 + <form method="post" action="/stripe/fan-plus/resume" style="display:inline">
137 + {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
138 + <button type="submit" class="secondary small">Resume</button>
139 + </form>
140 + {% else %}
141 + <form method="post" action="/stripe/fan-plus/cancel" style="display:inline"
142 + hx-confirm="Cancel Fan+ at the end of the current billing period?">
143 + {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
144 + <button type="submit" class="secondary small">Cancel</button>
145 + </form>
146 + {% endif %}
147 + <form method="post" action="/stripe/billing-portal" style="display:inline">
148 + {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
149 + <button type="submit" class="secondary small">Manage billing</button>
150 + </form>
151 + </div>
152 + {% else %}
153 + <div class="hint">
154 + Not subscribed. <a href="/fan-plus">Learn about Fan+</a>.
155 + </div>
156 + {% endif %}
157 + </div>
124 158 </div>
125 159
126 160 <div class="section-group-label">Preferences</div>
@@ -192,6 +192,15 @@ impl PaymentProvider for MockPaymentProvider {
192 192 Ok(())
193 193 }
194 194
195 + async fn set_platform_cancel_at_period_end(&self, _stripe_sub_id: &str, _cancel: bool) -> Result<()> {
196 + Ok(())
197 + }
198 +
199 + async fn create_billing_portal_session(&self, _stripe_customer_id: &str, return_url: &str) -> Result<String> {
200 + // Echo a deterministic URL so tests can assert the redirect target.
201 + Ok(format!("https://billing.stripe.test/portal?return={}", urlencoding::encode(return_url)))
202 + }
203 +
195 204 async fn create_refund(&self, _payment_intent_id: &str, _connected_account_id: &str) -> Result<()> {
196 205 Ok(())
197 206 }
@@ -15,7 +15,7 @@ async fn fan_plus_page_renders_for_anonymous() {
15 15 assert_eq!(resp.status, 200);
16 16 assert!(resp.text.contains("Fan+"));
17 17 assert!(resp.text.contains("Log in"));
18 - assert!(!resp.text.contains("Subscribe to Fan+"));
18 + assert!(!resp.text.contains("Join Fan+"));
19 19 }
20 20
21 21 #[tokio::test]
@@ -25,7 +25,7 @@ async fn fan_plus_page_renders_subscribe_button_for_user() {
25 25
26 26 let resp = h.client.get("/fan-plus").await;
27 27 assert_eq!(resp.status, 200);
28 - assert!(resp.text.contains("Subscribe to Fan+"));
28 + assert!(resp.text.contains("Join Fan+"));
29 29 assert!(!resp.text.contains("membership is active"));
30 30 }
31 31
@@ -47,7 +47,7 @@ async fn fan_plus_page_shows_active_status_for_subscriber() {
47 47 let resp = h.client.get("/fan-plus").await;
48 48 assert_eq!(resp.status, 200);
49 49 assert!(resp.text.contains("membership is active"));
50 - assert!(!resp.text.contains("Subscribe to Fan+"));
50 + assert!(!resp.text.contains("Join Fan+"));
51 51 }
52 52
53 53 #[tokio::test]
@@ -406,6 +406,147 @@ async fn canceled_fan_plus_not_shown_as_active() {
406 406 let resp = h.client.get("/fan-plus").await;
407 407 assert_eq!(resp.status, 200);
408 408 // Should show subscribe button, not active status
409 - assert!(resp.text.contains("Subscribe to Fan+"));
409 + assert!(resp.text.contains("Join Fan+"));
410 410 assert!(!resp.text.contains("membership is active"));
411 411 }
412 +
413 + // ── Self-service cancel / resume / billing portal ──
414 + //
415 + // These routes underpin the small dashboard pane added in this step. The
416 + // MockPaymentProvider ack's the Stripe calls so we only need to check our DB
417 + // state and HTTP responses.
418 +
419 + async fn seed_active_fan_plus(h: &TestHarness, user_id: makenotwork::db::UserId, sub_id: &str) {
420 + sqlx::query(
421 + "INSERT INTO fan_plus_subscriptions \
422 + (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \
423 + VALUES ($1, $2, $3, 'active', NOW() + interval '30 days')",
424 + )
425 + .bind(user_id)
426 + .bind(sub_id)
427 + .bind(format!("cus_{sub_id}"))
428 + .execute(&h.db)
429 + .await
430 + .unwrap();
431 + }
432 +
433 + #[tokio::test]
434 + async fn fan_plus_cancel_sets_cancel_at_period_end() {
435 + let mut h = TestHarness::with_mocks().await;
436 + let user_id = h.signup("cancuser", "canc@example.com", "password123").await;
437 + seed_active_fan_plus(&h, user_id, "sub_cancel_1").await;
438 +
439 + h.client.get("/dashboard").await; // prime CSRF
440 + let csrf = h.client.csrf_token().expect("csrf").to_string();
441 + let resp = h.client.post_form("/stripe/fan-plus/cancel", &format!("_csrf={csrf}")).await;
442 + assert!(resp.status.is_redirection(), "status: {} body: {}", resp.status, resp.text);
443 +
444 + let pending: bool = sqlx::query_scalar(
445 + "SELECT cancel_at_period_end FROM fan_plus_subscriptions WHERE user_id = $1",
446 + )
447 + .bind(user_id)
448 + .fetch_one(&h.db)
449 + .await
450 + .unwrap();
451 + assert!(pending);
452 + }
453 +
454 + #[tokio::test]
455 + async fn fan_plus_resume_clears_cancel_flag() {
456 + let mut h = TestHarness::with_mocks().await;
457 + let user_id = h.signup("resuuser", "resu@example.com", "password123").await;
458 + sqlx::query(
459 + "INSERT INTO fan_plus_subscriptions \
460 + (user_id, stripe_subscription_id, stripe_customer_id, status, cancel_at_period_end) \
461 + VALUES ($1, 'sub_resume_1', 'cus_resume_1', 'active', TRUE)",
462 + )
463 + .bind(user_id)
464 + .execute(&h.db)
465 + .await
466 + .unwrap();
467 +
468 + h.client.get("/dashboard").await;
469 + let csrf = h.client.csrf_token().expect("csrf").to_string();
470 + let resp = h.client.post_form("/stripe/fan-plus/resume", &format!("_csrf={csrf}")).await;
471 + assert!(resp.status.is_redirection(), "status: {}", resp.status);
472 +
473 + let pending: bool = sqlx::query_scalar(
474 + "SELECT cancel_at_period_end FROM fan_plus_subscriptions WHERE user_id = $1",
475 + )
476 + .bind(user_id)
477 + .fetch_one(&h.db)
478 + .await
479 + .unwrap();
480 + assert!(!pending);
481 + }
482 +
483 + #[tokio::test]
484 + async fn fan_plus_cancel_requires_active_subscription() {
485 + let mut h = TestHarness::with_mocks().await;
486 + h.signup("nosub", "nosub@example.com", "password123").await;
487 +
488 + h.client.get("/dashboard").await;
489 + let csrf = h.client.csrf_token().expect("csrf").to_string();
490 + let resp = h.client.post_form("/stripe/fan-plus/cancel", &format!("_csrf={csrf}")).await;
491 + // BadRequest from "No active Fan+ subscription"
492 + assert_eq!(resp.status.as_u16(), 400);
493 + }
494 +
495 + #[tokio::test]
496 + async fn billing_portal_redirects_to_stripe() {
497 + let mut h = TestHarness::with_mocks().await;
498 + let user_id = h.signup("portaluser", "portal@example.com", "password123").await;
499 + seed_active_fan_plus(&h, user_id, "sub_portal_1").await;
500 +
501 + h.client.get("/dashboard").await;
502 + let csrf = h.client.csrf_token().expect("csrf").to_string();
503 + let resp = h.client.post_form("/stripe/billing-portal", &format!("_csrf={csrf}")).await;
504 + assert!(resp.status.is_redirection(), "status: {}", resp.status);
505 + let location = resp.header("location").expect("Location header");
506 + assert!(location.starts_with("https://billing.stripe.test/portal"));
507 + }
508 +
509 + #[tokio::test]
510 + async fn dashboard_account_tab_shows_fan_plus_pane_for_subscriber() {
511 + let mut h = TestHarness::new().await;
512 + let user_id = h.signup("paneuser", "pane@example.com", "password123").await;
513 + seed_active_fan_plus(&h, user_id, "sub_pane_1").await;
514 +
515 + let resp = h.client.get("/dashboard/tabs/account").await;
516 + assert_eq!(resp.status, 200);
517 + assert!(resp.text.contains("Fan+ membership"));
518 + assert!(resp.text.contains("Cancel"));
519 + assert!(resp.text.contains("Manage billing"));
520 + assert!(!resp.text.contains("Learn about Fan+"));
521 + }
522 +
523 + #[tokio::test]
524 + async fn dashboard_account_tab_shows_resume_when_cancel_pending() {
525 + let mut h = TestHarness::new().await;
526 + let user_id = h.signup("pendinguser", "pending@example.com", "password123").await;
527 + sqlx::query(
528 + "INSERT INTO fan_plus_subscriptions \
529 + (user_id, stripe_subscription_id, stripe_customer_id, status, cancel_at_period_end, current_period_end) \
530 + VALUES ($1, 'sub_pending_1', 'cus_pending_1', 'active', TRUE, NOW() + interval '15 days')",
531 + )
532 + .bind(user_id)
533 + .execute(&h.db)
534 + .await
535 + .unwrap();
536 +
537 + let resp = h.client.get("/dashboard/tabs/account").await;
538 + assert_eq!(resp.status, 200);
539 + assert!(resp.text.contains("cancellation scheduled"));
540 + assert!(resp.text.contains("Resume"));
541 + }
542 +
543 + #[tokio::test]
544 + async fn dashboard_account_tab_shows_upsell_when_not_subscribed() {
545 + let mut h = TestHarness::new().await;
546 + h.signup("notsub", "notsub@example.com", "password123").await;
547 +
548 + let resp = h.client.get("/dashboard/tabs/account").await;
549 + assert_eq!(resp.status, 200);
550 + assert!(resp.text.contains("Learn about Fan+"));
551 + assert!(!resp.text.contains("Manage billing"));
552 + }