Skip to content

Add admin Manage Subscriptions page (view/pause/resume/cancel Stripe subscriptions) - #1443

Merged
ardalis merged 8 commits into
mainfrom
worktree-admin-manage-subscriptions
Jul 27, 2026
Merged

Add admin Manage Subscriptions page (view/pause/resume/cancel Stripe subscriptions)#1443
ardalis merged 8 commits into
mainfrom
worktree-admin-manage-subscriptions

Conversation

@ardalis

@ardalis ardalis commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an admin-only Manage Subscriptions page at /Admin/ManageSubscriptions (linked from the Admin menu) that works directly against the Stripe API:

  • Lists all billable Stripe subscriptions (active, past_due, trialing, unpaid) with customer email, plan, amount, interval, status badges (Paused / Cancels-at-period-end), and current period end. Pagination correctly advances StartingAfter (unlike the existing invoice list service, whose loop never advances the cursor).
  • Pause / Resume — uses Stripe pause_collection with behavior: "void" (Stripe stops charging and voids invoices generated while paused; membership access is unaffected since no customer.subscription.deleted event fires).
  • Cancel at Period End and Cancel Now — every action targets a specific subscription ID, unlike the existing email-based cancel that resolves at most one subscription per customer. This makes duplicate-active-subscription cases (double-billing) directly fixable from the admin UI.

Implementation

  • ISubscriptionHandlerService / StripeSubscriptionHandlerService in Infrastructure (mirrors the IInvoiceHandlerListService handler pattern), registered in DI.
  • StripeSubscriptionDto + SubscriptionProfile (AutoMapper) in Web, auto-registered via the existing AddMaps scan.
  • Razor Page with PRG POST handlers (Pause, Resume, Cancel, CancelNow), [TempData] status messages, StripeException handling, blank-ID guards, and confirm dialogs (static strings — no user data interpolated into JS).

Also fixes a pre-existing webhook bug

CustomerSubscriptionUpdatedWebHook compared the event type against EventTypes.CustomerUpdated ("customer.updated") instead of EventTypes.CustomerSubscriptionUpdated, so every real customer.subscription.updated event threw and returned 500 to Stripe — breaking the future-cancellation email flow. Fixed here because pausing/cancelling emits exactly this event.

Testing

  • 15 new xUnit + NSubstitute tests (service call shapes, pagination filtering, mapping incl. null-safety, page ordering by index, all four POST handlers, error and blank-ID paths).
  • Full solution suite: 232/232 passing (75 Tests + 154 UnitTests + 3 FunctionalTests).
  • Not covered: live Stripe smoke test (needs test-mode keys); webhook fix has no regression test (would require Stripe signature scaffolding).

Follow-ups (documented, out of scope)

  • Amount shows first item price only (ignores quantity/multi-item subscriptions).
  • OnGetAsync has no friendly error page on Stripe outage.
  • List fetches all historical subscriptions (status=all) then filters in memory; per-status queries would bound it as churn grows.
  • Resume action has no confirm dialog (it restarts charging).

🤖 Generated with Claude Code

ardalis and others added 8 commits July 26, 2026 08:16
- Create ISubscriptionHandlerService interface with ListBillableAsync method
- Implement StripeSubscriptionHandlerService with pagination and customer expansion
- Filter subscriptions to only billable statuses (active, past_due, trialing, unpaid)
- Exclude canceled subscriptions from results
- Register service in DI container
- Add comprehensive unit tests covering filtering and expansion behavior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements CancelAtPeriodEndAsync and CancelImmediatelyAsync on StripeSubscriptionHandlerService. CancelAtPeriodEndAsync sets CancelAtPeriodEnd=true to defer cancellation until the subscription's current billing period ends. CancelImmediatelyAsync calls Stripe's cancel endpoint to immediately terminate the subscription.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement web-layer DTO and AutoMapper profile for mapping Stripe.Subscription objects to StripeSubscriptionDto. The profile handles nested property extraction (customer email, plan details, pricing) and null-conditional chaining for optional fields. Stripe amounts in cents are converted to decimal dollars via division by 100.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Strengthen test to verify subscriptions are ordered by CustomerEmail then Id,
and to assert mapped fields end-to-end. Test now provides subscriptions in
non-sorted order with same-email pair to exercise ThenBy tie-break logic.
Would fail if OrderBy/ThenBy calls were removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement POST handlers for subscription actions (pause, resume, cancel at period
end, cancel immediately) on the Manage Subscriptions admin page. Add action
buttons to the UI with confirmation dialogs. Add the Manage Subscriptions link
to the admin menu. Includes test class for handlers (discovery issue pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove member-controlled data (CustomerEmail, subscription Id) from
  onsubmit confirm() JS strings in ManageSubscriptions/Index.cshtml.
  Razor HTML-encoding is decoded by the HTML parser before the JS
  engine sees it, so a literal apostrophe (e.g. o'brien@example.com)
  reached the JS string context, silently skipping confirmation
  (including for irreversible Cancel Now) or enabling stored XSS in
  the admin session. Confirm messages are now static.
- Fix CustomerSubscriptionUpdatedWebHook comparing stripeEvent.Type
  against EventTypes.CustomerUpdated ("customer.updated") instead of
  EventTypes.CustomerSubscriptionUpdated ("customer.subscription.updated"),
  which made the endpoint reject every real event it receives with an
  uncaught 500.
- Harden StripeSubscriptionHandlerService.ListBillableAsync's pagination
  loop against an infinite loop if Stripe ever returns HasMore=true with
  an empty Data page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ardalis
ardalis merged commit 2782def into main Jul 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant