From ee79cb92f8c59e0e819a5a07c8889e94af7ccd33 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 16 Mar 2026 19:39:15 -0500 Subject: [PATCH] feat: Next: Stripe billing and plan management - Upgrade Stripe.net to v50.4.1 and update the backend to support modern PaymentMethods while maintaining legacy token compatibility. - Implement a new billing feature in the Svelte UI with lazy-loaded Stripe integration and a functional plan change dialog. - Add TanStack Query hooks for fetching available plans and processing plan changes with coupon support. --- .../Exceptionless.Core.csproj | 2 +- .../ClientApp/package-lock.json | 21 ++ src/Exceptionless.Web/ClientApp/package.json | 4 +- .../components/change-plan-dialog.svelte | 344 ++++++++++++++++++ .../billing/components/stripe-provider.svelte | 75 ++++ .../src/lib/features/billing/constants.ts | 1 + .../src/lib/features/billing/index.ts | 16 + .../src/lib/features/billing/models.ts | 33 ++ .../src/lib/features/billing/schemas.ts | 9 + .../src/lib/features/billing/stripe.svelte.ts | 86 +++++ .../lib/features/organizations/api.svelte.ts | 72 ++++ .../dialogs/change-plan-dialog.svelte | 29 -- .../[organizationId]/billing/+page.svelte | 6 +- .../[organizationId]/usage/+page.svelte | 7 +- .../project/[projectId]/usage/+page.svelte | 9 +- .../Controllers/OrganizationController.cs | 109 +++++- .../Mapping/InvoiceMapper.cs | 2 +- .../Controllers/TokenControllerTests.cs | 5 +- .../Mapping/InvoiceMapperTests.cs | 12 +- 19 files changed, 776 insertions(+), 66 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index a706da2023..db913c0c59 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -31,7 +31,7 @@ - + diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index 80b2b975f7..d3150bfeb9 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -12,6 +12,7 @@ "@exceptionless/fetchclient": "^0.44.0", "@internationalized/date": "^3.12.0", "@lucide/svelte": "^0.577.0", + "@stripe/stripe-js": "^8.10.0", "@tanstack/svelte-form": "^1.28.5", "@tanstack/svelte-query": "^6.1.0", "@tanstack/svelte-query-devtools": "^6.0.4", @@ -30,6 +31,7 @@ "runed": "^0.37.1", "shiki": "^4.0.2", "svelte-sonner": "^1.1.0", + "svelte-stripe": "^2.0.0", "svelte-time": "^2.1.0", "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2", @@ -1988,6 +1990,15 @@ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@stripe/stripe-js": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.10.0.tgz", + "integrity": "sha512-E1FtmN4/AMpdV0zDUyEnTVMpQTMDi7iy2njG22DpFcSxeCujK22bQ/hmF3bGtNUclqGJhOZMkf7rjUyOAcj4CQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", @@ -7853,6 +7864,16 @@ "svelte": "^5.7.0" } }, + "node_modules/svelte-stripe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/svelte-stripe/-/svelte-stripe-2.0.0.tgz", + "integrity": "sha512-fKIufgE7Gd40j9PJh3RcQnL15XrKpJ544nWiv8CJmIzZwoF5lJjVRC0MJg6RVqJd1BEz+N9Ip731O12x2Y0PJg==", + "license": "MIT", + "peerDependencies": { + "@stripe/stripe-js": ">=5", + "svelte": "^5" + } + }, "node_modules/svelte-time": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/svelte-time/-/svelte-time-2.1.0.tgz", diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 1d2d918657..08fe30dbb2 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -71,6 +71,7 @@ "@exceptionless/fetchclient": "^0.44.0", "@internationalized/date": "^3.12.0", "@lucide/svelte": "^0.577.0", + "@stripe/stripe-js": "^8.10.0", "@tanstack/svelte-form": "^1.28.5", "@tanstack/svelte-query": "^6.1.0", "@tanstack/svelte-query-devtools": "^6.0.4", @@ -89,6 +90,7 @@ "runed": "^0.37.1", "shiki": "^4.0.2", "svelte-sonner": "^1.1.0", + "svelte-stripe": "^2.0.0", "svelte-time": "^2.1.0", "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2", @@ -100,4 +102,4 @@ "overrides": { "storybook": "$storybook" } -} \ No newline at end of file +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte new file mode 100644 index 0000000000..9a08c6ca4e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte @@ -0,0 +1,344 @@ + + + + + + + + Change Plan + + + You are currently on the {organization.plan_name} plan. + + + + {#if !isStripeEnabled()} +
+ +
+ {:else if plansQuery.isLoading} +
+ + + +
+ {:else if plansQuery.error} +
+ +
+ {:else if plansQuery.data} +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > + state.errors}> + {#snippet children(errors)} + + {/snippet} + + +
+ + + {#snippet children(field)} + + Select a Plan + field.handleChange(value)} + class="grid gap-3" + > + {#each plansQuery.data as plan (plan.id)} +
+ + +
+
+ {plan.name} + {#if plan.id === organization.plan_id} + (Current) + {/if} +
+
{plan.description}
+
+ {formatEvents(plan.max_events_per_month)} events/mo • {plan.retention_days} days retention • {plan.max_projects} + projects • {plan.max_users} users +
+
+
+
{formatPrice(plan.price)}
+
+
+
+ {/each} +
+ +
+ {/snippet} +
+ + + {#if isPaidPlan} + + +
+

Payment Method

+ + + {#if hasExistingCard} + + {#snippet children(field)} + field.handleChange(value as 'existing' | 'new')} + class="flex gap-6" + > +
+ + Card ending in {organization.card_last4} +
+
+ + Use a new card +
+
+ {/snippet} +
+ {/if} + + + {#if needsPayment} + { + stripeElements = elements; + }} + onload={(loadedStripe) => { + stripe = loadedStripe; + }} + > +
+ +
+
+ {/if} + + + {#if !hasExistingCard || cardMode === 'new'} + + {#snippet children(field)} + + Coupon Code (optional) + field.handleChange(e.currentTarget.value)} + /> + + + {/snippet} + + {/if} +
+ {/if} + + + {#if isDowngradeToFree} +
+

Help us improve Exceptionless!

+

+ We hate to see you downgrade, but we'd love to hear your feedback. Please let us know why you're downgrading so we can serve you + better in the future. +

+
+ {/if} +
+ + state.isSubmitting}> + {#snippet children(isSubmitting)} + + + + + {/snippet} + +
+ {/if} +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte new file mode 100644 index 0000000000..8ad7cb07bf --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte @@ -0,0 +1,75 @@ + + +{#if isLoading} + +{:else if error} + +{:else if stripe} + + {@render children()} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts new file mode 100644 index 0000000000..34cdb37935 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts @@ -0,0 +1 @@ +export const FREE_PLAN_ID = 'EX_FREE'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts new file mode 100644 index 0000000000..e58573abe9 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts @@ -0,0 +1,16 @@ +/** + * Billing feature module - Stripe integration for plan management. + */ + +// Components +export { default as ChangePlanDialog } from './components/change-plan-dialog.svelte'; + +export { default as StripeProvider } from './components/stripe-provider.svelte'; + +// Constants +export { FREE_PLAN_ID } from './constants'; + +// Models +export type { BillingPlan, CardMode, ChangePlanFormState, ChangePlanParams, ChangePlanResult } from './models'; +// Context and hooks +export { getStripePublishableKey, isStripeEnabled, loadStripeOnce, setStripeContext, type StripeContext, tryUseStripe, useStripe } from './stripe.svelte'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts new file mode 100644 index 0000000000..9a21996e53 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts @@ -0,0 +1,33 @@ +/** + * Billing models - re-exports from generated types plus billing-specific types. + */ + +export type { BillingPlan, ChangePlanResult } from '$lib/generated/api'; + +/** + * Card mode for the payment form. + */ +export type CardMode = 'existing' | 'new'; + +/** + * State for the change plan form. + */ +export interface ChangePlanFormState { + cardMode: CardMode; + couponId: string; + selectedPlanId: null | string; +} + +/** + * Parameters for the change-plan API call. + */ +export interface ChangePlanParams { + /** Optional coupon code to apply */ + couponId?: string; + /** Last 4 digits of the card (for display purposes) */ + last4?: string; + /** The plan ID to change to */ + planId: string; + /** Stripe PaymentMethod ID or legacy token */ + stripeToken?: string; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts new file mode 100644 index 0000000000..8782768fb2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts @@ -0,0 +1,9 @@ +import { type infer as Infer, object, string, enum as zodEnum } from 'zod'; + +export const ChangePlanSchema = object({ + cardMode: zodEnum(['existing', 'new']), + couponId: string(), + selectedPlanId: string().min(1, 'Please select a plan.') +}); + +export type ChangePlanFormData = Infer; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts new file mode 100644 index 0000000000..93e2bf5acc --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts @@ -0,0 +1,86 @@ +/** + * Stripe context and hooks for billing integration. + * + * Provides lazy-loaded Stripe instance with context-based access following + * the svelte-intercom provider pattern. + */ + +import type { Stripe, StripeElements } from '@stripe/stripe-js'; + +import { env } from '$env/dynamic/public'; +import { loadStripe } from '@stripe/stripe-js'; +import { getContext, setContext } from 'svelte'; + +const STRIPE_CONTEXT_KEY = Symbol('stripe-context'); + +export interface StripeContext { + readonly elements: null | StripeElements; + readonly error: null | string; + readonly isLoading: boolean; + readonly stripe: null | Stripe; +} + +/** + * Get the Stripe publishable key from environment. + */ +export function getStripePublishableKey(): string | undefined { + return env.PUBLIC_STRIPE_PUBLISHABLE_KEY; +} + +/** + * Check if Stripe is enabled via environment configuration. + */ +export function isStripeEnabled(): boolean { + return !!env.PUBLIC_STRIPE_PUBLISHABLE_KEY; +} + +// Singleton to prevent multiple Stripe loads +let _stripePromise: null | Promise = null; +let _stripeInstance: null | Stripe = null; + +/** + * Load Stripe instance lazily. Returns cached instance if already loaded. + */ +export async function loadStripeOnce(): Promise { + if (_stripeInstance) { + return _stripeInstance; + } + + if (!isStripeEnabled()) { + return null; + } + + if (!_stripePromise) { + _stripePromise = loadStripe(env.PUBLIC_STRIPE_PUBLISHABLE_KEY!); + } + + _stripeInstance = await _stripePromise; + return _stripeInstance; +} + +/** + * Set the Stripe context. Called by StripeProvider. + */ +export function setStripeContext(ctx: StripeContext): void { + setContext(STRIPE_CONTEXT_KEY, ctx); +} + +/** + * Try to get the Stripe context without throwing. + * Returns null if not within a StripeProvider. + */ +export function tryUseStripe(): null | StripeContext { + return getContext(STRIPE_CONTEXT_KEY) ?? null; +} + +/** + * Get the Stripe context. Must be called within a StripeProvider. + * @throws Error if called outside of StripeProvider + */ +export function useStripe(): StripeContext { + const ctx = getContext(STRIPE_CONTEXT_KEY); + if (!ctx) { + throw new Error('useStripe() must be called within a StripeProvider component'); + } + return ctx; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts index b3be2e34c2..17707b7ba2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts @@ -1,4 +1,5 @@ import type { WebSocketMessageValue } from '$features/websockets/models'; +import type { BillingPlan, ChangePlanResult } from '$lib/generated/api'; import type { QueryClient } from '@tanstack/svelte-query'; import { accessToken } from '$features/auth/index.svelte'; @@ -22,12 +23,14 @@ export async function invalidateOrganizationQueries(queryClient: QueryClient, me export const queryKeys = { adminSearch: (params: GetAdminSearchOrganizationsParams) => [...queryKeys.list(params.mode), 'admin', { ...params }] as const, + changePlan: (id: string | undefined) => [...queryKeys.type, id, 'change-plan'] as const, deleteOrganization: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const, id: (id: string | undefined, mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, id, { mode }] as const) : ([...queryKeys.type, id] as const)), ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const, invoice: (id: string | undefined) => [...queryKeys.type, 'invoice', id] as const, invoices: (id: string | undefined) => [...queryKeys.type, id, 'invoices'] as const, list: (mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, 'list', { mode }] as const) : ([...queryKeys.type, 'list'] as const)), + plans: (id: string | undefined) => [...queryKeys.type, id, 'plans'] as const, postOrganization: () => [...queryKeys.type, 'post-organization'] as const, setBonusOrganization: (id: string | undefined) => [...queryKeys.type, id, 'set-bonus'] as const, suspendOrganization: (id: string | undefined) => [...queryKeys.type, id, 'suspend'] as const, @@ -41,6 +44,23 @@ export interface AddOrganizationUserRequest { }; } +export interface ChangePlanParams extends Record { + /** Optional coupon code to apply */ + couponId?: string; + /** Last 4 digits of the card (for display purposes) */ + last4?: string; + /** The plan ID to change to */ + planId: string; + /** Stripe PaymentMethod ID or legacy token */ + stripeToken?: string; +} + +export interface ChangePlanRequest { + route: { + organizationId: string; + }; +} + export interface DeleteOrganizationRequest { route: { ids: string[]; @@ -110,6 +130,12 @@ export interface GetOrganizationsRequest { params?: GetOrganizationsParams; } +export interface GetPlansRequest { + route: { + organizationId: string; + }; +} + export interface PatchOrganizationRequest { route: { id: string; @@ -151,6 +177,34 @@ export function addOrganizationUser(request: AddOrganizationUserRequest) { })); } +/** + * Mutation to change an organization's billing plan. + */ +export function changePlanMutation(request: ChangePlanRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + mutationFn: async (params: ChangePlanParams) => { + const client = useFetchClient(); + const response = await client.postJSON(`organizations/${request.route.organizationId}/change-plan`, undefined, { + params + }); + + return response.data!; + }, + mutationKey: queryKeys.changePlan(request.route.organizationId), + onSuccess: () => { + // Invalidate organization data to reflect new plan + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, undefined) }); + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, 'stats') }); + queryClient.invalidateQueries({ queryKey: queryKeys.list(undefined) }); + // Also invalidate plans as the current plan indicator may change + queryClient.invalidateQueries({ queryKey: queryKeys.plans(request.route.organizationId) }); + } + })); +} + export function deleteOrganization(request: DeleteOrganizationRequest) { const queryClient = useQueryClient(); @@ -325,6 +379,24 @@ export function getOrganizationsQuery(request: GetOrganizationsRequest) { })); } +/** + * Query to fetch available billing plans for an organization. + */ +export function getPlansQuery(request: GetPlansRequest) { + return createQuery(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON(`organizations/${request.route.organizationId}/plans`, { + signal + }); + + return response.data!; + }, + queryKey: queryKeys.plans(request.route.organizationId) + })); +} + export function patchOrganization(request: PatchOrganizationRequest) { const queryClient = useQueryClient(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte deleted file mode 100644 index 4a172c69db..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - Change Plan - -

We're still working on this feature in the new app. In the meantime, you can update your plan in our previous app.

-
-
- - Close - OK - -
-
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte index d08fb3a2f1..2a1473e298 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte @@ -9,8 +9,8 @@ import { Skeleton } from '$comp/ui/skeleton'; import * as Table from '$comp/ui/table'; import { env } from '$env/dynamic/public'; + import { ChangePlanDialog } from '$features/billing'; import { getInvoicesQuery, getOrganizationQuery } from '$features/organizations/api.svelte'; - import ChangePlanDialog from '$features/organizations/components/dialogs/change-plan-dialog.svelte'; import { organization } from '$features/organizations/context.svelte'; import GlobalUser from '$features/users/components/global-user.svelte'; import CreditCard from '@lucide/svelte/icons/credit-card'; @@ -157,6 +157,6 @@ {/if} -{#if params.changePlan} - +{#if params.changePlan && organizationQuery.data} + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte index 2fd5a2ad59..1441e6ccdd 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte @@ -1,4 +1,5 @@