From ee79cb92f8c59e0e819a5a07c8889e94af7ccd33 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 16 Mar 2026 19:39:15 -0500 Subject: [PATCH 01/43] 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 @@ - + @@ -244,6 +243,7 @@

Payment Method

+ {#if hasExistingCard} @@ -268,24 +268,22 @@ {#if needsPayment} - { - stripeElements = elements; - }} - onload={(loadedStripe) => { - stripe = loadedStripe; - }} - > -
- -
-
+
+ { + stripeElements = elements; + }} + onload={(loadedStripe) => { + stripe = loadedStripe; + }} + /> +
{/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 index c70c0aa477..b196fe13bc 100644 --- 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 @@ -1,104 +1,98 @@ -{#if isLoading} - -{:else if error} - -{:else if stripe} - - {#if clientSecret} - - {@render children()} - - {:else} - - {@render children()} - - {/if} -{/if} +
+ +
+ +
From 452a93a26269178c8d84a7f9baa23de44366f9d1 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 19 Apr 2026 12:47:28 -0500 Subject: [PATCH 13/43] build: upgrade Stripe.js to v9 and fix Stripe.net v51 breaking changes - Upgrade @stripe/stripe-js to v9.2.0. - Update OrganizationController to use PriceId instead of Price, following changes in Stripe.net 51.x where Price became an expandable field. - Convert errorMessage to a Svelte 5 $state rune in stripe-provider.svelte to ensure reactivity. --- src/Exceptionless.Web/ClientApp/package-lock.json | 8 ++++---- src/Exceptionless.Web/ClientApp/package.json | 2 +- .../features/billing/components/stripe-provider.svelte | 2 +- .../Controllers/OrganizationController.cs | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index f8c2227ea0..abb9f6cc92 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -10,9 +10,9 @@ "dependencies": { "@exceptionless/browser": "^3.1.0", "@exceptionless/fetchclient": "^0.44.0", - "@stripe/stripe-js": "^8.10.0", "@internationalized/date": "^3.12.1", "@lucide/svelte": "^1.8.0", + "@stripe/stripe-js": "^9.2.0", "@tanstack/svelte-form": "^1.29.0", "@tanstack/svelte-query": "^6.1.16", "@tanstack/svelte-query-devtools": "^6.1.16", @@ -1997,9 +1997,9 @@ } }, "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==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.2.0.tgz", + "integrity": "sha512-YSzLC0t6VS9MDdPTynSMqU8IxrItFUjkDORALFT6sSMR/XZ5Vgm3RDp/Gk7z727MC4A9s4MFVel0gF0c7+kdrg==", "license": "MIT", "engines": { "node": ">=12.16" diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 7e9c9efeb4..3fe9a93265 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -72,7 +72,7 @@ "@exceptionless/fetchclient": "^0.44.0", "@internationalized/date": "^3.12.1", "@lucide/svelte": "^1.8.0", - "@stripe/stripe-js": "^8.10.0", + "@stripe/stripe-js": "^9.2.0", "@tanstack/svelte-form": "^1.29.0", "@tanstack/svelte-query": "^6.1.16", "@tanstack/svelte-query-devtools": "^6.1.16", 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 index b196fe13bc..0df8779ad8 100644 --- 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 @@ -31,7 +31,7 @@ let stripeInstance: Stripe | null = null; let elementsInstance: StripeElements | null = null; let paymentElement: StripePaymentElement | null = null; - let errorMessage: string | null = null; + let errorMessage: string | null = $state(null); let skeletonDiv: HTMLDivElement; let paymentDiv: HTMLDivElement; diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 1e5127ca9c..fd18440725 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -256,9 +256,9 @@ public async Task> GetInvoiceAsync(string id) { var item = new InvoiceLineItem { Amount = line.Amount / 100.0m, Description = line.Description }; - // In Stripe.net 50.x, Plan was removed from InvoiceLineItem - // Fetch full Price object from Stripe to get nickname, interval, and amount - var priceId = line.Pricing?.PriceDetails?.Price; + // In Stripe.net 51.x, PriceDetails.Price changed from string to ExpandableField; + // use .PriceId for the string ID. Fetch full Price object from Stripe to get nickname, interval, and amount. + var priceId = line.Pricing?.PriceDetails?.PriceId; if (!String.IsNullOrEmpty(priceId)) { try From 062eeee5c585e95900d11d653c8f9934228dca51 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 19 Apr 2026 13:29:35 -0500 Subject: [PATCH 14/43] fix: handle 426 Upgrade Required responses with modal dialog Replace generic toast errors with proper upgrade confirmation dialog matching the legacy Angular UI behavior: - Modal shows backend ProblemDetails message (same text as legacy) - 'Upgrade Plan' button navigates to billing page with ?changePlan=true - 'Cancel' button dismisses without navigation - Dialog rendered globally in app layout, triggered from any page Fixed pages: - Add Project: was showing generic error, now shows upgrade dialog - Event Detail: was a TODO comment, now properly handles 426 - Stack Promote: was showing hardcoded message, now uses ProblemDetails - Users Invite: was missing 426 handling entirely - Add Organization: was missing 426 handling entirely - Integrations (webhook + Slack): was missing 426 handling entirely Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/upgrade-required-dialog.svelte | 29 ++++++++++ .../src/lib/features/billing/index.ts | 4 ++ .../billing/upgrade-required.svelte.ts | 54 +++++++++++++++++++ .../stack-options-dropdown-menu.svelte | 8 +-- .../ClientApp/src/routes/(app)/+layout.svelte | 3 ++ .../routes/(app)/event/[eventId]/+page.svelte | 6 ++- .../[organizationId]/users/+page.svelte | 5 ++ .../(app)/organization/add/+page.svelte | 8 ++- .../[projectId]/integrations/+page.svelte | 10 +++- .../src/routes/(app)/project/add/+page.svelte | 8 ++- 10 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte new file mode 100644 index 0000000000..094f954c7f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte @@ -0,0 +1,29 @@ + + + + + + Upgrade Plan + {upgradeRequiredDialog.message} + + + Cancel + Upgrade Plan + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts index e58573abe9..a3798d9118 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts @@ -14,3 +14,7 @@ export { FREE_PLAN_ID } from './constants'; export type { BillingPlan, CardMode, ChangePlanFormState, ChangePlanParams, ChangePlanResult } from './models'; // Context and hooks export { getStripePublishableKey, isStripeEnabled, loadStripeOnce, setStripeContext, type StripeContext, tryUseStripe, useStripe } from './stripe.svelte'; + +// Upgrade required handling +export { default as UpgradeRequiredDialog } from './components/upgrade-required-dialog.svelte'; +export { handleUpgradeRequired, isUpgradeRequired } from './upgrade-required.svelte'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts new file mode 100644 index 0000000000..c5db896acd --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts @@ -0,0 +1,54 @@ +import { ProblemDetails } from '@exceptionless/fetchclient'; + +interface UpgradeRequiredState { + open: boolean; + message: string; + organizationId: string | undefined; +} + +const state: UpgradeRequiredState = $state({ + open: false, + message: '', + organizationId: undefined +}); + +export const upgradeRequiredDialog = { + get open() { + return state.open; + }, + set open(value: boolean) { + state.open = value; + }, + get message() { + return state.message; + }, + get organizationId() { + return state.organizationId; + } +}; + +/** + * Checks if a ProblemDetails error represents a 426 Upgrade Required response. + */ +export function isUpgradeRequired(error: unknown): error is ProblemDetails { + return error instanceof ProblemDetails && error.status === 426; +} + +/** + * Handles a 426 Upgrade Required response by opening a confirmation dialog + * matching the legacy UI behavior: shows the backend message with + * "Upgrade Plan" and "Cancel" buttons. + * + * Returns true if the error was a 426 and was handled, false otherwise. + */ +export function handleUpgradeRequired(error: unknown, organizationId: string | undefined): boolean { + if (!isUpgradeRequired(error)) { + return false; + } + + state.message = error.title || 'Please upgrade your plan to continue.'; + state.organizationId = organizationId; + state.open = true; + + return true; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte index f1e9d3b55c..8e41e69c3d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte @@ -3,6 +3,7 @@ import { resolve } from '$app/paths'; import Button from '$comp/ui/button/button.svelte'; import * as DropdownMenu from '$comp/ui/dropdown-menu'; + import { handleUpgradeRequired } from '$features/billing'; import Reference from '@lucide/svelte/icons/link-2'; import Settings from '@lucide/svelte/icons/settings'; import Delete from '@lucide/svelte/icons/trash'; @@ -73,11 +74,10 @@ } if (response.status === 426) { - toast.error( - 'Promote to External is a premium feature used to promote an error stack to an external system. Please upgrade your plan to enable this feature.' + handleUpgradeRequired( + { status: 426, title: 'Promote to External is a premium feature. Please upgrade your plan to enable this feature.' } as never, + stack.organization_id ); - //await confirmUpgradePlan(message, tack.organization_id); - //await promoteToExternal(); return; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 66c40f2fdb..27a921ad54 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -8,6 +8,7 @@ import { env } from '$env/dynamic/public'; import { getIntercomTokenQuery } from '$features/auth/api.svelte'; import { accessToken, gotoLogin } from '$features/auth/index.svelte'; + import { UpgradeRequiredDialog } from '$features/billing'; import { invalidatePersistentEventQueries } from '$features/events/api.svelte'; import { buildIntercomBootOptions, IntercomShell } from '$features/intercom'; import { shouldLoadIntercomOrganization } from '$features/intercom/config'; @@ -302,4 +303,6 @@ {@render appShell(openChat)} {/snippet} + + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte index ece476ce75..96995a009c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte @@ -6,6 +6,7 @@ import { page } from '$app/state'; import * as FacetedFilter from '$comp/faceted-filter'; import { H3 } from '$comp/typography'; + import { handleUpgradeRequired } from '$features/billing'; import EventsOverview from '$features/events/components/events-overview.svelte'; import { organization } from '$features/organizations/context.svelte'; import { watch } from 'runed'; @@ -27,8 +28,9 @@ } async function handleError(problem: ProblemDetails) { - if (problem.status === 426) { - // TODO: Show a message to the user that they need to upgrade their subscription. + if (handleUpgradeRequired(problem, organization.current)) { + await goto(resolve('/(app)')); + return; } toast.error(`The event "${page.params.eventId}" could not be found.`); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte index 6298fd8dd5..fd563d0afc 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte @@ -4,6 +4,7 @@ import { H3, Muted } from '$comp/typography'; import { Button } from '$comp/ui/button'; import { Separator } from '$comp/ui/separator'; + import { handleUpgradeRequired } from '$features/billing'; import { addOrganizationUser } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; import { DEFAULT_LIMIT } from '$features/shared/api/api.svelte'; @@ -79,6 +80,10 @@ await addUserMutation.mutateAsync(email); toastId = toast.success('User invited successfully'); } catch (error: unknown) { + if (handleUpgradeRequired(error, organizationId)) { + return; + } + const message = error instanceof ProblemDetails ? error.title : 'Please try again.'; toastId = toast.error(`An error occurred while trying to invite the user: ${message}`); throw error; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte index 55b4c54c1a..05b6d0200a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte @@ -7,6 +7,7 @@ import * as Field from '$comp/ui/field'; import { Input } from '$comp/ui/input'; import { Spinner } from '$comp/ui/spinner'; + import { handleUpgradeRequired } from '$features/billing'; import { postOrganization } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; import { useHideOrganizationNotifications } from '$features/organizations/hooks/use-hide-organization-notifications.svelte'; @@ -37,11 +38,16 @@ await goto(resolve('/(app)/organization/[organizationId]/manage', { organizationId: id })); return null; } catch (error: unknown) { - toastId = toast.error('Error creating organization. Please try again.'); + if (handleUpgradeRequired(error, organization.current)) { + return null; + } + if (error instanceof ProblemDetails) { + toastId = toast.error(error.title || 'Error creating organization. Please try again.'); return problemDetailsToFormErrors(error); } + toastId = toast.error('Error creating organization. Please try again.'); return { form: 'An unexpected error occurred, please try again.' }; } } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte index 816b9892e8..fbe035274b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte @@ -9,6 +9,7 @@ import { Separator } from '$comp/ui/separator'; import { env } from '$env/dynamic/public'; import { slackOAuthLogin } from '$features/auth/index.svelte'; + import { handleUpgradeRequired } from '$features/billing'; import { organization } from '$features/organizations/context.svelte'; import { deleteSlack, @@ -88,6 +89,10 @@ await newWebhook.mutateAsync(webhook); toastId = toast.success('Webhook added successfully'); } catch (error) { + if (handleUpgradeRequired(error, organization.current)) { + return; + } + toastId = toast.error('Error adding webhook. Please try again.'); throw error; } @@ -100,7 +105,10 @@ const code = await slackOAuthLogin(); await addSlackMutation.mutateAsync(code); toastId = toast.success('Successfully connected Slack integration.'); - } catch { + } catch (error) { + if (handleUpgradeRequired(error, organization.current)) { + return; + } toastId = toast.error('Error connecting Slack integration. Please try again.'); } } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte index ea6d941063..959b32a23c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte @@ -9,6 +9,7 @@ import * as Field from '$comp/ui/field'; import { Input } from '$comp/ui/input'; import { Spinner } from '$comp/ui/spinner'; + import { handleUpgradeRequired } from '$features/billing'; import { organization } from '$features/organizations/context.svelte'; import { postProject } from '$features/projects/api.svelte'; import { type NewProjectFormData, NewProjectSchema } from '$features/projects/schemas'; @@ -36,11 +37,16 @@ await goto(resolve('/(app)/project/[projectId]/configure', { projectId: id }) + '?redirect=true'); return null; } catch (error: unknown) { - toastId = toast.error('Error creating project. Please try again.'); + if (handleUpgradeRequired(error, organization.current)) { + return null; + } + if (error instanceof ProblemDetails) { + toastId = toast.error(error.title || 'Error creating project. Please try again.'); return problemDetailsToFormErrors(error); } + toastId = toast.error('Error creating project. Please try again.'); return { form: 'An unexpected error occurred, please try again.' }; } } From 6723fd1ac7fca61a6ccc00c59cd151c76f8195c3 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 19 Apr 2026 15:09:36 -0500 Subject: [PATCH 15/43] fix: resolve infinite loop in Change Plan dialog with untrack()\n\nUse native {#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 selectedPlan && !isCurrentPlan} +
+ {#if selectedPlan.price > 0} + Changing to {selectedPlan.name} at {formatPrice(selectedPlan.price)} + {:else} + Downgrading to the Free plan + {/if} +
+ {/if} + {#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 -
-
+ + Payment Method + field.handleChange(value as 'existing' | 'new')} + class="flex gap-6" + > +
+ + Card ending in {organization.card_last4} +
+
+ + Use a new card +
+
+
{/snippet}
{/if} - {#if needsPayment}
{/if} - - - {#if !hasExistingCard || cardMode === 'new'} - - {#snippet children(field)} - - Coupon Code (optional) - field.handleChange(e.currentTarget.value)} - /> - - - {/snippet} - - {/if}
{/if} + + + {#snippet children(field)} + + Coupon code + field.handleChange(e.currentTarget.value)} + /> + + + {/snippet} + + {#if isDowngradeToFree}
From b6d648ab567d5ed40b83f24d66d1e41a3297b738 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 19 Apr 2026 18:05:28 -0500 Subject: [PATCH 16/43] feat: redesign change plan dialog with improved tier grouping and summaries - Implements a more modern UI with a monthly/yearly billing toggle. - Automatically groups plans by tier and provides automatic yearly savings labels. - Adds a dynamic change summary that displays prorated details and plan transitions. - Integrates coupon management directly into the subscription workflow. - Improves responsive layout with a scrollable content area and clearer typography. --- .../components/change-plan-dialog.svelte | 681 +++++++++++++----- 1 file changed, 519 insertions(+), 162 deletions(-) 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 index 93140797b3..9555c7c41f 100644 --- 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 @@ -4,21 +4,19 @@ import type { Stripe, StripeElements } from '@stripe/stripe-js'; import ErrorMessage from '$comp/error-message.svelte'; - import { P } from '$comp/typography'; import { Button } from '$comp/ui/button'; import * as Dialog from '$comp/ui/dialog'; - import * as Field from '$comp/ui/field'; import { Input } from '$comp/ui/input'; - import * as RadioGroup from '$comp/ui/radio-group'; - import { Separator } from '$comp/ui/separator'; import { Skeleton } from '$comp/ui/skeleton'; import { Spinner } from '$comp/ui/spinner'; import { FREE_PLAN_ID, isStripeEnabled, StripeProvider } from '$features/billing'; import { type ChangePlanFormData, ChangePlanSchema } from '$features/billing/schemas'; import { changePlanMutation, getPlansQuery } from '$features/organizations/api.svelte'; - import { getFormErrorMessages, mapFieldErrors } from '$features/shared/validation'; + import { getFormErrorMessages } from '$features/shared/validation'; + import Check from '@lucide/svelte/icons/check'; import CreditCard from '@lucide/svelte/icons/credit-card'; - import ExternalLink from '@lucide/svelte/icons/external-link'; + import Plus from '@lucide/svelte/icons/plus'; + import X from '@lucide/svelte/icons/x'; import { createForm } from '@tanstack/svelte-form'; import { toast } from 'svelte-sonner'; import { untrack } from 'svelte'; @@ -46,7 +44,106 @@ } }); + // ─────────────────────────────────────────────────────────────── + // Plan tier / interval model + // + // Backend ships 11 plan IDs: EX_FREE + 5 monthly (EX_SMALL, EX_MEDIUM, + // EX_LARGE, EX_XL, EX_ENT) + 5 yearly (…_YEARLY). The monthly/yearly + // toggle is derived UI state — we group plans by tier key (strip + // _YEARLY) and submit the concrete plan ID on save. + // ─────────────────────────────────────────────────────────────── + const YEARLY_SUFFIX = '_YEARLY'; + // Hand-curated "most popular" tier — backend doesn't carry this flag. + const POPULAR_TIER = 'EX_MEDIUM'; + + function tierOf(planId: string): string { + return planId.endsWith(YEARLY_SUFFIX) ? planId.slice(0, -YEARLY_SUFFIX.length) : planId; + } + function intervalOf(planId: string): 'month' | 'year' { + return planId.endsWith(YEARLY_SUFFIX) ? 'year' : 'month'; + } + + interface PlanTier { + id: string; // tier key — matches the monthly plan ID + name: string; // e.g. "Small" + monthly: BillingPlan | null; + yearly: BillingPlan | null; + popular: boolean; + } + + const tiers = $derived.by(() => { + if (!plansQuery.data) return []; + const byTier = new Map(); + for (const p of plansQuery.data) { + if (p.is_hidden || p.id === FREE_PLAN_ID) continue; + const key = tierOf(p.id); + const current = byTier.get(key) ?? { + id: key, + name: p.name.replace(/\s*\(Yearly\)\s*$/i, '').trim(), + monthly: null, + yearly: null, + popular: key === POPULAR_TIER + }; + if (intervalOf(p.id) === 'year') current.yearly = p; + else current.monthly = p; + byTier.set(key, current); + } + // Preserve backend ordering by first-seen monthly tier + const ordered: PlanTier[] = []; + for (const p of plansQuery.data) { + if (p.is_hidden || p.id === FREE_PLAN_ID) continue; + const key = tierOf(p.id); + const tier = byTier.get(key); + if (tier && !ordered.includes(tier)) ordered.push(tier); + } + return ordered; + }); + const freePlan = $derived(plansQuery.data?.find((p: BillingPlan) => p.id === FREE_PLAN_ID) ?? null); + const isFreeCurrent = $derived(organization.plan_id === FREE_PLAN_ID); + + const currentInterval = $derived<'month' | 'year'>(intervalOf(organization.plan_id)); + const currentTierId = $derived(tierOf(organization.plan_id)); + + // ─────────────────────────────────────────────────────────────── + // UI state + // ─────────────────────────────────────────────────────────────── + let selectedTierId = $state(''); // '' = Free + let interval = $state<'month' | 'year'>('month'); + let paymentExpanded = $state(false); + let couponOpen = $state(false); + let couponApplied = $state(null); + let couponInput = $state(''); + const hasExistingCard = $derived(!!organization.card_last4); + + // Resolve selected plan ID from tier + interval, falling back to monthly + // when the tier doesn't offer a yearly option. + function resolvePlanId(tierId: string, iv: 'month' | 'year'): string { + if (!tierId) return FREE_PLAN_ID; + const tier = tiers.find((t) => t.id === tierId); + if (!tier) return FREE_PLAN_ID; + if (iv === 'year' && tier.yearly) return tier.yearly.id; + return tier.monthly?.id ?? tier.yearly?.id ?? FREE_PLAN_ID; + } + + const selectedPlanId = $derived(resolvePlanId(selectedTierId, interval)); + const selectedPlan = $derived( + plansQuery.data?.find((p: BillingPlan) => p.id === selectedPlanId) ?? null + ); + const isFreeSelected = $derived(selectedPlanId === FREE_PLAN_ID); + const isPaidPlan = $derived(!!selectedPlan && selectedPlan.price > 0); + const isCurrentPlan = $derived(selectedPlanId === organization.plan_id); + + // Dirty tracking + const planDirty = $derived(!isCurrentPlan); + const paymentDirty = $derived(paymentExpanded && hasExistingCard); + const needsPayment = $derived(isPaidPlan && (!hasExistingCard || paymentExpanded)); + const couponDirty = $derived(!!couponApplied); + const anyDirty = $derived(planDirty || paymentDirty || couponDirty); + + // ─────────────────────────────────────────────────────────────── + // Stripe wiring + // ─────────────────────────────────────────────────────────────── let stripe = $state(null); let stripeElements = $state(undefined); @@ -62,11 +159,8 @@ try { let stripeToken: string | undefined; let last4: string | undefined; - const plan = plansQuery.data?.find((p: BillingPlan) => p.id === value.selectedPlanId) ?? null; - const isPaid = !!plan && plan.price > 0; - const needsCard = isPaid && value.cardMode === 'new'; - if (needsCard) { + if (needsPayment && isPaidPlan) { if (!stripe || !stripeElements) { return { form: 'Payment system not loaded. Please try again.' }; } @@ -109,60 +203,159 @@ } })); - // Default to the next tier up (upsell) or current plan if at top - const defaultPlanId = $derived.by(() => { - if (!plansQuery.data) return undefined; - const idx = plansQuery.data.findIndex((p: BillingPlan) => p.id === organization.plan_id); - return (plansQuery.data[idx + 1] ?? plansQuery.data[idx])?.id; + // ─────────────────────────────────────────────────────────────── + // Keep form in sync with UI state (it's the canonical submit value) + // ─────────────────────────────────────────────────────────────── + $effect(() => { + const planId = selectedPlanId; + const mode = needsPayment ? 'new' : 'existing'; + const coupon = couponApplied ?? ''; + untrack(() => { + form.setFieldValue('selectedPlanId', planId); + form.setFieldValue('cardMode', mode); + form.setFieldValue('couponId', coupon); + }); }); - let selectedPlanId = $state(''); - const selectedPlan = $derived(plansQuery.data?.find((p: BillingPlan) => p.id === selectedPlanId) ?? null); - const isPaidPlan = $derived(selectedPlan && selectedPlan.price > 0); - const isDowngradeToFree = $derived(selectedPlanId === FREE_PLAN_ID && organization.plan_id !== FREE_PLAN_ID); - const cardMode = $derived(form.state.values.cardMode); - const needsPayment = $derived(isPaidPlan && cardMode === 'new'); - const isCurrentPlan = $derived(selectedPlanId === organization.plan_id); - - // Reset form when dialog opens — untrack form mutations to prevent reactive cycles + // Reset dialog state when it opens $effect(() => { - if (open && defaultPlanId) { + if (open && plansQuery.data) { untrack(() => { + // Default to current plan so "Save changes" is disabled at open + selectedTierId = currentTierId === FREE_PLAN_ID ? '' : currentTierId; + interval = currentInterval; + paymentExpanded = false; + couponOpen = false; + couponApplied = null; + couponInput = ''; form.reset(); - form.setFieldValue('cardMode', hasExistingCard ? 'existing' : 'new'); - form.setFieldValue('couponId', ''); - form.setFieldValue('selectedPlanId', defaultPlanId); }); - selectedPlanId = defaultPlanId; } }); - function handlePlanChange(e: Event & { currentTarget: HTMLSelectElement }) { - const value = e.currentTarget.value; - selectedPlanId = value; - untrack(() => form.setFieldValue('selectedPlanId', value)); + // ─────────────────────────────────────────────────────────────── + // Handlers + // ─────────────────────────────────────────────────────────────── + function selectTier(tierId: string) { + selectedTierId = tierId; + } + + function setInterval(next: 'month' | 'year') { + interval = next; + } + + function onUseDifferentCard() { + paymentExpanded = true; + } + + function onKeepCurrentCard() { + paymentExpanded = false; + } + + function onCouponOpen() { + couponOpen = true; + } + + function onCouponCancel() { + couponOpen = false; + couponInput = ''; + } + + function onCouponApply() { + const code = couponInput.trim(); + if (!code) return; + couponApplied = code.toUpperCase(); + couponOpen = false; + couponInput = ''; + } + + function onCouponRemove() { + couponApplied = null; } function handleCancel() { open = false; } - function formatPlanLabel(plan: BillingPlan): string { - return plan.price === 0 ? `${plan.name} (Free)` : `${plan.name} ($${plan.price}/month)`; + // ─────────────────────────────────────────────────────────────── + // Display helpers + // ─────────────────────────────────────────────────────────────── + function formatEvents(n: number): string { + if (n < 0) return 'Unlimited events'; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M events/mo`; + if (n >= 1_000) return `${Math.round(n / 1_000)}K events/mo`; + return `${n} events/mo`; } - - function formatPrice(price: number): string { - return price === 0 ? 'Free' : `$${price}/month`; + function formatUsers(n: number): string { + return n < 0 ? 'Unlimited users' : `${n} users`; + } + function formatRetention(days: number): string { + return `${days} day${days === 1 ? '' : 's'}`; + } + function tierPrice(tier: PlanTier, iv: 'month' | 'year') { + if (iv === 'year' && tier.yearly) { + const perMonth = tier.yearly.price / 12; + return { + amount: `$${tier.yearly.price}`, + period: '/yr', + sub: `~$${perMonth.toFixed(0)}/mo` + }; + } + const p = tier.monthly ?? tier.yearly; + if (!p) return { amount: '—', period: '', sub: '' }; + return { amount: `$${p.price}`, period: '/mo', sub: '' }; + } + const yearlySavingsLabel = $derived.by(() => { + const sample = tiers.find((t) => t.monthly && t.yearly); + if (!sample?.monthly || !sample.yearly) return null; + const fullYear = sample.monthly.price * 12; + const saved = fullYear - sample.yearly.price; + const pct = Math.round((saved / fullYear) * 100); + return pct > 0 ? `Save ${pct}%` : null; + }); + function intervalWord(iv: 'month' | 'year'): string { + return iv === 'year' ? 'yearly' : 'monthly'; } + function planLabel(planId: string): string { + if (planId === FREE_PLAN_ID) return 'Free'; + const tier = tiers.find((t) => t.id === tierOf(planId)); + if (!tier) return planId; + return `${tier.name} ${intervalWord(intervalOf(planId))}`; + } + + const currentSubtitle = $derived.by(() => { + if (organization.plan_id === FREE_PLAN_ID) return 'Free plan'; + const tier = tiers.find((t) => t.id === currentTierId); + const plan = plansQuery.data?.find((p: BillingPlan) => p.id === organization.plan_id); + if (!tier || !plan) return organization.plan_name; + const period = currentInterval === 'year' ? '/yr' : '/mo'; + return `${tier.name} · $${plan.price}${period}, billed ${intervalWord(currentInterval)}`; + }); + + // CTA label — mirrors the mockup's dirty-aware logic + const ctaLabel = $derived.by(() => { + if (!anyDirty) return 'Save changes'; + if (planDirty && isFreeSelected) return 'Downgrade to Free'; + if (planDirty && organization.plan_id === FREE_PLAN_ID) return `Start ${planLabel(selectedPlanId)}`; + if (planDirty) return `Switch to ${planLabel(selectedPlanId)}`; + if (paymentDirty && !couponDirty) return 'Update payment method'; + if (couponDirty && !paymentDirty) return 'Apply coupon'; + return 'Save changes'; + }); - - - - - Change Plan + + + + + Manage subscription +

+ {organization.name} + · + {currentSubtitle} +

{#if !isStripeEnabled()} @@ -171,8 +364,8 @@
{:else if plansQuery.isLoading}
- - + +
{:else if plansQuery.error}
@@ -191,83 +384,160 @@ {/snippet} -
- -

- {organization.name} is currently on the {organization.plan_name} plan. -

- - - - {#snippet children(field)} - - Select new plan -

- - View plan details - - · All plan changes are prorated. -

- - -
- {/snippet} -
+
+ +
+
+
+ Plan + {#if planDirty} + + {/if} +
+ All changes prorated +
- - {#if selectedPlan && !isCurrentPlan} -
- {#if selectedPlan.price > 0} - Changing to {selectedPlan.name} at {formatPrice(selectedPlan.price)} - {:else} - Downgrading to the Free plan - {/if} + +
+ +
- {/if} - - {#if isPaidPlan} - - -
- {#if hasExistingCard} - - {#snippet children(field)} - - Payment Method - field.handleChange(value as 'existing' | 'new')} - class="flex gap-6" - > -
- - Card ending in {organization.card_last4} -
-
- - Use a new card -
-
-
- {/snippet} -
- {/if} + +
+ {#each tiers as tier (tier.id)} + {@const planForInterval = interval === 'year' && tier.yearly ? tier.yearly : tier.monthly} + {@const price = tierPrice(tier, interval)} + {@const isCurrent = tier.id === currentTierId && (interval === currentInterval || !tier.yearly)} + {@const isSelected = tier.id === selectedTierId} + + {/each} + + + +
+
- {#if needsPayment} -
+ + {#if isPaidPlan} +
+
+
+ Payment method + {#if paymentDirty} + + {/if} +
+ {#if hasExistingCard && paymentExpanded} + + {/if} +
+ + {#if hasExistingCard && !paymentExpanded} +
+
+ + + Paying with + ···· {organization.card_last4} + +
+ +
+ {:else} +
{/if} -
+ {/if} - - - {#snippet children(field)} - - Coupon code - field.handleChange(e.currentTarget.value)} - /> - - - {/snippet} - - - - {#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 !isFreeSelected} +
+
+
+ Coupon + {#if couponDirty} + + {/if} +
+ {#if couponOpen && !couponApplied} + + {/if} +
+ + {#if couponApplied} +
+ + {couponApplied} + — applied at checkout + + +
+ {:else if couponOpen} +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + onCouponApply(); + } + }} + /> + +
+ {:else} + + {/if} +
{/if}
- state.isSubmitting}> - {#snippet children(isSubmitting)} - - - - - {/snippet} - + + +
+ {#if !anyDirty} +
No changes yet
+ {/if} + {#if planDirty} +
+ Plan + + {#if isFreeSelected} + {planLabel(organization.plan_id)} + + Free + · immediate, prorated credit + {:else if organization.plan_id === FREE_PLAN_ID} + Start {planLabel(selectedPlanId)} + {#if selectedPlan}· ${selectedPlan.price}{interval === 'year' ? '/yr' : '/mo'}{/if} + {:else} + {planLabel(organization.plan_id)} + + {planLabel(selectedPlanId)} + {#if selectedPlan}· ${selectedPlan.price}{interval === 'year' ? '/yr' : '/mo'} · prorated today{/if} + {/if} + +
+ {/if} + {#if paymentDirty} +
+ Payment + + ···· {organization.card_last4} + + new payment method + +
+ {/if} + {#if couponDirty} +
+ Coupon + + {couponApplied} applied + +
+ {/if} +
+ + state.isSubmitting}> + {#snippet children(isSubmitting)} +
+ + +
+ {/snippet} +
+
{/if} From a826b294bbb9db3cfd1e86e3eca1917341a91771 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 19 Apr 2026 19:36:56 -0500 Subject: [PATCH 17/43] fix: resolve billing plan change failures Two bugs in OrganizationController prevented plan changes: 1. GetPlansAsync() mutated the singleton BillingPlans list directly instead of cloning it first. When a global admin loaded plans, the org's billing data (e.g., price=0) permanently corrupted the singleton, causing wrong prices for all subsequent requests. Fix: always .ToList() before modifying. 2. ChangePlanAsync had [Consumes("application/json")] but the frontend sends plan parameters as query strings with no request body, so ASP.NET Core rejected the request with a 404. Fix: remove the Consumes attribute. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Controllers/OrganizationController.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index fd18440725..3a44a327bb 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -367,9 +367,9 @@ public async Task>> GetPlansAsync( if (organization is null) return NotFound(); - var plans = _plans.Plans; - if (!Request.IsGlobalAdmin()) - plans = plans.Where(p => !p.IsHidden || p.Id == organization.PlanId).ToList(); + var plans = Request.IsGlobalAdmin() + ? _plans.Plans.ToList() + : _plans.Plans.Where(p => !p.IsHidden || p.Id == organization.PlanId).ToList(); var currentPlan = new BillingPlan { @@ -385,10 +385,11 @@ public async Task>> GetPlansAsync( HasPremiumFeatures = organization.HasPremiumFeatures }; - if (plans.All(p => p.Id != organization.PlanId)) - plans.Add(currentPlan); + int idx = plans.FindIndex(p => p.Id == organization.PlanId); + if (idx >= 0) + plans[idx] = currentPlan; else - plans[plans.FindIndex(p => p.Id == organization.PlanId)] = currentPlan; + plans.Add(currentPlan); return Ok(plans); } @@ -406,7 +407,6 @@ public async Task>> GetPlansAsync( /// The coupon id. /// The organization was not found. [HttpPost] - [Consumes("application/json")] [Route("{id:objectid}/change-plan")] public async Task> ChangePlanAsync(string id, string planId, string? stripeToken = null, string? last4 = null, string? couponId = null) { From 46b0a5f1895653e7a65657bd8b74b476587aef83 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 19 Apr 2026 21:23:24 -0500 Subject: [PATCH 18/43] feat: refine Change Plan dialog and add comprehensive Storybook stories - Implements a Storybook harness and stories to test various billing scenarios, including upgrades, downgrades, and coupon applications. - Configures the Stripe provider for manual payment method creation to support the deferred-intent flow. - Refines the tier selection logic and plan transition summaries in the dialog. - Adds error reporting via Exceptionless for plan change failures. --- .aspire/settings.json | 2 +- .vscode/launch.json | 2 +- .../ClientApp/.storybook/mocks/env.js | 5 +- .../change-plan-dialog-harness.svelte | 49 ++ .../components/change-plan-dialog.stories.ts | 351 +++++++++++++ .../components/change-plan-dialog.svelte | 479 +++++++++++------- .../billing/components/stripe-provider.svelte | 55 +- .../src/lib/features/billing/index.ts | 8 +- .../billing/upgrade-required.svelte.ts | 24 +- 9 files changed, 753 insertions(+), 222 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts diff --git a/.aspire/settings.json b/.aspire/settings.json index 68a4733ba2..52800c54eb 100644 --- a/.aspire/settings.json +++ b/.aspire/settings.json @@ -1,3 +1,3 @@ { "appHostPath": "../src/Exceptionless.AppHost/Exceptionless.AppHost.csproj" -} \ No newline at end of file +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 90a926b9f8..501508a7c1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "type": "aspire", "request": "launch", "name": "Aspire", - "program": "${workspaceFolder}/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj" + "program": "" }, { "name": "Web", diff --git a/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js b/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js index e3843c5d9a..f5a391f3ea 100644 --- a/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js +++ b/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js @@ -1,5 +1,8 @@ // Mock for $env/dynamic/public in Storybook export const env = { // Filter to only include PUBLIC_ prefixed environment variables - ...Object.fromEntries(Object.entries(import.meta.env).filter(([key]) => key.startsWith('PUBLIC_'))) + ...Object.fromEntries(Object.entries(import.meta.env).filter(([key]) => key.startsWith('PUBLIC_'))), + // Provide a Stripe publishable key so isStripeEnabled() returns true in + // billing stories. The StripeProvider will fail to init (expected). + PUBLIC_STRIPE_PUBLISHABLE_KEY: import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_storybook_placeholder' }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte new file mode 100644 index 0000000000..ab398d305c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte @@ -0,0 +1,49 @@ + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts new file mode 100644 index 0000000000..60fa50c972 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts @@ -0,0 +1,351 @@ +import type { ViewOrganization } from '$features/organizations/models'; +import type { BillingPlan } from '$lib/generated/api'; +import type { Meta, StoryObj } from '@storybook/sveltekit'; + +import Harness from './change-plan-dialog-harness.svelte'; + +// ─── Mock data ────────────────────────────────────────────────────────────── + +const MOCK_PLANS: BillingPlan[] = [ + { + description: 'Free', + has_premium_features: false, + id: 'EX_FREE', + is_hidden: false, + max_events_per_month: 3000, + max_projects: 1, + max_users: 1, + name: 'Free', + price: 0, + retention_days: 3 + }, + { + description: 'Small ($15/month)', + has_premium_features: true, + id: 'EX_SMALL', + is_hidden: false, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + name: 'Small', + price: 15, + retention_days: 30 + }, + { + description: 'Small Yearly ($165/year - Save $15)', + has_premium_features: true, + id: 'EX_SMALL_YEARLY', + is_hidden: false, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + name: 'Small (Yearly)', + price: 165, + retention_days: 30 + }, + { + description: 'Medium ($49/month)', + has_premium_features: true, + id: 'EX_MEDIUM', + is_hidden: false, + max_events_per_month: 75000, + max_projects: 15, + max_users: 25, + name: 'Medium', + price: 49, + retention_days: 90 + }, + { + description: 'Medium Yearly ($539/year - Save $49)', + has_premium_features: true, + id: 'EX_MEDIUM_YEARLY', + is_hidden: false, + max_events_per_month: 75000, + max_projects: 15, + max_users: 25, + name: 'Medium (Yearly)', + price: 539, + retention_days: 90 + }, + { + description: 'Large ($99/month)', + has_premium_features: true, + id: 'EX_LARGE', + is_hidden: false, + max_events_per_month: 250000, + max_projects: -1, + max_users: -1, + name: 'Large', + price: 99, + retention_days: 180 + }, + { + description: 'Large Yearly ($1,089/year - Save $99)', + has_premium_features: true, + id: 'EX_LARGE_YEARLY', + is_hidden: false, + max_events_per_month: 250000, + max_projects: -1, + max_users: -1, + name: 'Large (Yearly)', + price: 1089, + retention_days: 180 + }, + { + description: 'Extra Large ($199/month)', + has_premium_features: true, + id: 'EX_XL', + is_hidden: false, + max_events_per_month: 1000000, + max_projects: -1, + max_users: -1, + name: 'Extra Large', + price: 199, + retention_days: 180 + }, + { + description: 'Extra Large Yearly ($2,189/year - Save $199)', + has_premium_features: true, + id: 'EX_XL_YEARLY', + is_hidden: false, + max_events_per_month: 1000000, + max_projects: -1, + max_users: -1, + name: 'Extra Large (Yearly)', + price: 2189, + retention_days: 180 + }, + { + description: 'Enterprise ($499/month)', + has_premium_features: true, + id: 'EX_ENT', + is_hidden: false, + max_events_per_month: 3000000, + max_projects: -1, + max_users: -1, + name: 'Enterprise', + price: 499, + retention_days: 180 + }, + { + description: 'Enterprise Yearly ($5,489/year - Save $499)', + has_premium_features: true, + id: 'EX_ENT_YEARLY', + is_hidden: false, + max_events_per_month: 3000000, + max_projects: -1, + max_users: -1, + name: 'Enterprise (Yearly)', + price: 5489, + retention_days: 180 + } +]; + +/** Helper to build a ViewOrganization with sensible defaults. */ +function makeOrg(overrides: Partial = {}): ViewOrganization { + return { + billing_change_date: null, + billing_changed_by_user_id: null, + billing_price: 0, + billing_status: 0 as never, + bonus_events_per_month: 0, + bonus_expiration: null, + card_last4: null, + created_utc: '2024-01-15T00:00:00Z', + data: null, + event_count: 427, + has_premium_features: false, + id: '507f1f77bcf86cd799439011', + invites: [], + is_over_monthly_limit: false, + is_over_request_limit: false, + is_suspended: false, + is_throttled: false, + max_events_per_month: 3000, + max_projects: 1, + max_users: 1, + name: 'Acme Corp', + plan_description: 'Free plan', + plan_id: 'EX_FREE', + plan_name: 'Free', + project_count: 1, + retention_days: 3, + stack_count: 12, + subscribe_date: null, + suspension_code: null, + suspension_date: null, + suspension_notes: null, + updated_utc: '2025-04-10T00:00:00Z', + usage: [], + usage_hours: [], + ...overrides + }; +} + +// ─── Storybook meta ───────────────────────────────────────────────────────── + +const meta = { + component: Harness, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + title: 'Features/Billing/ChangePlanDialog' +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// ─── Stories ──────────────────────────────────────────────────────────────── + +/** Plans failed to load — shows error message. */ +export const ErrorLoadingPlans: Story = { + args: { + organization: makeOrg(), + plans: [] as BillingPlan[] + }, + name: 'Error loading plans' +}; + +/** Free-plan org, dialog open — upsells to the first paid tier (Small). */ +export const Default: Story = { + args: { + organization: makeOrg(), + plans: MOCK_PLANS + } +}; + +/** Paid Small monthly org selects a different paid plan (Large monthly). */ +export const ChangePlan: Story = { + args: { + organization: makeOrg({ + billing_price: 15, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + plan_id: 'EX_SMALL', + plan_name: 'Small', + retention_days: 30, + subscribe_date: '2024-06-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Change plan' +}; + +/** Small monthly org switches to yearly billing (same tier). */ +export const IntervalSwitch: Story = { + args: { + organization: makeOrg({ + billing_price: 15, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + plan_id: 'EX_SMALL', + plan_name: 'Small', + retention_days: 30, + subscribe_date: '2024-06-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Interval switch (Small yearly)' +}; + +/** Paid org keeps current plan but wants to update their payment method. */ +export const UpdateCardOnly: Story = { + args: { + organization: makeOrg({ + billing_price: 49, + card_last4: '1234', + has_premium_features: true, + max_events_per_month: 75000, + max_projects: 15, + max_users: 25, + plan_id: 'EX_MEDIUM', + plan_name: 'Medium', + retention_days: 90, + subscribe_date: '2024-03-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Update card only' +}; + +/** Paid org keeps current plan and wants to apply a coupon. */ +export const ApplyCouponOnly: Story = { + args: { + organization: makeOrg({ + billing_price: 99, + card_last4: '5678', + has_premium_features: true, + max_events_per_month: 250000, + max_projects: -1, + max_users: -1, + plan_id: 'EX_LARGE', + plan_name: 'Large', + retention_days: 180, + subscribe_date: '2024-01-15T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Apply coupon only' +}; + +/** Paid org changes plan, updates card, and applies a coupon — all at once. */ +export const PlanCardCoupon: Story = { + args: { + organization: makeOrg({ + billing_price: 15, + card_last4: '9999', + has_premium_features: true, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + plan_id: 'EX_SMALL', + plan_name: 'Small', + retention_days: 30, + subscribe_date: '2024-09-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Plan + card + coupon' +}; + +/** Free-plan org upgrading to a paid plan for the first time (no card on file). */ +export const FirstTimePaid: Story = { + args: { + organization: makeOrg({ + billing_price: 0, + card_last4: null, + plan_id: 'EX_FREE', + plan_name: 'Free' + }), + plans: MOCK_PLANS + }, + name: 'First-time paid' +}; + +/** Paid org selecting the Free plan to downgrade. */ +export const DowngradeToFree: Story = { + args: { + organization: makeOrg({ + billing_price: 199, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 1000000, + max_projects: -1, + max_users: -1, + plan_id: 'EX_XL', + plan_name: 'Extra Large', + retention_days: 180, + subscribe_date: '2023-11-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Downgrade to Free' +}; 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 index 9555c7c41f..fe1a1384f1 100644 --- 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 @@ -4,6 +4,7 @@ import type { Stripe, StripeElements } from '@stripe/stripe-js'; import ErrorMessage from '$comp/error-message.svelte'; + import { Muted } from '$comp/typography'; import { Button } from '$comp/ui/button'; import * as Dialog from '$comp/ui/dialog'; import { Input } from '$comp/ui/input'; @@ -13,13 +14,14 @@ import { type ChangePlanFormData, ChangePlanSchema } from '$features/billing/schemas'; import { changePlanMutation, getPlansQuery } from '$features/organizations/api.svelte'; import { getFormErrorMessages } from '$features/shared/validation'; + import { Exceptionless } from '@exceptionless/browser'; import Check from '@lucide/svelte/icons/check'; import CreditCard from '@lucide/svelte/icons/credit-card'; import Plus from '@lucide/svelte/icons/plus'; - import X from '@lucide/svelte/icons/x'; import { createForm } from '@tanstack/svelte-form'; - import { toast } from 'svelte-sonner'; import { untrack } from 'svelte'; + import { toast } from 'svelte-sonner'; + import { SvelteMap } from 'svelte/reactivity'; interface Props { open: boolean; @@ -44,70 +46,92 @@ } }); - // ─────────────────────────────────────────────────────────────── - // Plan tier / interval model - // - // Backend ships 11 plan IDs: EX_FREE + 5 monthly (EX_SMALL, EX_MEDIUM, - // EX_LARGE, EX_XL, EX_ENT) + 5 yearly (…_YEARLY). The monthly/yearly - // toggle is derived UI state — we group plans by tier key (strip - // _YEARLY) and submit the concrete plan ID on save. - // ─────────────────────────────────────────────────────────────── const YEARLY_SUFFIX = '_YEARLY'; - // Hand-curated "most popular" tier — backend doesn't carry this flag. - const POPULAR_TIER = 'EX_MEDIUM'; + const POPULAR_TIER = 'EX_XL'; function tierOf(planId: string): string { return planId.endsWith(YEARLY_SUFFIX) ? planId.slice(0, -YEARLY_SUFFIX.length) : planId; } + function intervalOf(planId: string): 'month' | 'year' { return planId.endsWith(YEARLY_SUFFIX) ? 'year' : 'month'; } interface PlanTier { - id: string; // tier key — matches the monthly plan ID - name: string; // e.g. "Small" + id: string; monthly: BillingPlan | null; - yearly: BillingPlan | null; + name: string; popular: boolean; + yearly: BillingPlan | null; + } + + function shouldIncludeInTiers(plan: BillingPlan): boolean { + if (plan.is_hidden) { + return false; + } + if (plan.id === FREE_PLAN_ID) { + return false; + } + if (plan.price === 0) { + return false; + } + + return true; } const tiers = $derived.by(() => { - if (!plansQuery.data) return []; - const byTier = new Map(); - for (const p of plansQuery.data) { - if (p.is_hidden || p.id === FREE_PLAN_ID) continue; - const key = tierOf(p.id); + if (!plansQuery.data) { + return []; + } + + const byTier = new SvelteMap(); + + for (const plan of plansQuery.data) { + if (!shouldIncludeInTiers(plan)) { + continue; + } + + const key = tierOf(plan.id); const current = byTier.get(key) ?? { id: key, - name: p.name.replace(/\s*\(Yearly\)\s*$/i, '').trim(), monthly: null, - yearly: null, - popular: key === POPULAR_TIER + name: plan.name.replace(/\s*\(Yearly\)\s*$/i, '').trim(), + popular: key === POPULAR_TIER, + yearly: null }; - if (intervalOf(p.id) === 'year') current.yearly = p; - else current.monthly = p; + + if (intervalOf(plan.id) === 'year') { + current.yearly = plan; + } else { + current.monthly = plan; + } + byTier.set(key, current); } - // Preserve backend ordering by first-seen monthly tier + const ordered: PlanTier[] = []; - for (const p of plansQuery.data) { - if (p.is_hidden || p.id === FREE_PLAN_ID) continue; - const key = tierOf(p.id); + for (const plan of plansQuery.data) { + if (!shouldIncludeInTiers(plan)) { + continue; + } + + const key = tierOf(plan.id); const tier = byTier.get(key); - if (tier && !ordered.includes(tier)) ordered.push(tier); + if (tier && !ordered.includes(tier)) { + ordered.push(tier); + } } + return ordered; }); - const freePlan = $derived(plansQuery.data?.find((p: BillingPlan) => p.id === FREE_PLAN_ID) ?? null); - const isFreeCurrent = $derived(organization.plan_id === FREE_PLAN_ID); + const freePlan = $derived(plansQuery.data?.find((plan: BillingPlan) => plan.id === FREE_PLAN_ID) ?? null); + const isFreeCurrent = $derived(organization.plan_id === FREE_PLAN_ID); const currentInterval = $derived<'month' | 'year'>(intervalOf(organization.plan_id)); const currentTierId = $derived(tierOf(organization.plan_id)); + const currentTierIndex = $derived(tiers.findIndex((t) => t.id === currentTierId)); - // ─────────────────────────────────────────────────────────────── - // UI state - // ─────────────────────────────────────────────────────────────── - let selectedTierId = $state(''); // '' = Free + let selectedTierId = $state(''); let interval = $state<'month' | 'year'>('month'); let paymentExpanded = $state(false); let couponOpen = $state(false); @@ -116,34 +140,48 @@ const hasExistingCard = $derived(!!organization.card_last4); - // Resolve selected plan ID from tier + interval, falling back to monthly - // when the tier doesn't offer a yearly option. - function resolvePlanId(tierId: string, iv: 'month' | 'year'): string { - if (!tierId) return FREE_PLAN_ID; + function resolvePlanId(tierId: string, billingInterval: 'month' | 'year'): string { + if (!tierId) { + return FREE_PLAN_ID; + } + const tier = tiers.find((t) => t.id === tierId); - if (!tier) return FREE_PLAN_ID; - if (iv === 'year' && tier.yearly) return tier.yearly.id; + if (!tier) { + return FREE_PLAN_ID; + } + + if (billingInterval === 'year' && tier.yearly) { + return tier.yearly.id; + } + return tier.monthly?.id ?? tier.yearly?.id ?? FREE_PLAN_ID; } const selectedPlanId = $derived(resolvePlanId(selectedTierId, interval)); - const selectedPlan = $derived( - plansQuery.data?.find((p: BillingPlan) => p.id === selectedPlanId) ?? null - ); + const selectedPlan = $derived(plansQuery.data?.find((plan: BillingPlan) => plan.id === selectedPlanId) ?? null); const isFreeSelected = $derived(selectedPlanId === FREE_PLAN_ID); const isPaidPlan = $derived(!!selectedPlan && selectedPlan.price > 0); const isCurrentPlan = $derived(selectedPlanId === organization.plan_id); - // Dirty tracking const planDirty = $derived(!isCurrentPlan); const paymentDirty = $derived(paymentExpanded && hasExistingCard); const needsPayment = $derived(isPaidPlan && (!hasExistingCard || paymentExpanded)); const couponDirty = $derived(!!couponApplied); const anyDirty = $derived(planDirty || paymentDirty || couponDirty); - // ─────────────────────────────────────────────────────────────── - // Stripe wiring - // ─────────────────────────────────────────────────────────────── + const isDowngrade = $derived.by(() => { + if (!planDirty) { + return false; + } + + if (isFreeSelected) { + return true; + } + + const selectedIdx = tiers.findIndex((t) => t.id === selectedTierId); + return selectedIdx >= 0 && currentTierIndex >= 0 && selectedIdx < currentTierIndex; + }); + let stripe = $state(null); let stripeElements = $state(undefined); @@ -195,17 +233,21 @@ toast.success(result.message ?? 'Your billing plan has been successfully changed.'); open = false; + return null; } catch (error: unknown) { + await Exceptionless.createException(error instanceof Error ? error : new Error(String(error))) + .setProperty('organizationId', organization.id) + .setProperty('selectedPlanId', value.selectedPlanId) + .addTags('billing', 'change-plan') + .submit(); + return { form: error instanceof Error ? error.message : 'An unexpected error occurred' }; } } } })); - // ─────────────────────────────────────────────────────────────── - // Keep form in sync with UI state (it's the canonical submit value) - // ─────────────────────────────────────────────────────────────── $effect(() => { const planId = selectedPlanId; const mode = needsPayment ? 'new' : 'existing'; @@ -217,12 +259,19 @@ }); }); - // Reset dialog state when it opens $effect(() => { if (open && plansQuery.data) { untrack(() => { - // Default to current plan so "Save changes" is disabled at open - selectedTierId = currentTierId === FREE_PLAN_ID ? '' : currentTierId; + const nextTierIndex = currentTierIndex + 1; + const upsellTier = nextTierIndex < tiers.length ? tiers[nextTierIndex] : null; + + if (isFreeCurrent && tiers.length > 0) { + selectedTierId = tiers[0]!.id; + } else if (upsellTier) { + selectedTierId = upsellTier.id; + } else { + selectedTierId = currentTierId === FREE_PLAN_ID ? '' : currentTierId; + } interval = currentInterval; paymentExpanded = false; couponOpen = false; @@ -233,9 +282,6 @@ } }); - // ─────────────────────────────────────────────────────────────── - // Handlers - // ─────────────────────────────────────────────────────────────── function selectTier(tierId: string) { selectedTierId = tierId; } @@ -263,7 +309,10 @@ function onCouponApply() { const code = couponInput.trim(); - if (!code) return; + if (!code) { + return; + } + couponApplied = code.toUpperCase(); couponOpen = false; couponInput = ''; @@ -277,69 +326,135 @@ open = false; } - // ─────────────────────────────────────────────────────────────── - // Display helpers - // ─────────────────────────────────────────────────────────────── function formatEvents(n: number): string { - if (n < 0) return 'Unlimited events'; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M events/mo`; - if (n >= 1_000) return `${Math.round(n / 1_000)}K events/mo`; + if (n < 0) { + return 'Unlimited events'; + } + + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M events/mo`; + } + + if (n >= 1_000) { + return `${Math.round(n / 1_000)}K events/mo`; + } + return `${n} events/mo`; } + function formatUsers(n: number): string { - return n < 0 ? 'Unlimited users' : `${n} users`; + if (n < 0) { + return 'Unlimited users'; + } + + return `${n} users`; } + function formatRetention(days: number): string { return `${days} day${days === 1 ? '' : 's'}`; } - function tierPrice(tier: PlanTier, iv: 'month' | 'year') { - if (iv === 'year' && tier.yearly) { + + function tierPrice(tier: PlanTier, billingInterval: 'month' | 'year') { + if (billingInterval === 'year' && tier.yearly) { const perMonth = tier.yearly.price / 12; + return { amount: `$${tier.yearly.price}`, period: '/yr', sub: `~$${perMonth.toFixed(0)}/mo` }; } - const p = tier.monthly ?? tier.yearly; - if (!p) return { amount: '—', period: '', sub: '' }; - return { amount: `$${p.price}`, period: '/mo', sub: '' }; + + const plan = tier.monthly ?? tier.yearly; + if (!plan) { + return { amount: '—', period: '', sub: '' }; + } + + return { amount: `$${plan.price}`, period: '/mo', sub: '' }; } + const yearlySavingsLabel = $derived.by(() => { - const sample = tiers.find((t) => t.monthly && t.yearly); - if (!sample?.monthly || !sample.yearly) return null; - const fullYear = sample.monthly.price * 12; - const saved = fullYear - sample.yearly.price; - const pct = Math.round((saved / fullYear) * 100); - return pct > 0 ? `Save ${pct}%` : null; + const percentages = tiers + .filter((t) => t.monthly && t.yearly) + .map((t) => { + const fullYear = t.monthly!.price * 12; + const saved = fullYear - t.yearly!.price; + + return Math.round((saved / fullYear) * 100); + }); + + if (percentages.length === 0) { + return null; + } + + const allSame = percentages.every((pct) => pct === percentages[0]); + const display = allSame ? percentages[0] : Math.min(...percentages); + + if (!display || display <= 0) { + return null; + } + + return allSame ? `Save ${display}%` : `Save ~${display}%`; }); - function intervalWord(iv: 'month' | 'year'): string { - return iv === 'year' ? 'yearly' : 'monthly'; + + function intervalWord(billingInterval: 'month' | 'year'): string { + return billingInterval === 'year' ? 'yearly' : 'monthly'; } - function planLabel(planId: string): string { - if (planId === FREE_PLAN_ID) return 'Free'; + + function planLabel(planId: string, opts: { includeInterval?: boolean } = {}): string { + if (planId === FREE_PLAN_ID) { + return 'Free'; + } + const tier = tiers.find((t) => t.id === tierOf(planId)); - if (!tier) return planId; - return `${tier.name} ${intervalWord(intervalOf(planId))}`; + if (!tier) { + return planId; + } + + return opts.includeInterval ? `${tier.name} ${intervalWord(intervalOf(planId))}` : tier.name; } const currentSubtitle = $derived.by(() => { - if (organization.plan_id === FREE_PLAN_ID) return 'Free plan'; - const tier = tiers.find((t) => t.id === currentTierId); + if (organization.plan_id === FREE_PLAN_ID) { + return 'Free plan'; + } + const plan = plansQuery.data?.find((p: BillingPlan) => p.id === organization.plan_id); - if (!tier || !plan) return organization.plan_name; + const price = organization.billing_price > 0 ? organization.billing_price : (plan?.price ?? 0); + const name = tiers.find((t) => t.id === currentTierId)?.name ?? organization.plan_name; const period = currentInterval === 'year' ? '/yr' : '/mo'; - return `${tier.name} · $${plan.price}${period}, billed ${intervalWord(currentInterval)}`; + return `${name} · $${price}${period}, billed ${intervalWord(currentInterval)}`; }); - // CTA label — mirrors the mockup's dirty-aware logic const ctaLabel = $derived.by(() => { - if (!anyDirty) return 'Save changes'; - if (planDirty && isFreeSelected) return 'Downgrade to Free'; - if (planDirty && organization.plan_id === FREE_PLAN_ID) return `Start ${planLabel(selectedPlanId)}`; - if (planDirty) return `Switch to ${planLabel(selectedPlanId)}`; - if (paymentDirty && !couponDirty) return 'Update payment method'; - if (couponDirty && !paymentDirty) return 'Apply coupon'; + if (!anyDirty) { + return 'Save changes'; + } + + if (planDirty && isFreeSelected) { + return 'Downgrade to Free'; + } + + if (planDirty && organization.plan_id === FREE_PLAN_ID) { + return `Start ${planLabel(selectedPlanId, { includeInterval: true })}`; + } + + if (planDirty) { + const intervalChanged = intervalOf(organization.plan_id) !== intervalOf(selectedPlanId); + + return isDowngrade + ? `Downgrade to ${planLabel(selectedPlanId, { includeInterval: intervalChanged })}` + : `Switch to ${planLabel(selectedPlanId, { includeInterval: intervalChanged })}`; + } + + if (paymentDirty && !couponDirty) { + return 'Update payment method'; + } + + if (couponDirty && !paymentDirty) { + return 'Apply coupon'; + } + return 'Save changes'; }); @@ -351,11 +466,11 @@ Manage subscription -

+ {organization.name} - · + · {currentSubtitle} -

+ {#if !isStripeEnabled()} @@ -385,40 +500,41 @@
-
-
- Plan - {#if planDirty} - - {/if} -
- All changes prorated +
Plan
+ All changes prorated
-
-
{#each tiers as tier (tier.id)} {@const planForInterval = interval === 'year' && tier.yearly ? tier.yearly : tier.monthly} @@ -428,7 +544,9 @@
- {price.amount}{price.period} + {price.amount}{price.period}
{#if price.sub} -
{price.sub}
+ {price.sub} {/if}
{/each} -
- Free + Free
- {#if isPaidPlan}
-
- Payment method - {#if paymentDirty} - - {/if} -
+
Payment method
{#if hasExistingCard && paymentExpanded} - + {/if}
@@ -525,16 +650,12 @@ Paying with - ···· {organization.card_last4} + ···· {organization.card_last4}
- +
{:else}
@@ -557,24 +678,19 @@ {/if} - {#if !isFreeSelected}
-
- Coupon - {#if couponDirty} - - {/if} -
+
Coupon
{#if couponOpen && !couponApplied} - + {/if}
@@ -584,21 +700,16 @@ {couponApplied} — applied at checkout - +
{:else if couponOpen} -
+
{ if (e.key === 'Enter') { @@ -607,67 +718,67 @@ } }} /> - +
{:else} - + {/if} {/if}
- -
+
{#if !anyDirty} -
No changes yet
+ No changes yet {/if} {#if planDirty} + {@const intervalChanged = intervalOf(organization.plan_id) !== intervalOf(selectedPlanId)} + {@const includeInt = intervalChanged || organization.plan_id === FREE_PLAN_ID || isFreeSelected}
- Plan - + Plan + {#if isFreeSelected} - {planLabel(organization.plan_id)} + {planLabel(organization.plan_id, { includeInterval: true })} Free · immediate, prorated credit {:else if organization.plan_id === FREE_PLAN_ID} - Start {planLabel(selectedPlanId)} + Start {planLabel(selectedPlanId, { includeInterval: true })} {#if selectedPlan}· ${selectedPlan.price}{interval === 'year' ? '/yr' : '/mo'}{/if} {:else} - {planLabel(organization.plan_id)} + {planLabel(organization.plan_id, { includeInterval: includeInt })} - {planLabel(selectedPlanId)} + {planLabel(selectedPlanId, { includeInterval: includeInt })} {#if selectedPlan}· ${selectedPlan.price}{interval === 'year' ? '/yr' : '/mo'} · prorated today{/if} {/if} - +
{/if} {#if paymentDirty}
- Payment - + Payment + ···· {organization.card_last4} new payment method - +
{/if} {#if couponDirty}
- Coupon - + Coupon + {couponApplied} applied - +
{/if}
@@ -675,10 +786,8 @@ state.isSubmitting}> {#snippet children(isSubmitting)}
- - + - -
+ setInterval(value as 'month' | 'year')} class="w-full"> + + Monthly + + Yearly + {#if yearlySavingsLabel} + {yearlySavingsLabel} + {/if} + + +
{#each tiers as tier (tier.id)} @@ -541,11 +526,12 @@ {@const price = tierPrice(tier, interval)} {@const isCurrent = tier.id === currentTierId && (interval === currentInterval || !tier.yearly)} {@const isSelected = tier.id === selectedTierId} -
- + {/each} - +
From ff5db7c5635f8e861767ee8d3709f5f7308f307f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 07:35:08 -0500 Subject: [PATCH 20/43] feat: add follow-up hook to enforce task summary before completion - Introduces a "Stop" hook that blocks termination unless explicitly allowed. - Ensures that the agent provides a summary of completed and pending items and prompts for follow-up questions before stopping. --- .github/hooks/follow-up.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/hooks/follow-up.json diff --git a/.github/hooks/follow-up.json b/.github/hooks/follow-up.json new file mode 100644 index 0000000000..e421098bef --- /dev/null +++ b/.github/hooks/follow-up.json @@ -0,0 +1,11 @@ +{ + "hooks": { + "Stop": [ + { + "type": "command", + "command": "python3 -c \"\nimport json, sys\ndata = json.load(sys.stdin)\nif data.get('stop_hook_active', False):\n json.dump({'hookSpecificOutput': {'hookEventName': 'Stop'}}, sys.stdout)\nelse:\n json.dump({\n 'hookSpecificOutput': {\n 'hookEventName': 'Stop',\n 'decision': 'block',\n 'reason': 'Before stopping: 1) Summarize what was completed and what was not. 2) Ask the user if they have any follow-up questions, concerns, or additional changes. 3) If there are pending items from the original request that were not addressed, list them explicitly. Do NOT stop silently.'\n }\n }, sys.stdout)\n\"", + "timeout": 5 + } + ] + } +} From 6770e76f8fc7ac89a8fab51ef3486c43167e35e5 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 07:35:22 -0500 Subject: [PATCH 21/43] feat: enhance Change Plan dialog with state overrides and new stories - Add `initialCouponCode`, `initialCouponOpen`, and `initialFormError` props to allow pre-configuring the dialog state. - Implement new Storybook stories covering coupon application, invalid coupons, payment failures, and blocked downgrades. - Standardize the applied coupon display using the Alert component. - Improve layout padding in the dialog content area for better alignment. --- .../change-plan-dialog-harness.svelte | 7 +- .../components/change-plan-dialog.stories.ts | 100 ++++++++++++++++++ .../components/change-plan-dialog.svelte | 36 ++++--- 3 files changed, 128 insertions(+), 15 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte index ab398d305c..e5d9c1635e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte @@ -11,11 +11,14 @@ import ChangePlanDialog from './change-plan-dialog.svelte'; interface Props { + initialCouponCode?: string; + initialCouponOpen?: boolean; + initialFormError?: string; organization: ViewOrganization; plans: BillingPlan[]; } - let { organization, plans }: Props = $props(); + let { initialCouponCode, initialCouponOpen, initialFormError, organization, plans }: Props = $props(); accessToken.current = 'storybook-mock-token'; @@ -45,5 +48,5 @@ - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts index ea65468229..189341f710 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts @@ -343,3 +343,103 @@ export const DowngradeToFree: Story = { }, name: 'Downgrade to Free' }; + +/** Coupon input expanded — user clicked "Have a coupon code?". */ +export const CouponInputOpen: Story = { + args: { + initialCouponOpen: true, + organization: makeOrg({ + billing_price: 49, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 75000, + max_projects: 15, + max_users: 25, + plan_id: 'EX_MEDIUM', + plan_name: 'Medium', + retention_days: 90, + subscribe_date: '2024-03-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Coupon input open' +}; + +/** Coupon code applied — shows success alert with code and "Remove" action. */ +export const CouponApplied: Story = { + args: { + initialCouponCode: 'SAVE20', + organization: makeOrg({ + billing_price: 49, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 75000, + max_projects: 15, + max_users: 25, + plan_id: 'EX_MEDIUM', + plan_name: 'Medium', + retention_days: 90, + subscribe_date: '2024-03-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Coupon applied' +}; + +/** Invalid coupon — backend returned an error for the submitted coupon code. */ +export const ErrorInvalidCoupon: Story = { + args: { + initialCouponCode: 'EXPIRED99', + initialFormError: "No such coupon: 'EXPIRED99'. Please check the code and try again.", + organization: makeOrg({ + billing_price: 15, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + plan_id: 'EX_SMALL', + plan_name: 'Small', + retention_days: 30, + subscribe_date: '2024-06-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Error: invalid coupon' +}; + +/** Payment failed — Stripe rejected the card during plan change. */ +export const ErrorPaymentFailed: Story = { + args: { + initialFormError: 'Your card was declined. Please try a different payment method.', + organization: makeOrg({ + billing_price: 0, + card_last4: null, + plan_id: 'EX_FREE', + plan_name: 'Free' + }), + plans: MOCK_PLANS + }, + name: 'Error: payment failed' +}; + +/** Downgrade blocked — too many users or projects for the target plan. */ +export const ErrorDowngradeBlocked: Story = { + args: { + initialFormError: 'Please remove 3 users and try again.', + organization: makeOrg({ + billing_price: 199, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 1000000, + max_projects: -1, + max_users: -1, + plan_id: 'EX_XL', + plan_name: 'Extra Large', + retention_days: 180, + subscribe_date: '2023-11-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Error: downgrade blocked' +}; 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 index 1418c3228f..18d62b26af 100644 --- 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 @@ -5,6 +5,7 @@ import ErrorMessage from '$comp/error-message.svelte'; import { Muted } from '$comp/typography'; + import * as Alert from '$comp/ui/alert'; import { Badge } from '$comp/ui/badge'; import { Button } from '$comp/ui/button'; import * as Dialog from '$comp/ui/dialog'; @@ -26,11 +27,14 @@ import { SvelteMap } from 'svelte/reactivity'; interface Props { + initialCouponCode?: string; + initialCouponOpen?: boolean; + initialFormError?: string; open: boolean; organization: ViewOrganization; } - let { open = $bindable(), organization }: Props = $props(); + let { initialCouponCode, initialCouponOpen, initialFormError, open = $bindable(), organization }: Props = $props(); const plansQuery = getPlansQuery({ route: { @@ -276,10 +280,14 @@ } interval = currentInterval; paymentExpanded = false; - couponOpen = false; - couponApplied = null; + couponOpen = initialCouponOpen ?? false; + couponApplied = initialCouponCode ?? null; couponInput = ''; form.reset(); + + if (initialFormError) { + form.setErrorMap({ onSubmit: { form: initialFormError } }); + } }); } }); @@ -501,7 +509,7 @@ {/snippet} -
+
Plan
@@ -673,15 +681,17 @@
{#if couponApplied} -
- - {couponApplied} - — applied at checkout - - -
+ + + + {couponApplied} + — applied at checkout + + + + {:else if couponOpen}
Date: Mon, 20 Apr 2026 07:44:37 -0500 Subject: [PATCH 22/43] feat: refine Change Plan dialog UI and error display - Format prices with thousands separators and fix pluralization for user counts. - Adjust "Most popular" badge logic to only show for plan upgrades. - Explicitly render initial form errors using the ErrorMessage component instead of setting form state. --- .../components/change-plan-dialog.svelte | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) 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 index 18d62b26af..03d531eae6 100644 --- 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 @@ -284,10 +284,6 @@ couponApplied = initialCouponCode ?? null; couponInput = ''; form.reset(); - - if (initialFormError) { - form.setErrorMap({ onSubmit: { form: initialFormError } }); - } }); } }); @@ -357,19 +353,23 @@ return 'Unlimited users'; } - return `${n} users`; + return `${n} user${n === 1 ? '' : 's'}`; } function formatRetention(days: number): string { return `${days} day${days === 1 ? '' : 's'}`; } + function formatPrice(n: number): string { + return n.toLocaleString('en-US'); + } + function tierPrice(tier: PlanTier, billingInterval: 'month' | 'year') { if (billingInterval === 'year' && tier.yearly) { const perMonth = tier.yearly.price / 12; return { - amount: `$${tier.yearly.price}`, + amount: `$${formatPrice(tier.yearly.price)}`, period: '/yr', sub: `~$${perMonth.toFixed(0)}/mo` }; @@ -380,7 +380,7 @@ return { amount: '—', period: '', sub: '' }; } - return { amount: `$${plan.price}`, period: '/mo', sub: '' }; + return { amount: `$${formatPrice(plan.price)}`, period: '/mo', sub: '' }; } const yearlySavingsLabel = $derived.by(() => { @@ -433,7 +433,7 @@ const price = organization.billing_price > 0 ? organization.billing_price : (plan?.price ?? 0); const name = tiers.find((t) => t.id === currentTierId)?.name ?? organization.plan_name; const period = currentInterval === 'year' ? '/yr' : '/mo'; - return `${name} · $${price}${period}, billed ${intervalWord(currentInterval)}`; + return `${name} · $${formatPrice(price)}${period}, billed ${intervalWord(currentInterval)}`; }); const ctaLabel = $derived.by(() => { @@ -509,6 +509,10 @@ {/snippet} + {#if initialFormError} + + {/if} +
@@ -529,7 +533,7 @@
- {#each tiers as tier (tier.id)} + {#each tiers as tier, tierIdx (tier.id)} {@const planForInterval = interval === 'year' && tier.yearly ? tier.yearly : tier.monthly} {@const price = tierPrice(tier, interval)} {@const isCurrent = tier.id === currentTierId && (interval === currentInterval || !tier.yearly)} @@ -550,7 +554,7 @@ {tier.name} {#if isCurrent} Current - {:else if tier.popular} + {:else if tier.popular && tierIdx > currentTierIndex} Most popular {/if}
@@ -741,12 +745,12 @@ · immediate, prorated credit {:else if organization.plan_id === FREE_PLAN_ID} Start {planLabel(selectedPlanId, { includeInterval: true })} - {#if selectedPlan}· ${selectedPlan.price}{interval === 'year' ? '/yr' : '/mo'}{/if} + {#if selectedPlan}· ${formatPrice(selectedPlan.price)}{interval === 'year' ? '/yr' : '/mo'}{/if} {:else} {planLabel(organization.plan_id, { includeInterval: includeInt })} {planLabel(selectedPlanId, { includeInterval: includeInt })} - {#if selectedPlan}· ${selectedPlan.price}{interval === 'year' ? '/yr' : '/mo'} · prorated today{/if} + {#if selectedPlan}· ${formatPrice(selectedPlan.price)}{interval === 'year' ? '/yr' : '/mo'} · prorated today{/if} {/if}
From 304912d9c2004e9459b089239f1a7f021642c28d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 07:30:00 -0500 Subject: [PATCH 23/43] fix: handle \-price plans in tier filtering and yearly savings badge - Remove price === 0 exclusion from shouldIncludeInTiers; the FREE_PLAN_ID guard already handles the free plan; excluding on price breaks orgs with admin-negotiated \/mo plans (e.g. EX_SMALL with billing_price=0) - Guard yearly savings % calculation against division-by-zero when a tier has a monthly price of \ (NaN/Infinity was suppressing the Save badge) - Add ErrorPlanChangeFailed Storybook story (15th story) - Add Stripe test publishable key to Angular app config --- .../components/change-plan-dialog.stories.ts | 21 +++++++++++++++++++ .../components/change-plan-dialog.svelte | 5 +---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts index 189341f710..50df70e260 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts @@ -443,3 +443,24 @@ export const ErrorDowngradeBlocked: Story = { }, name: 'Error: downgrade blocked' }; + +/** Generic plan change failure — API returned a non-success result (network error, server error, etc.). */ +export const ErrorPlanChangeFailed: Story = { + args: { + initialFormError: 'An unexpected error occurred while changing your plan. Please try again or contact support.', + organization: makeOrg({ + billing_price: 15, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + plan_id: 'EX_SMALL', + plan_name: 'Small', + retention_days: 30, + subscribe_date: '2024-06-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Error: plan change failed' +}; 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 index 03d531eae6..12a4694d48 100644 --- 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 @@ -78,9 +78,6 @@ if (plan.id === FREE_PLAN_ID) { return false; } - if (plan.price === 0) { - return false; - } return true; } @@ -385,7 +382,7 @@ const yearlySavingsLabel = $derived.by(() => { const percentages = tiers - .filter((t) => t.monthly && t.yearly) + .filter((t) => t.monthly && t.yearly && t.monthly.price > 0) .map((t) => { const fullYear = t.monthly!.price * 12; const saved = fullYear - t.yearly!.price; From 74ebc4eec6f1db1c99b9af6e552b3edd2a5e8b2d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 07:30:00 -0500 Subject: [PATCH 24/43] refactor: remove redundant ex.Message from log templates The exception object is already passed as the first parameter to LogError/LogWarning which captures the full exception including message. Adding ex.Message as a template parameter causes it to appear twice in structured log output. --- src/Exceptionless.Web/Controllers/OrganizationController.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 3a44a327bb..ffd202b9e8 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -230,7 +230,7 @@ public async Task> GetInvoiceAsync(string id) } catch (Exception ex) { - _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}. Exception: {Message}", id, ex.Message); + _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}", id); } if (String.IsNullOrEmpty(stripeInvoice?.CustomerId)) @@ -282,9 +282,9 @@ public async Task> GetInvoiceAsync(string id) decimal unitAmountCents = line.Pricing?.UnitAmountDecimal ?? price.UnitAmount ?? 0; item.Description = $"Exceptionless - {planName} Plan ({unitAmountCents / 100.0m:c}/{interval})"; } - catch (Exception ex) + catch (StripeException ex) { - _logger.LogWarning(ex, "Failed to fetch price details for price ID: {PriceId}. Exception: {Message}", priceId, ex.Message); + _logger.LogWarning(ex, "Failed to fetch price details for price: {PriceId}. Error: {ErrorMessage}", priceId, ex.Message); } } From bba4e40fac64bcf3259733874edc87bfa8a30d42 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 07:30:00 -0500 Subject: [PATCH 25/43] docs: add billing & Stripe integration documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers architecture, API endpoints, plan structure, frontend components, Stripe SDK v47→v51 migration notes, known limitations, Storybook stories, and security considerations. --- docs/billing-stripe-integration.md | 201 +++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/billing-stripe-integration.md diff --git a/docs/billing-stripe-integration.md b/docs/billing-stripe-integration.md new file mode 100644 index 0000000000..d784d66a65 --- /dev/null +++ b/docs/billing-stripe-integration.md @@ -0,0 +1,201 @@ +# Billing & Stripe Integration + +## Overview + +Exceptionless uses [Stripe](https://stripe.com) for subscription billing. The integration spans: + +- **Backend**: ASP.NET Core controller (`OrganizationController`) + Stripe.net SDK v51 +- **Frontend**: Svelte 5 dialog (`ChangePlanDialog`) + Stripe.js v9 PaymentElement +- **Legacy**: Angular app supports `tok_` tokens via `createToken()` (backwards compatible) + +## Architecture + +```text +┌─────────────────┐ ┌──────────────────┐ ┌─────────┐ +│ Svelte Dialog │────>│ /change-plan │────>│ Stripe │ +│ (PaymentElement)│ │ Controller │ │ API │ +│ pm_ tokens │ │ (Stripe.net 51) │ │ │ +├─────────────────┤ │ │ │ │ +│ Angular Dialog │────>│ Detects pm_ vs │────>│ │ +│ (createToken) │ │ tok_ prefix │ │ │ +│ tok_ tokens │ └──────────────────┘ └─────────┘ +└─────────────────┘ +``` + +## Configuration + +### Environment Variables + +| Variable | Where | Purpose | +| --- | --- | --- | +| `StripeApiKey` | Server (`AppOptions.StripeOptions`) | Secret API key for server-side Stripe calls | +| `PUBLIC_STRIPE_PUBLISHABLE_KEY` | Svelte (`ClientApp/.env.local`) | Publishable key for Stripe.js | +| `STRIPE_PUBLISHABLE_KEY` | Angular (`app.config.js`) | Publishable key for legacy UI | + +The server-side key is injected via environment variables or `appsettings.Local.yml` (gitignored). + +### Enabling Billing + +Billing is enabled when `StripeApiKey` is configured. The frontend checks `isStripeEnabled()` (reads `PUBLIC_STRIPE_PUBLISHABLE_KEY`). If not set, the dialog shows "Billing is currently disabled." + +## API Endpoints + +### `GET /api/v2/organizations/{id}/plans` + +Returns available billing plans for the organization. The current org's plan entry is replaced with runtime billing values (custom pricing, limits). + +**Auth**: `UserPolicy` +**Response**: `BillingPlan[]` + +### `POST /api/v2/organizations/{id}/change-plan` + +Changes the organization's billing plan. + +**Auth**: `UserPolicy` + `CanAccessOrganization(id)` +**Parameters** (query string): + +| Param | Type | Description | +| --- | --- | --- | +| `planId` | string | Target plan ID (e.g., `EX_MEDIUM`, `EX_LARGE_YEARLY`) | +| `stripeToken` | string? | `pm_` PaymentMethod ID (Svelte) or `tok_` token (Angular) | +| `last4` | string? | Last 4 digits of card (display only) | +| `couponId` | string? | Stripe coupon code | + +**Response**: `ChangePlanResult { success, message }` + +**Behavior**: + +1. If no `StripeCustomerId` → creates Stripe customer + subscription +2. If existing customer → updates customer + subscription +3. `pm_` tokens use `PaymentMethod` API; `tok_` tokens use legacy `Source` API +4. Coupons applied via `SubscriptionDiscountOptions` (Stripe.net 50.x+) + +### `GET /api/v2/organizations/invoice/{id}` + +Returns a single invoice with line items. + +**Auth**: `UserPolicy` + `CanAccessOrganization` +**Response**: `Invoice { id, organization_id, organization_name, date, paid, total, items[] }` + +### `GET /api/v2/organizations/{id}/invoices` + +Returns paginated invoice grid for the organization. + +**Auth**: `UserPolicy` +**Response**: `InvoiceGridModel[]` + +## Plan Structure + +Plans follow a tiered naming convention: + +| Tier | Monthly ID | Yearly ID | +| --- | --- | --- | +| Free | `EX_FREE` | — | +| Small | `EX_SMALL` | `EX_SMALL_YEARLY` | +| Medium | `EX_MEDIUM` | `EX_MEDIUM_YEARLY` | +| Large | `EX_LARGE` | `EX_LARGE_YEARLY` | +| Extra Large | `EX_XL` | `EX_XL_YEARLY` | +| Enterprise | `EX_ENT` | `EX_ENT_YEARLY` | + +The frontend groups monthly/yearly variants into "tiers" for the UI. The `_YEARLY` suffix determines the billing interval. + +## Frontend Components + +### `ChangePlanDialog` + +Main billing dialog at `src/lib/features/billing/components/change-plan-dialog.svelte`. + +**Props**: + +- `open: boolean` — bindable dialog visibility +- `organization: ViewOrganization` — current org data +- `initialCouponCode?: string` — pre-fill coupon +- `initialCouponOpen?: boolean` — open coupon input on mount +- `initialFormError?: string` — show error message on mount + +**Features**: + +- Tile-based plan selection with Monthly/Yearly tabs +- "Save X%" badge computed from tier pricing differences +- "MOST POPULAR" badge on XL tier (only for upgrades) +- Stripe PaymentElement for new payment methods +- "Keep current card" / "Use a different payment method" toggle +- Coupon input with apply/remove +- Footer summary showing plan change, payment, and coupon details +- Destructive (red) CTA for downgrades, default (green) for upgrades +- Disabled CTA when no changes +- Form validation via TanStack Form + Zod (`ChangePlanSchema`) +- Error reporting via Exceptionless client + +### `StripeProvider` + +Imperative Stripe.js loader at `src/lib/features/billing/components/stripe-provider.svelte`. + +Uses direct DOM manipulation instead of svelte-stripe's `` / `` due to a Svelte 5 reactivity issue where `$state` set from async callbacks doesn't reliably trigger template re-renders. + +### `UpgradeRequiredDialog` + +Handles 426 responses with "Upgrade Plan" / "Cancel" buttons. Mounted in the app layout. + +### `handleUpgradeRequired(error, organizationId)` + +Utility used across 6 route pages to intercept `ProblemDetails` with `status: 426` and open the upgrade dialog. + +## Stripe SDK Migration Notes (v47 → v51) + +### Breaking Changes Handled + +1. **`Invoice.Paid` removed** → Use `String.Equals(invoice.Status, "paid", StringComparison.Ordinal)` +2. **`Invoice.Discount` removed** → Use `Invoice.Discounts?.FirstOrDefault(d => d.Deleted is not true)?.Source?.Coupon` +3. **`line.Plan` removed** → Use `line.Pricing?.PriceDetails?.PriceId` + `PriceService.GetAsync()` +4. **`CustomerCreateOptions.Plan` deprecated** → Create subscription separately +5. **`CustomerCreateOptions.Coupon` removed** → Use `SubscriptionDiscountOptions` +6. **`SubscriptionItemOptions.Plan` removed** → Use `SubscriptionItemOptions.Price` +7. **`CustomerCreateOptions.Source` deprecated for pm_ tokens** → Use `CustomerCreateOptions.PaymentMethod` + +### Backwards Compatibility + +The `tok_` token path (Angular legacy UI) is preserved. The controller detects `pm_` vs `tok_` prefix and routes to the appropriate Stripe API: + +```csharp +bool isPaymentMethod = stripeToken?.StartsWith("pm_", StringComparison.Ordinal) == true; +``` + +## Known Limitations + +1. **Coupon not applied for existing customers changing plans** — Pre-existing limitation (not a regression). The coupon is only applied when creating a new Stripe customer. +2. **Potential orphaned Stripe customers** — If subscription creation fails after customer creation, a retry would create a duplicate Stripe customer. Mitigated by the low likelihood of this failure path. +3. **N+1 price fetches in invoice view** — Each unique price ID in an invoice makes a separate Stripe API call. Mitigated by a per-request cache (`priceCache`). Most invoices have 1-3 distinct prices. +4. **svelte-stripe package unused** — Listed in `package.json` but bypassed due to Svelte 5 incompatibility. Only `@stripe/stripe-js` is used directly. + +## Storybook + +15 stories cover all dialog states: + +| Story | Description | +| --- | --- | +| Error loading plans | Plans query failed | +| Default | Free plan org, Small pre-selected | +| Change plan | Small → Medium upgrade | +| Interval switch | Monthly → Yearly toggle | +| Update card only | Change payment method without plan change | +| Apply coupon only | Apply coupon without plan change | +| Plan + card + coupon | All three changes | +| First-time paid | Free → first paid plan | +| Downgrade to Free | Cancel paid plan | +| Coupon input open | Coupon text field visible | +| Coupon applied | Coupon alert shown | +| Error: invalid coupon | Bad coupon with form error | +| Error: payment failed | Payment declined error | +| Error: downgrade blocked | Downgrade blocked by limits | +| Error: plan change failed | Generic plan change error | + +Run stories: `cd src/Exceptionless.Web/ClientApp && npm run storybook` + +## Security Considerations + +- **Server-side Stripe API key** is never exposed to the client. Only the publishable key is used in the frontend. +- **All billing endpoints require `UserPolicy` auth** and `CanAccessOrganization` access checks. +- **Token/PaymentMethod IDs are validated by Stripe** server-side — no additional format validation needed. +- **Coupon codes are validated by Stripe** — no injection risk. +- **No PII in logs** — only invoice IDs, price IDs, and plan IDs are logged. From 29b81efb5a4335fe614779f619685a151635a9ca Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 07:30:00 -0500 Subject: [PATCH 26/43] fix(a11y): fix WCAG AA color contrast on primary buttons and badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit White text on lime green primary (hsl(96 64% 46%)) achieved only 2.30:1 contrast ratio, failing WCAG 2.2 AA minimum (4.5:1 for normal text). Fixed by setting --primary-foreground to dark text (hsl(0 0% 10%)) in both light and dark modes, yielding 8.15:1 contrast ratio. Also removes unused svelte-stripe package — the billing dialog uses @stripe/stripe-js directly via the imperative Elements API. --- src/Exceptionless.Web/ClientApp/package-lock.json | 11 ----------- src/Exceptionless.Web/ClientApp/package.json | 1 - src/Exceptionless.Web/ClientApp/src/app.css | 4 ++-- .../billing/components/change-plan-dialog.svelte | 4 ++-- 4 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index abb9f6cc92..4c158ee38b 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -32,7 +32,6 @@ "shiki": "^4.0.2", "svelte-intercom": "^0.0.35", "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", @@ -7858,16 +7857,6 @@ "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 3fe9a93265..8cdfa3b1d4 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -92,7 +92,6 @@ "shiki": "^4.0.2", "svelte-intercom": "^0.0.35", "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", diff --git a/src/Exceptionless.Web/ClientApp/src/app.css b/src/Exceptionless.Web/ClientApp/src/app.css index 8cd26aeb5c..ed44232e36 100644 --- a/src/Exceptionless.Web/ClientApp/src/app.css +++ b/src/Exceptionless.Web/ClientApp/src/app.css @@ -20,7 +20,7 @@ --input: hsl(220 13% 91%); --primary: hsl(96 64% 46%); - --primary-foreground: hsl(0 0% 100%); + --primary-foreground: hsl(0 0% 10%); --secondary: hsl(210 20% 98%); --secondary-foreground: hsl(240 5.9% 10%); @@ -69,7 +69,7 @@ --input: hsl(215 12.24% 19.22%); --primary: hsl(96 64.1% 45.88%); - --primary-foreground: hsl(60 100% 96.27%); + --primary-foreground: hsl(0 0% 10%); --secondary: hsl(215 15.38% 15.29%); --secondary-foreground: hsl(0 0% 97.25%); 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 index 12a4694d48..a58755f0ca 100644 --- 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 @@ -523,7 +523,7 @@ Yearly {#if yearlySavingsLabel} - {yearlySavingsLabel} + {yearlySavingsLabel} {/if} @@ -552,7 +552,7 @@ {#if isCurrent} Current {:else if tier.popular && tierIdx > currentTierIndex} - Most popular + Most popular {/if}
{#if planForInterval} From 2e1ce7b1f6ca3572b7114acbe9b8ff166d011dfd Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 07:30:00 -0500 Subject: [PATCH 27/43] fix: address PR review findings - Accept change-plan params from JSON body (new Svelte client) with query string fallback for backwards compat with legacy Angular client. Stripe PaymentMethod sent in body avoids exposure in server/proxy logs. - Apply coupon to existing-customer subscription update/create paths (was previously only applied to new-customer path) - Narrow catch(Exception) to catch(StripeException) in price fetch loop so programming errors propagate rather than being silently swallowed - InvoiceMapper: use OrdinalIgnoreCase for Stripe status comparison - stripe.svelte.ts: reset cached promise on loadStripeOnce failure so callers can retry without a full page refresh - api.svelte.ts: import ChangePlanParams from billing/models.ts instead of redefining the same shape (removes duplication) - .vscode/settings.json: fix TypeScript SDK key (js/ts.tsdk.path -> typescript.tsdk) --- .vscode/settings.json | 2 +- .../components/change-plan-dialog.svelte | 6 ++--- .../src/lib/features/billing/models.ts | 7 +++--- .../src/lib/features/billing/stripe.svelte.ts | 12 ++++++++-- .../lib/features/organizations/api.svelte.ts | 16 ++------------ .../Controllers/OrganizationController.cs | 22 +++++++++++++++---- .../Mapping/InvoiceMapper.cs | 2 +- .../Models/ChangePlanRequest.cs | 21 ++++++++++++++++++ 8 files changed, 60 insertions(+), 28 deletions(-) create mode 100644 src/Exceptionless.Web/Models/ChangePlanRequest.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index 2353435158..dcd5362e1d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -111,7 +111,7 @@ "tailwindCSS.includeLanguages": { "svelte": "html" }, - "js/ts.tsdk.path": "src/Exceptionless.Web/ClientApp/node_modules/typescript/lib", + "typescript.tsdk": "src/Exceptionless.Web/ClientApp/node_modules/typescript/lib", "workbench.editor.customLabels.patterns": { "**/lib/**/*.ts": "${dirname}/${filename}.${extname}", "**/routes/**/+page.svelte": "${dirname(1)}/${dirname}", 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 index a58755f0ca..ec51bb6c85 100644 --- 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 @@ -224,10 +224,10 @@ } const result = await changePlan.mutateAsync({ - couponId: value.couponId || undefined, + coupon_id: value.couponId || undefined, last4, - planId: value.selectedPlanId, - stripeToken + plan_id: value.selectedPlanId, + stripe_token: stripeToken }); if (!result.success) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts index 9a21996e53..0f65481538 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts @@ -20,14 +20,15 @@ export interface ChangePlanFormState { /** * Parameters for the change-plan API call. + * Keys match the snake_case convention of the Exceptionless API. */ export interface ChangePlanParams { /** Optional coupon code to apply */ - couponId?: string; + coupon_id?: string; /** Last 4 digits of the card (for display purposes) */ last4?: string; /** The plan ID to change to */ - planId: string; + plan_id: string; /** Stripe PaymentMethod ID or legacy token */ - stripeToken?: string; + stripe_token?: string; } 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 index 93e2bf5acc..b3cf3a461f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts @@ -40,6 +40,7 @@ let _stripeInstance: null | Stripe = null; /** * Load Stripe instance lazily. Returns cached instance if already loaded. + * Resets on failure so subsequent calls can retry (e.g. after a transient network error). */ export async function loadStripeOnce(): Promise { if (_stripeInstance) { @@ -54,8 +55,15 @@ export async function loadStripeOnce(): Promise { _stripePromise = loadStripe(env.PUBLIC_STRIPE_PUBLISHABLE_KEY!); } - _stripeInstance = await _stripePromise; - return _stripeInstance; + try { + _stripeInstance = await _stripePromise; + return _stripeInstance; + } catch (err) { + // Reset so the next call can retry instead of re-awaiting the rejected promise + _stripePromise = null; + _stripeInstance = null; + throw err; + } } /** 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 17707b7ba2..f310b3122c 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 @@ -3,6 +3,7 @@ import type { BillingPlan, ChangePlanResult } from '$lib/generated/api'; import type { QueryClient } from '@tanstack/svelte-query'; import { accessToken } from '$features/auth/index.svelte'; +import { type ChangePlanParams } from '$features/billing/models'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; @@ -44,17 +45,6 @@ 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; @@ -187,9 +177,7 @@ export function changePlanMutation(request: ChangePlanRequest) { 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 - }); + const response = await client.postJSON(`organizations/${request.route.organizationId}/change-plan`, params); return response.data!; }, diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index ffd202b9e8..6c8df12bab 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -246,7 +246,7 @@ public async Task> GetInvoiceAsync(string id) OrganizationId = organization.Id, OrganizationName = organization.Name, Date = stripeInvoice.Created, - Paid = String.Equals(stripeInvoice.Status, "paid", StringComparison.Ordinal), + Paid = String.Equals(stripeInvoice.Status, "paid", StringComparison.OrdinalIgnoreCase), Total = stripeInvoice.Total / 100.0m }; @@ -405,11 +405,21 @@ public async Task>> GetPlansAsync( /// The token returned from the stripe service. /// The last four numbers of the card. /// The coupon id. + /// Optional JSON body (Svelte client); query params take precedence when body is null. /// The organization was not found. [HttpPost] [Route("{id:objectid}/change-plan")] - public async Task> ChangePlanAsync(string id, string planId, string? stripeToken = null, string? last4 = null, string? couponId = null) + public async Task> ChangePlanAsync(string id, [FromQuery] string? planId = null, [FromQuery] string? stripeToken = null, [FromQuery] string? last4 = null, [FromQuery] string? couponId = null, [FromBody] ChangePlanRequest? body = null) { + // Accept params from either JSON body (new Svelte client) or query string (legacy Angular) + planId = body?.PlanId ?? planId; + stripeToken = body?.StripeToken ?? stripeToken; + last4 = body?.Last4 ?? last4; + couponId = body?.CouponId ?? couponId; + + if (String.IsNullOrEmpty(planId)) + return Ok(ChangePlanResult.FailWithMessage("Invalid PlanId.")); // required but nullable for binding + if (String.IsNullOrEmpty(id) || !CanAccessOrganization(id)) return NotFound(); @@ -554,11 +564,15 @@ public async Task> ChangePlanAsync(string id, str if (subscription is not null) { update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Price = planId }); + if (!String.IsNullOrWhiteSpace(couponId)) + update.Discounts = [new SubscriptionDiscountOptions { Coupon = couponId }]; await subscriptionService.UpdateAsync(subscription.Id, update); } else { create.Items.Add(new SubscriptionItemOptions { Price = planId }); + if (!String.IsNullOrWhiteSpace(couponId)) + create.Discounts = [new SubscriptionDiscountOptions { Coupon = couponId }]; await subscriptionService.CreateAsync(create); } @@ -573,9 +587,9 @@ public async Task> ChangePlanAsync(string id, str await _repository.SaveAsync(organization, o => o.Cache().Originals()); await _messagePublisher.PublishAsync(new PlanChanged { OrganizationId = organization.Id }); } - catch (Exception ex) + catch (StripeException ex) { - _logger.LogCritical(ex, "An error occurred while trying to update your billing plan: {Message}", ex.Message); + _logger.LogCritical(ex, "An error occurred while trying to update your billing plan: {ErrorMessage}", ex.Message); return Ok(ChangePlanResult.FailWithMessage(ex.Message)); } diff --git a/src/Exceptionless.Web/Mapping/InvoiceMapper.cs b/src/Exceptionless.Web/Mapping/InvoiceMapper.cs index 01f84c42f7..c3c653d64d 100644 --- a/src/Exceptionless.Web/Mapping/InvoiceMapper.cs +++ b/src/Exceptionless.Web/Mapping/InvoiceMapper.cs @@ -13,7 +13,7 @@ public InvoiceGridModel MapToInvoiceGridModel(Stripe.Invoice source) { Id = source.Id[3..], // Strip "in_" prefix Date = source.Created, - Paid = String.Equals(source.Status, "paid", StringComparison.Ordinal) + Paid = String.Equals(source.Status, "paid", StringComparison.OrdinalIgnoreCase) }; public List MapToInvoiceGridModels(IEnumerable source) diff --git a/src/Exceptionless.Web/Models/ChangePlanRequest.cs b/src/Exceptionless.Web/Models/ChangePlanRequest.cs new file mode 100644 index 0000000000..ec170cb420 --- /dev/null +++ b/src/Exceptionless.Web/Models/ChangePlanRequest.cs @@ -0,0 +1,21 @@ +namespace Exceptionless.Web.Models; + +/// +/// Request body for the change-plan endpoint. +/// Accepted as JSON body from the Svelte client; query params remain supported for the legacy Angular client. +/// Property names use snake_case to match the Exceptionless API JSON convention. +/// +public class ChangePlanRequest +{ + /// The plan ID to switch to. + public string? PlanId { get; set; } + + /// Stripe PaymentMethod ID (pm_...) from the modern Svelte UI, or legacy tok_... token. + public string? StripeToken { get; set; } + + /// Last 4 digits of the card for display purposes. + public string? Last4 { get; set; } + + /// Optional coupon/promotion code to apply. + public string? CouponId { get; set; } +} From fcbf7d2af38aa847622c6aa1d5c74f75440bee21 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 07:30:00 -0500 Subject: [PATCH 28/43] Clean up change-plan endpoint: remove dual binding, enforce ProblemDetails, regenerate client types - Remove [FromQuery]/[FromBody] dual binding from ChangePlanAsync; endpoint now accepts only a JSON body - Add [Required] to PlanId on ChangePlanRequest; [ApiController] now returns 400 ProblemDetails automatically for missing plan_id - Remove stale XML comments about Angular/Svelte dual-binding compat - Fix logger message template: {ErrorMessage} -> {Message} - Update Angular org service to POST JSON body instead of query params - Regenerate ChangePlanRequest from OpenAPI spec (plan_id: string required); remove manual ChangePlanParams - Handle ProblemDetails in change-plan-dialog catch block - Fix catch (err) -> catch (error: unknown) in stripe.svelte.ts - Update organizations.http test to use JSON body --- .../organization/organization-service.js | 8 ++- .../components/change-plan-dialog.svelte | 6 +- .../src/lib/features/billing/index.ts | 2 +- .../src/lib/features/billing/models.ts | 17 +---- .../src/lib/features/billing/stripe.svelte.ts | 4 +- .../lib/features/organizations/api.svelte.ts | 11 ++- .../ClientApp/src/lib/generated/api.ts | 13 +++- .../ClientApp/src/lib/generated/schemas.ts | 25 ++++--- .../Controllers/OrganizationController.cs | 71 +++++++------------ .../Models/ChangePlanRequest.cs | 14 ++-- tests/http/organizations.http | 4 +- 11 files changed, 79 insertions(+), 96 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js b/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js index cb5f7561c0..ab0ba22699 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js +++ b/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js @@ -30,7 +30,13 @@ } function changePlan(id, options) { - return Restangular.one("organizations", id).customPOST(null, "change-plan", options); + const body = { + plan_id: options.planId, + stripe_token: options.stripeToken, + last4: options.last4, + coupon_id: options.couponId + }; + return Restangular.one("organizations", id).customPOST(body, "change-plan"); } function getOldestCreationDate(organizations) { 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 index ec51bb6c85..80ce4b425b 100644 --- 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 @@ -16,7 +16,7 @@ import { FREE_PLAN_ID, isStripeEnabled, StripeProvider } from '$features/billing'; import { type ChangePlanFormData, ChangePlanSchema } from '$features/billing/schemas'; import { changePlanMutation, getPlansQuery } from '$features/organizations/api.svelte'; - import { getFormErrorMessages } from '$features/shared/validation'; + import { getFormErrorMessages, isProblemDetails, problemDetailsToFormErrors } from '$features/shared/validation'; import { Exceptionless } from '@exceptionless/browser'; import Check from '@lucide/svelte/icons/check'; import CreditCard from '@lucide/svelte/icons/credit-card'; @@ -239,6 +239,10 @@ return null; } catch (error: unknown) { + if (isProblemDetails(error)) { + return problemDetailsToFormErrors(error); + } + await Exceptionless.createException(error instanceof Error ? error : new Error(String(error))) .setProperty('organizationId', organization.id) .setProperty('selectedPlanId', value.selectedPlanId) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts index 316c75b899..2f62715317 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts @@ -13,7 +13,7 @@ export { default as UpgradeRequiredDialog } from './components/upgrade-required- // Constants export { FREE_PLAN_ID } from './constants'; // Models -export type { BillingPlan, CardMode, ChangePlanFormState, ChangePlanParams, ChangePlanResult } from './models'; +export type { BillingPlan, CardMode, ChangePlanFormState, ChangePlanRequest, 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 index 0f65481538..de988551bf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts @@ -2,7 +2,7 @@ * Billing models - re-exports from generated types plus billing-specific types. */ -export type { BillingPlan, ChangePlanResult } from '$lib/generated/api'; +export type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/generated/api'; /** * Card mode for the payment form. @@ -17,18 +17,3 @@ export interface ChangePlanFormState { couponId: string; selectedPlanId: null | string; } - -/** - * Parameters for the change-plan API call. - * Keys match the snake_case convention of the Exceptionless API. - */ -export interface ChangePlanParams { - /** Optional coupon code to apply */ - coupon_id?: string; - /** Last 4 digits of the card (for display purposes) */ - last4?: string; - /** The plan ID to change to */ - plan_id: string; - /** Stripe PaymentMethod ID or legacy token */ - stripe_token?: string; -} 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 index b3cf3a461f..542b3756c5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts @@ -58,11 +58,11 @@ export async function loadStripeOnce(): Promise { try { _stripeInstance = await _stripePromise; return _stripeInstance; - } catch (err) { + } catch (error: unknown) { // Reset so the next call can retry instead of re-awaiting the rejected promise _stripePromise = null; _stripeInstance = null; - throw err; + throw error; } } 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 f310b3122c..47bbaf51ef 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,9 +1,8 @@ import type { WebSocketMessageValue } from '$features/websockets/models'; -import type { BillingPlan, ChangePlanResult } from '$lib/generated/api'; +import type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/generated/api'; import type { QueryClient } from '@tanstack/svelte-query'; import { accessToken } from '$features/auth/index.svelte'; -import { type ChangePlanParams } from '$features/billing/models'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; @@ -45,7 +44,7 @@ export interface AddOrganizationUserRequest { }; } -export interface ChangePlanRequest { +export interface ChangePlanMutationRequest { route: { organizationId: string; }; @@ -170,12 +169,12 @@ export function addOrganizationUser(request: AddOrganizationUserRequest) { /** * Mutation to change an organization's billing plan. */ -export function changePlanMutation(request: ChangePlanRequest) { +export function changePlanMutation(request: ChangePlanMutationRequest) { const queryClient = useQueryClient(); - return createMutation(() => ({ + return createMutation(() => ({ enabled: () => !!accessToken.current && !!request.route.organizationId, - mutationFn: async (params: ChangePlanParams) => { + mutationFn: async (params: ChangePlanRequest) => { const client = useFetchClient(); const response = await client.postJSON(`organizations/${request.route.organizationId}/change-plan`, params); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 856890de12..965391b22a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -38,6 +38,13 @@ export interface ChangePasswordModel { password: string; } +export interface ChangePlanRequest { + plan_id: string; + stripe_token?: null | string; + last4?: null | string; + coupon_id?: null | string; +} + export interface ChangePlanResult { success: boolean; message?: null | string; @@ -125,9 +132,9 @@ export interface NewProject { export interface NewToken { /** @pattern ^[a-fA-F0-9]{24}$ */ - organization_id?: null | string; + organization_id: string; /** @pattern ^[a-fA-F0-9]{24}$ */ - project_id?: null | string; + project_id: string; /** @pattern ^[a-fA-F0-9]{24}$ */ default_project_id?: null | string; scopes: string[]; @@ -196,7 +203,7 @@ export interface PersistentEvent { */ created_utc: string; /** Used to store primitive data type custom data values for searching the event. */ - idx: Record; + idx?: null | Record; /** The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types. */ type?: null | string; /** The event source (ie. machine name, log name, feature name). */ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 66e65ceef1..3f66a51307 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -61,6 +61,17 @@ export type ChangePasswordModelFormData = Infer< typeof ChangePasswordModelSchema >; +export const ChangePlanRequestSchema = object({ + plan_id: string().min(1, "Plan id is required"), + stripe_token: string() + .min(1, "Stripe token is required") + .nullable() + .optional(), + last4: string().min(1, '"last4" is required').nullable().optional(), + coupon_id: string().min(1, "Coupon id is required").nullable().optional(), +}); +export type ChangePlanRequestFormData = Infer; + export const ChangePlanResultSchema = object({ success: boolean(), message: string().min(1, "Message is required").nullable().optional(), @@ -80,8 +91,8 @@ export const CountResultSchema = object({ aggregations: record( string(), lazy(() => IAggregateSchema), - ).optional(), - data: record(string(), unknown()).nullable().optional(), + ), + data: record(string(), unknown()).nullable(), }); export type CountResultFormData = Infer; @@ -164,14 +175,10 @@ export type NewProjectFormData = Infer; export const NewTokenSchema = object({ organization_id: string() .length(24, "Organization id must be exactly 24 characters") - .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format") - .nullable() - .optional(), + .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format"), project_id: string() .length(24, "Project id must be exactly 24 characters") - .regex(/^[a-fA-F0-9]{24}$/, "Project id has invalid format") - .nullable() - .optional(), + .regex(/^[a-fA-F0-9]{24}$/, "Project id has invalid format"), default_project_id: string() .length(24, "Default project id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Default project id has invalid format") @@ -243,7 +250,7 @@ export const PersistentEventSchema = object({ .regex(/^[a-fA-F0-9]{24}$/, "Stack id has invalid format"), is_first_occurrence: boolean(), created_utc: iso.datetime(), - idx: record(string(), unknown()), + idx: record(string(), unknown()).nullable().optional(), type: string() .min(1, "Type is required") .max(100, "Type must be at most 100 characters") diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 6c8df12bab..f651870676 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -401,25 +401,12 @@ public async Task>> GetPlansAsync( /// Upgrades or downgrades the organizations plan. /// /// The identifier of the organization. - /// The identifier of the plan. - /// The token returned from the stripe service. - /// The last four numbers of the card. - /// The coupon id. - /// Optional JSON body (Svelte client); query params take precedence when body is null. + /// The plan change request. /// The organization was not found. [HttpPost] [Route("{id:objectid}/change-plan")] - public async Task> ChangePlanAsync(string id, [FromQuery] string? planId = null, [FromQuery] string? stripeToken = null, [FromQuery] string? last4 = null, [FromQuery] string? couponId = null, [FromBody] ChangePlanRequest? body = null) + public async Task> ChangePlanAsync(string id, ChangePlanRequest body) { - // Accept params from either JSON body (new Svelte client) or query string (legacy Angular) - planId = body?.PlanId ?? planId; - stripeToken = body?.StripeToken ?? stripeToken; - last4 = body?.Last4 ?? last4; - couponId = body?.CouponId ?? couponId; - - if (String.IsNullOrEmpty(planId)) - return Ok(ChangePlanResult.FailWithMessage("Invalid PlanId.")); // required but nullable for binding - if (String.IsNullOrEmpty(id) || !CanAccessOrganization(id)) return NotFound(); @@ -433,7 +420,7 @@ public async Task> ChangePlanAsync(string id, [Fr if (organization is null) return Ok(ChangePlanResult.FailWithMessage("Invalid OrganizationId.")); - var plan = _billingManager.GetBillingPlan(planId); + var plan = _billingManager.GetBillingPlan(body.PlanId); if (plan is null) return Ok(ChangePlanResult.FailWithMessage("Invalid PlanId.")); @@ -454,8 +441,7 @@ public async Task> ChangePlanAsync(string id, [Fr var paymentMethodService = new PaymentMethodService(client); // Detect if stripeToken is a legacy token (tok_) or modern PaymentMethod (pm_) - // This maintains backwards compatibility with the legacy Angular UI - bool isPaymentMethod = stripeToken?.StartsWith("pm_", StringComparison.Ordinal) == true; + bool isPaymentMethod = body.StripeToken?.StartsWith("pm_", StringComparison.Ordinal) == true; try { @@ -474,7 +460,7 @@ public async Task> ChangePlanAsync(string id, [Fr } else if (String.IsNullOrEmpty(organization.StripeCustomerId)) { - if (String.IsNullOrEmpty(stripeToken)) + if (String.IsNullOrEmpty(body.StripeToken)) return Ok(ChangePlanResult.FailWithMessage("Billing information was not set.")); organization.SubscribeDate = _timeProvider.GetUtcNow().UtcDateTime; @@ -485,20 +471,17 @@ public async Task> ChangePlanAsync(string id, [Fr Email = CurrentUser.EmailAddress }; - // Handle both legacy tokens and modern PaymentMethod IDs for backwards compatibility if (isPaymentMethod) { - // Modern Svelte UI: Uses PaymentMethod from createPaymentMethod() - createCustomer.PaymentMethod = stripeToken; + createCustomer.PaymentMethod = body.StripeToken; createCustomer.InvoiceSettings = new CustomerInvoiceSettingsOptions { - DefaultPaymentMethod = stripeToken + DefaultPaymentMethod = body.StripeToken }; } else { - // Legacy Angular UI: Uses token from createToken() - createCustomer.Source = stripeToken; + createCustomer.Source = body.StripeToken; } var customer = await customerService.CreateAsync(createCustomer); @@ -507,23 +490,21 @@ public async Task> ChangePlanAsync(string id, [Fr var subscriptionOptions = new SubscriptionCreateOptions { Customer = customer.Id, - Items = [new SubscriptionItemOptions { Price = planId }] + Items = [new SubscriptionItemOptions { Price = body.PlanId }] }; if (isPaymentMethod) - subscriptionOptions.DefaultPaymentMethod = stripeToken; + subscriptionOptions.DefaultPaymentMethod = body.StripeToken; - // In Stripe.net 50.x, Coupon was removed from SubscriptionCreateOptions - // Use Discounts collection with SubscriptionDiscountOptions instead - if (!String.IsNullOrWhiteSpace(couponId)) - subscriptionOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = couponId }]; + if (!String.IsNullOrWhiteSpace(body.CouponId)) + subscriptionOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = body.CouponId }]; await subscriptionService.CreateAsync(subscriptionOptions); organization.BillingStatus = BillingStatus.Active; organization.RemoveSuspension(); organization.StripeCustomerId = customer.Id; - organization.CardLast4 = last4; + organization.CardLast4 = body.Last4; } else { @@ -535,24 +516,22 @@ public async Task> ChangePlanAsync(string id, [Fr if (!Request.IsGlobalAdmin()) customerUpdateOptions.Email = CurrentUser.EmailAddress; - if (!String.IsNullOrEmpty(stripeToken)) + if (!String.IsNullOrEmpty(body.StripeToken)) { if (isPaymentMethod) { - // Modern Svelte UI: Attach PaymentMethod and set as default - await paymentMethodService.AttachAsync(stripeToken, new PaymentMethodAttachOptions + await paymentMethodService.AttachAsync(body.StripeToken, new PaymentMethodAttachOptions { Customer = organization.StripeCustomerId }); customerUpdateOptions.InvoiceSettings = new CustomerInvoiceSettingsOptions { - DefaultPaymentMethod = stripeToken + DefaultPaymentMethod = body.StripeToken }; } else { - // Legacy Angular UI: Use Source for token - customerUpdateOptions.Source = stripeToken; + customerUpdateOptions.Source = body.StripeToken; } cardUpdated = true; } @@ -563,21 +542,21 @@ public async Task> ChangePlanAsync(string id, [Fr var subscription = subscriptionList.FirstOrDefault(s => !s.CanceledAt.HasValue); if (subscription is not null) { - update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Price = planId }); - if (!String.IsNullOrWhiteSpace(couponId)) - update.Discounts = [new SubscriptionDiscountOptions { Coupon = couponId }]; + update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Price = body.PlanId }); + if (!String.IsNullOrWhiteSpace(body.CouponId)) + update.Discounts = [new SubscriptionDiscountOptions { Coupon = body.CouponId }]; await subscriptionService.UpdateAsync(subscription.Id, update); } else { - create.Items.Add(new SubscriptionItemOptions { Price = planId }); - if (!String.IsNullOrWhiteSpace(couponId)) - create.Discounts = [new SubscriptionDiscountOptions { Coupon = couponId }]; + create.Items.Add(new SubscriptionItemOptions { Price = body.PlanId }); + if (!String.IsNullOrWhiteSpace(body.CouponId)) + create.Discounts = [new SubscriptionDiscountOptions { Coupon = body.CouponId }]; await subscriptionService.CreateAsync(create); } if (cardUpdated) - organization.CardLast4 = last4; + organization.CardLast4 = body.Last4; organization.BillingStatus = BillingStatus.Active; organization.RemoveSuspension(); @@ -589,7 +568,7 @@ public async Task> ChangePlanAsync(string id, [Fr } catch (StripeException ex) { - _logger.LogCritical(ex, "An error occurred while trying to update your billing plan: {ErrorMessage}", ex.Message); + _logger.LogCritical(ex, "An error occurred while trying to update your billing plan: {Message}", ex.Message); return Ok(ChangePlanResult.FailWithMessage(ex.Message)); } diff --git a/src/Exceptionless.Web/Models/ChangePlanRequest.cs b/src/Exceptionless.Web/Models/ChangePlanRequest.cs index ec170cb420..6c5f92e086 100644 --- a/src/Exceptionless.Web/Models/ChangePlanRequest.cs +++ b/src/Exceptionless.Web/Models/ChangePlanRequest.cs @@ -1,21 +1,15 @@ +using System.ComponentModel.DataAnnotations; + namespace Exceptionless.Web.Models; -/// -/// Request body for the change-plan endpoint. -/// Accepted as JSON body from the Svelte client; query params remain supported for the legacy Angular client. -/// Property names use snake_case to match the Exceptionless API JSON convention. -/// public class ChangePlanRequest { - /// The plan ID to switch to. - public string? PlanId { get; set; } + [Required] + public required string PlanId { get; set; } - /// Stripe PaymentMethod ID (pm_...) from the modern Svelte UI, or legacy tok_... token. public string? StripeToken { get; set; } - /// Last 4 digits of the card for display purposes. public string? Last4 { get; set; } - /// Optional coupon/promotion code to apply. public string? CouponId { get; set; } } diff --git a/tests/http/organizations.http b/tests/http/organizations.http index ff060f944f..7eb620f0ac 100644 --- a/tests/http/organizations.http +++ b/tests/http/organizations.http @@ -45,10 +45,12 @@ Content-Type: application/json @organizationId = {{newOrganization.response.body.$.id}} ### Change Plan -POST {{apiUrl}}/organizations/{{organizationId}}/change-plan?planId=EX_FREE +POST {{apiUrl}}/organizations/{{organizationId}}/change-plan Authorization: Bearer {{token}} Content-Type: application/json +{ "plan_id": "EX_FREE" } + ### Add User POST {{apiUrl}}/organizations/{{organizationId}}/users/test2@localhost Authorization: Bearer {{token}} From 20a528167cb2442e5091e006decdf7bfde950d28 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 07:30:00 -0500 Subject: [PATCH 29/43] fix: use instanceof ProblemDetails instead of isProblemDetails type guard in change-plan dialog --- .../features/billing/components/change-plan-dialog.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 80ce4b425b..70a5520b99 100644 --- 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 @@ -16,8 +16,9 @@ import { FREE_PLAN_ID, isStripeEnabled, StripeProvider } from '$features/billing'; import { type ChangePlanFormData, ChangePlanSchema } from '$features/billing/schemas'; import { changePlanMutation, getPlansQuery } from '$features/organizations/api.svelte'; - import { getFormErrorMessages, isProblemDetails, problemDetailsToFormErrors } from '$features/shared/validation'; + import { getFormErrorMessages, problemDetailsToFormErrors } from '$features/shared/validation'; import { Exceptionless } from '@exceptionless/browser'; + import { ProblemDetails } from '@exceptionless/fetchclient'; import Check from '@lucide/svelte/icons/check'; import CreditCard from '@lucide/svelte/icons/credit-card'; import Plus from '@lucide/svelte/icons/plus'; @@ -239,7 +240,7 @@ return null; } catch (error: unknown) { - if (isProblemDetails(error)) { + if (error instanceof ProblemDetails) { return problemDetailsToFormErrors(error); } From a1a2a4fe9cce3c96e0d48f37b16c5e9857cfafa1 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 07:35:00 -0500 Subject: [PATCH 30/43] Use proper HTTP status codes for ChangePlan pre-validation errors Billing disabled and invalid org now return 404 instead of 200+fail. Invalid plan ID returns 422 ValidationProblem. Business logic failures (downgrade constraints, missing billing info, Stripe errors) remain as 200+ChangePlanResult for backwards compatibility with both frontends. --- .../Controllers/OrganizationController.cs | 91 ++++++++++----- .../Controllers/Data/openapi.json | 106 +++++++++++++++++- tests/http/organizations.http | 8 +- 3 files changed, 169 insertions(+), 36 deletions(-) diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index f651870676..6c579a2b0e 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -20,6 +20,7 @@ using Foundatio.Repositories.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Stripe; using DataDictionary = Exceptionless.Core.Models.DataDictionary; using Invoice = Exceptionless.Web.Models.Invoice; @@ -399,14 +400,36 @@ public async Task>> GetPlansAsync( /// /// /// Upgrades or downgrades the organizations plan. + /// Accepts parameters via JSON body (preferred) or query string (legacy). /// /// The identifier of the organization. - /// The plan change request. + /// The plan change request (JSON body). + /// Legacy query parameter: the plan identifier. + /// Legacy query parameter: the Stripe token. + /// Legacy query parameter: last four digits of the card. + /// Legacy query parameter: the coupon identifier. /// The organization was not found. [HttpPost] [Route("{id:objectid}/change-plan")] - public async Task> ChangePlanAsync(string id, ChangePlanRequest body) + public async Task> ChangePlanAsync( + string id, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ChangePlanRequest? model = null, + [FromQuery] string? planId = null, + [FromQuery] string? stripeToken = null, + [FromQuery] string? last4 = null, + [FromQuery] string? couponId = null) { + // Support legacy clients that send query parameters instead of a JSON body + model ??= new ChangePlanRequest { PlanId = planId ?? String.Empty }; + if (String.IsNullOrEmpty(model.PlanId) && !String.IsNullOrEmpty(planId)) + model.PlanId = planId; + if (String.IsNullOrEmpty(model.StripeToken) && !String.IsNullOrEmpty(stripeToken)) + model.StripeToken = stripeToken; + if (String.IsNullOrEmpty(model.Last4) && !String.IsNullOrEmpty(last4)) + model.Last4 = last4; + if (String.IsNullOrEmpty(model.CouponId) && !String.IsNullOrEmpty(couponId)) + model.CouponId = couponId; + if (String.IsNullOrEmpty(id) || !CanAccessOrganization(id)) return NotFound(); @@ -414,15 +437,18 @@ public async Task> ChangePlanAsync(string id, Cha .Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext)); if (!_options.StripeOptions.EnableBilling) - return Ok(ChangePlanResult.FailWithMessage("Plans cannot be changed while billing is disabled.")); + return NotFound(); var organization = await GetModelAsync(id, false); if (organization is null) - return Ok(ChangePlanResult.FailWithMessage("Invalid OrganizationId.")); + return NotFound(); - var plan = _billingManager.GetBillingPlan(body.PlanId); + var plan = _billingManager.GetBillingPlan(model.PlanId); if (plan is null) - return Ok(ChangePlanResult.FailWithMessage("Invalid PlanId.")); + { + ModelState.AddModelError(nameof(model.PlanId), "Invalid PlanId."); + return ValidationProblem(ModelState); + } if (String.Equals(organization.PlanId, plan.Id) && String.Equals(_plans.FreePlan.Id, plan.Id)) return Ok(ChangePlanResult.SuccessWithMessage("Your plan was not changed as you were already on the free plan.")); @@ -441,7 +467,7 @@ public async Task> ChangePlanAsync(string id, Cha var paymentMethodService = new PaymentMethodService(client); // Detect if stripeToken is a legacy token (tok_) or modern PaymentMethod (pm_) - bool isPaymentMethod = body.StripeToken?.StartsWith("pm_", StringComparison.Ordinal) == true; + bool isPaymentMethod = model.StripeToken?.StartsWith("pm_", StringComparison.Ordinal) == true; try { @@ -460,7 +486,7 @@ public async Task> ChangePlanAsync(string id, Cha } else if (String.IsNullOrEmpty(organization.StripeCustomerId)) { - if (String.IsNullOrEmpty(body.StripeToken)) + if (String.IsNullOrEmpty(model.StripeToken)) return Ok(ChangePlanResult.FailWithMessage("Billing information was not set.")); organization.SubscribeDate = _timeProvider.GetUtcNow().UtcDateTime; @@ -473,15 +499,15 @@ public async Task> ChangePlanAsync(string id, Cha if (isPaymentMethod) { - createCustomer.PaymentMethod = body.StripeToken; + createCustomer.PaymentMethod = model.StripeToken; createCustomer.InvoiceSettings = new CustomerInvoiceSettingsOptions { - DefaultPaymentMethod = body.StripeToken + DefaultPaymentMethod = model.StripeToken }; } else { - createCustomer.Source = body.StripeToken; + createCustomer.Source = model.StripeToken; } var customer = await customerService.CreateAsync(createCustomer); @@ -490,21 +516,21 @@ public async Task> ChangePlanAsync(string id, Cha var subscriptionOptions = new SubscriptionCreateOptions { Customer = customer.Id, - Items = [new SubscriptionItemOptions { Price = body.PlanId }] + Items = [new SubscriptionItemOptions { Price = model.PlanId }] }; if (isPaymentMethod) - subscriptionOptions.DefaultPaymentMethod = body.StripeToken; + subscriptionOptions.DefaultPaymentMethod = model.StripeToken; - if (!String.IsNullOrWhiteSpace(body.CouponId)) - subscriptionOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = body.CouponId }]; + if (!String.IsNullOrWhiteSpace(model.CouponId)) + subscriptionOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; await subscriptionService.CreateAsync(subscriptionOptions); organization.BillingStatus = BillingStatus.Active; organization.RemoveSuspension(); organization.StripeCustomerId = customer.Id; - organization.CardLast4 = body.Last4; + organization.CardLast4 = model.Last4; } else { @@ -516,22 +542,22 @@ public async Task> ChangePlanAsync(string id, Cha if (!Request.IsGlobalAdmin()) customerUpdateOptions.Email = CurrentUser.EmailAddress; - if (!String.IsNullOrEmpty(body.StripeToken)) + if (!String.IsNullOrEmpty(model.StripeToken)) { if (isPaymentMethod) { - await paymentMethodService.AttachAsync(body.StripeToken, new PaymentMethodAttachOptions + await paymentMethodService.AttachAsync(model.StripeToken, new PaymentMethodAttachOptions { Customer = organization.StripeCustomerId }); customerUpdateOptions.InvoiceSettings = new CustomerInvoiceSettingsOptions { - DefaultPaymentMethod = body.StripeToken + DefaultPaymentMethod = model.StripeToken }; } else { - customerUpdateOptions.Source = body.StripeToken; + customerUpdateOptions.Source = model.StripeToken; } cardUpdated = true; } @@ -540,23 +566,27 @@ public async Task> ChangePlanAsync(string id, Cha var subscriptionList = await subscriptionService.ListAsync(new SubscriptionListOptions { Customer = organization.StripeCustomerId }); var subscription = subscriptionList.FirstOrDefault(s => !s.CanceledAt.HasValue); - if (subscription is not null) + if (subscription is not null && subscription.Items.Data.Count > 0) { - update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Price = body.PlanId }); - if (!String.IsNullOrWhiteSpace(body.CouponId)) - update.Discounts = [new SubscriptionDiscountOptions { Coupon = body.CouponId }]; + update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Price = model.PlanId }); + if (!String.IsNullOrWhiteSpace(model.CouponId)) + update.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; await subscriptionService.UpdateAsync(subscription.Id, update); } + else if (subscription is not null) + { + _logger.LogWarning("Subscription {SubscriptionId} has no items for organization {OrganizationId}", subscription.Id, id); + } else { - create.Items.Add(new SubscriptionItemOptions { Price = body.PlanId }); - if (!String.IsNullOrWhiteSpace(body.CouponId)) - create.Discounts = [new SubscriptionDiscountOptions { Coupon = body.CouponId }]; + create.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); + if (!String.IsNullOrWhiteSpace(model.CouponId)) + create.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; await subscriptionService.CreateAsync(create); } if (cardUpdated) - organization.CardLast4 = body.Last4; + organization.CardLast4 = model.Last4; organization.BillingStatus = BillingStatus.Active; organization.RemoveSuspension(); @@ -571,6 +601,11 @@ public async Task> ChangePlanAsync(string id, Cha _logger.LogCritical(ex, "An error occurred while trying to update your billing plan: {Message}", ex.Message); return Ok(ChangePlanResult.FailWithMessage(ex.Message)); } + catch (Exception ex) + { + _logger.LogCritical(ex, "An unexpected error occurred while trying to update your billing plan: {Message}", ex.Message); + return Ok(ChangePlanResult.FailWithMessage("An error occurred while changing plans. Please try again.")); + } return Ok(new ChangePlanResult { Success = true }); } diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 086c0a5990..258f1635d1 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -16,7 +16,7 @@ }, "servers": [ { - "url": "http://localhost/" + "url": "http://localhost:5200/" } ], "paths": { @@ -4317,7 +4317,7 @@ "Organization" ], "summary": "Change plan", - "description": "Upgrades or downgrades the organizations plan.", + "description": "Upgrades or downgrades the organizations plan.\nAccepts parameters via JSON body (preferred) or query string (legacy).", "parameters": [ { "name": "id", @@ -4332,7 +4332,7 @@ { "name": "planId", "in": "query", - "description": "The identifier of the plan.", + "description": "Legacy query parameter: the plan identifier.", "schema": { "type": "string" } @@ -4340,7 +4340,7 @@ { "name": "stripeToken", "in": "query", - "description": "The token returned from the stripe service.", + "description": "Legacy query parameter: the Stripe token.", "schema": { "type": "string" } @@ -4348,7 +4348,7 @@ { "name": "last4", "in": "query", - "description": "The last four numbers of the card.", + "description": "Legacy query parameter: last four digits of the card.", "schema": { "type": "string" } @@ -4356,12 +4356,77 @@ { "name": "couponId", "in": "query", - "description": "The coupon id.", + "description": "Legacy query parameter: the coupon identifier.", "schema": { "type": "string" } } ], + "requestBody": { + "description": "The plan change request (JSON body).", + "content": { + "text/plain": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + }, + "application/octet-stream": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + }, + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + } + } + }, "responses": { "200": { "description": "OK", @@ -7060,6 +7125,35 @@ } } }, + "ChangePlanRequest": { + "required": [ + "plan_id" + ], + "type": "object", + "properties": { + "plan_id": { + "type": "string" + }, + "stripe_token": { + "type": [ + "null", + "string" + ] + }, + "last4": { + "type": [ + "null", + "string" + ] + }, + "coupon_id": { + "type": [ + "null", + "string" + ] + } + } + }, "ChangePlanResult": { "required": [ "success" diff --git a/tests/http/organizations.http b/tests/http/organizations.http index 7eb620f0ac..46697f3c5f 100644 --- a/tests/http/organizations.http +++ b/tests/http/organizations.http @@ -44,13 +44,17 @@ Content-Type: application/json ### @organizationId = {{newOrganization.response.body.$.id}} -### Change Plan +### Change Plan (JSON body - preferred) POST {{apiUrl}}/organizations/{{organizationId}}/change-plan Authorization: Bearer {{token}} Content-Type: application/json { "plan_id": "EX_FREE" } +### Change Plan (query params - legacy) +POST {{apiUrl}}/organizations/{{organizationId}}/change-plan?planId=EX_FREE +Authorization: Bearer {{token}} + ### Add User POST {{apiUrl}}/organizations/{{organizationId}}/users/test2@localhost Authorization: Bearer {{token}} @@ -89,4 +93,4 @@ Content-Type: application/json ### Unsuspend DELETE {{apiUrl}}/organizations/{{organizationId}}/suspend Authorization: Bearer {{token}} -Content-Type: application/json \ No newline at end of file +Content-Type: application/json From ba14c41b6a9ed476c702e63201f605f86a1d9842 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 21:50:37 -0500 Subject: [PATCH 31/43] Refactor Change Plan dialog UX and optimize server-side Stripe operations - Default plan upgrades to yearly billing to promote savings. - Improve coupon error handling by auto-focusing the input and showing inline errors on failure. - Add toast notifications for plan change success and failure states. - Parallelize Stripe customer update and subscription listing tasks in the backend. - Transition dialog visibility to use an `onclose` callback instead of `bind:open` for more predictable state management. --- .../change-plan-dialog-harness.svelte | 8 +- .../components/change-plan-dialog.svelte | 75 +++++++++++++------ .../lib/features/organizations/api.svelte.ts | 3 +- .../[organizationId]/billing/+page.svelte | 12 ++- .../Controllers/OrganizationController.cs | 12 ++- 5 files changed, 82 insertions(+), 28 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte index e5d9c1635e..d3d42d9fa0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte @@ -43,10 +43,16 @@ }); let open = $state(true); + + function handleClose() { + open = false; + } - + {#if open} + + {/if} 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 index 70a5520b99..4c96224965 100644 --- 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 @@ -31,11 +31,11 @@ initialCouponCode?: string; initialCouponOpen?: boolean; initialFormError?: string; - open: boolean; + onclose: () => void; organization: ViewOrganization; } - let { initialCouponCode, initialCouponOpen, initialFormError, open = $bindable(), organization }: Props = $props(); + let { initialCouponCode, initialCouponOpen, initialFormError, onclose, organization }: Props = $props(); const plansQuery = getPlansQuery({ route: { @@ -141,6 +141,8 @@ let couponOpen = $state(false); let couponApplied = $state(null); let couponInput = $state(''); + let couponError = $state(null); + let couponInputEl = $state(null); const hasExistingCard = $derived(!!organization.card_last4); @@ -232,16 +234,33 @@ }); if (!result.success) { - return { form: result.message ?? 'Failed to change plan' }; + const message = result.message ?? 'Failed to change plan'; + const isCouponError = /coupon/i.test(message); + + if (isCouponError && value.couponId) { + // Clear applied coupon, reopen input with the bad code, focus it + couponApplied = null; + couponInput = value.couponId; + couponOpen = true; + couponError = message; + // Focus the input after DOM updates + requestAnimationFrame(() => couponInputEl?.focus()); + } + + toast.error(message); + return { form: message }; } toast.success(result.message ?? 'Your billing plan has been successfully changed.'); - open = false; + onclose(); return null; } catch (error: unknown) { if (error instanceof ProblemDetails) { - return problemDetailsToFormErrors(error); + const formErrors = problemDetailsToFormErrors(error); + const errorMessage = formErrors?.form ?? error.title ?? 'An unexpected error occurred'; + toast.error(errorMessage); + return formErrors; } await Exceptionless.createException(error instanceof Error ? error : new Error(String(error))) @@ -250,7 +269,9 @@ .addTags('billing', 'change-plan') .submit(); - return { form: error instanceof Error ? error.message : 'An unexpected error occurred' }; + const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred'; + toast.error(errorMessage); + return { form: errorMessage }; } } } @@ -268,7 +289,7 @@ }); $effect(() => { - if (open && plansQuery.data) { + if (plansQuery.data) { untrack(() => { const nextTierIndex = currentTierIndex + 1; const upsellTier = nextTierIndex < tiers.length ? tiers[nextTierIndex] : null; @@ -280,11 +301,13 @@ } else { selectedTierId = currentTierId === FREE_PLAN_ID ? '' : currentTierId; } - interval = currentInterval; + // Always default to yearly to promote savings (especially for free→paid upgrades) + interval = isFreeCurrent ? 'year' : currentInterval; paymentExpanded = false; couponOpen = initialCouponOpen ?? false; couponApplied = initialCouponCode ?? null; couponInput = ''; + couponError = null; form.reset(); }); } @@ -308,11 +331,13 @@ function onCouponOpen() { couponOpen = true; + couponError = null; } function onCouponCancel() { couponOpen = false; couponInput = ''; + couponError = null; } function onCouponApply() { @@ -324,14 +349,16 @@ couponApplied = code.toUpperCase(); couponOpen = false; couponInput = ''; + couponError = null; } function onCouponRemove() { couponApplied = null; + couponError = null; } function handleCancel() { - open = false; + onclose(); } function formatEvents(n: number): string { @@ -471,7 +498,7 @@ }); - + { if (!v) onclose(); }}> @@ -505,16 +532,6 @@ form.handleSubmit(); }} > - state.errors}> - {#snippet children(errors)} - - {/snippet} - - - {#if initialFormError} - - {/if} -
@@ -691,7 +708,7 @@ {couponApplied} - — applied at checkout + — will be applied
+ {#if couponError} + + {/if} {:else}
{#if isPaidPlan} -
+
Payment method
{#if hasExistingCard && paymentExpanded} @@ -688,7 +698,7 @@ {/if} {#if !isFreeSelected} -
+
Coupon
{#if couponOpen && !couponApplied} From 7187572b626b96a8e090bddfa30e1eb5bbcc4d7d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Apr 2026 22:34:50 -0500 Subject: [PATCH 33/43] Fixed linting --- .../billing/components/change-plan-dialog.svelte | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 index 88c2ff1683..e0d33be202 100644 --- 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 @@ -508,7 +508,12 @@ }); - { if (!v) onclose(); }}> + { + if (!v) onclose(); + }} +> @@ -555,7 +560,7 @@ Yearly {#if yearlySavingsLabel} - {yearlySavingsLabel} + {yearlySavingsLabel} {/if} @@ -584,7 +589,7 @@ {#if isCurrent} Current {:else if tier.popular && tierIdx > currentTierIndex} - Most popular + Most popular {/if}
{#if planForInterval} @@ -734,7 +739,9 @@ bind:value={couponInput} bind:ref={couponInputEl} class={couponError ? 'border-destructive' : ''} - oninput={() => { couponError = null; }} + oninput={() => { + couponError = null; + }} onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); From 2639dc2aa34a84ed2c8693fab20681ff41858fbd Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 22 Apr 2026 20:19:55 -0500 Subject: [PATCH 34/43] Add integration tests for organization billing endpoints - Test GetPlansAsync for correct plan visibility and organization-level value overlays across different user roles. - Verify that billing-related endpoints (change-plan, invoices) return NotFound when billing is disabled in the configuration. --- .vscode/launch.json | 2 +- docs/billing-stripe-integration.md | 10 +- .../stack-options-dropdown-menu.svelte | 5 +- .../Controllers/Data/openapi.json | 2 +- .../OrganizationControllerTests.cs | 178 ++++++++++++++++-- .../Controllers/TokenControllerTests.cs | 12 +- 6 files changed, 184 insertions(+), 25 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 501508a7c1..90a926b9f8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "type": "aspire", "request": "launch", "name": "Aspire", - "program": "" + "program": "${workspaceFolder}/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj" }, { "name": "Web", diff --git a/docs/billing-stripe-integration.md b/docs/billing-stripe-integration.md index d784d66a65..ee61939e76 100644 --- a/docs/billing-stripe-integration.md +++ b/docs/billing-stripe-integration.md @@ -52,15 +52,17 @@ Returns available billing plans for the organization. The current org's plan ent Changes the organization's billing plan. **Auth**: `UserPolicy` + `CanAccessOrganization(id)` -**Parameters** (query string): +**Body** (JSON, preferred): -| Param | Type | Description | +| Field | Type | Description | | --- | --- | --- | | `planId` | string | Target plan ID (e.g., `EX_MEDIUM`, `EX_LARGE_YEARLY`) | | `stripeToken` | string? | `pm_` PaymentMethod ID (Svelte) or `tok_` token (Angular) | | `last4` | string? | Last 4 digits of card (display only) | | `couponId` | string? | Stripe coupon code | +Legacy Angular clients may pass these as query string parameters instead. + **Response**: `ChangePlanResult { success, message }` **Behavior**: @@ -107,8 +109,8 @@ Main billing dialog at `src/lib/features/billing/components/change-plan-dialog.s **Props**: -- `open: boolean` — bindable dialog visibility - `organization: ViewOrganization` — current org data +- `onclose: () => void` — callback when dialog closes - `initialCouponCode?: string` — pre-fill coupon - `initialCouponOpen?: boolean` — open coupon input on mount - `initialFormError?: string` — show error message on mount @@ -163,7 +165,7 @@ bool isPaymentMethod = stripeToken?.StartsWith("pm_", StringComparison.Ordinal) ## Known Limitations -1. **Coupon not applied for existing customers changing plans** — Pre-existing limitation (not a regression). The coupon is only applied when creating a new Stripe customer. +1. ~~**Coupon not applied for existing customers changing plans**~~ — Fixed. Coupons are now applied in all paths: new customer, existing customer updating subscription, and existing customer creating a new subscription. 2. **Potential orphaned Stripe customers** — If subscription creation fails after customer creation, a retry would create a duplicate Stripe customer. Mitigated by the low likelihood of this failure path. 3. **N+1 price fetches in invoice view** — Each unique price ID in an invoice makes a separate Stripe API call. Mitigated by a per-request cache (`priceCache`). Most invoices have 1-3 distinct prices. 4. **svelte-stripe package unused** — Listed in `package.json` but bypassed due to Svelte 5 incompatibility. Only `@stripe/stripe-js` is used directly. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte index 8e41e69c3d..e8144f07e9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte @@ -74,10 +74,7 @@ } if (response.status === 426) { - handleUpgradeRequired( - { status: 426, title: 'Promote to External is a premium feature. Please upgrade your plan to enable this feature.' } as never, - stack.organization_id - ); + handleUpgradeRequired(response.problem, stack.organization_id); return; } diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 258f1635d1..cd3428146b 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -16,7 +16,7 @@ }, "servers": [ { - "url": "http://localhost:5200/" + "url": "http://localhost/" } ], "paths": { diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 9514a29281..71ac67461f 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -1,4 +1,6 @@ +using Exceptionless.Core.Billing; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; @@ -9,21 +11,21 @@ namespace Exceptionless.Tests.Controllers; -/// -/// Tests for OrganizationController including mapping coverage. -/// Validates NewOrganization -> Organization and Organization -> ViewOrganization mappings. -/// public sealed class OrganizationControllerTests : IntegrationTestsBase { private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; private readonly IUserRepository _userRepository; + private readonly BillingManager _billingManager; + private readonly BillingPlans _plans; public OrganizationControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _organizationRepository = GetService(); _projectRepository = GetService(); _userRepository = GetService(); + _billingManager = GetService(); + _plans = GetService(); } protected override async Task ResetDataAsync() @@ -51,13 +53,12 @@ public async Task PostAsync_NewOrganization_MapsToOrganizationAndCreates() .StatusCodeShouldBeCreated() ); - // Assert - Verify Organization mapping -> Organization correctly + // Assert Assert.NotNull(viewOrg); Assert.NotNull(viewOrg.Id); Assert.Equal("Test Organization", viewOrg.Name); Assert.True(viewOrg.CreatedUtc > DateTime.MinValue); - // Verify persisted entity var organization = await _organizationRepository.GetByIdAsync(viewOrg.Id); Assert.NotNull(organization); Assert.Equal("Test Organization", organization.Name); @@ -73,7 +74,7 @@ public async Task GetAsync_ExistingOrganization_MapsToViewOrganization() .StatusCodeShouldBeOk() ); - // Assert - Verify mapped Organization -> ViewOrganization correctly + // Assert Assert.NotNull(viewOrg); Assert.Equal(SampleDataService.TEST_ORG_ID, viewOrg.Id); Assert.False(String.IsNullOrEmpty(viewOrg.Name)); @@ -92,7 +93,7 @@ public async Task GetAsync_WithStatsMode_ReturnsPopulatedViewOrganization() .StatusCodeShouldBeOk() ); - // Assert - ViewOrganization should include computed properties + // Assert Assert.NotNull(viewOrg); Assert.Equal(SampleDataService.TEST_ORG_ID, viewOrg.Id); Assert.NotNull(viewOrg.Usage); @@ -109,7 +110,7 @@ public async Task GetAllAsync_ReturnsViewOrganizationCollection() .StatusCodeShouldBeOk() ); - // Assert - All organizations should be mapped to ViewOrganization + // Assert Assert.NotNull(viewOrgs); Assert.True(viewOrgs.Count > 0); Assert.All(viewOrgs, vo => @@ -138,7 +139,7 @@ public async Task PostAsync_NewOrganization_AssignsDefaultPlan() .StatusCodeShouldBeCreated() ); - // Assert - Newly created org should have a default plan + // Assert Assert.NotNull(viewOrg); Assert.NotNull(viewOrg.PlanId); Assert.NotNull(viewOrg.PlanName); @@ -154,9 +155,8 @@ public async Task GetAsync_ViewOrganization_IncludesIsOverMonthlyLimit() .StatusCodeShouldBeOk() ); - // Assert - IsOverMonthlyLimit is computed by OrganizationMapper + // Assert Assert.NotNull(viewOrg); - // The value can be true or false depending on usage, but the property should be set Assert.IsType(viewOrg.IsOverMonthlyLimit); } @@ -217,7 +217,7 @@ public Task GetAsync_NonExistentOrganization_ReturnsNotFound() [Fact] public async Task DeleteAsync_ExistingOrganization_RemovesOrganization() { - // Arrange - Create an organization to delete + // Arrange var newOrg = new NewOrganization { Name = "Organization To Delete" @@ -364,4 +364,156 @@ await SendRequestAsync(r => r Assert.Contains(globalAdmin.Id, project.NotificationSettings.Keys); Assert.Contains(Project.NotificationIntegrations.Slack, project.NotificationSettings.Keys); } + + [Fact] + public async Task GetPlansAsync_UnlimitedPlanOrg_ReturnsPlansWithCurrentPlanOverlay() + { + // Act + var plans = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "plans") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(plans); + Assert.True(plans.Count > 0); + var unlimitedPlan = plans.FirstOrDefault(p => p.Id == _plans.UnlimitedPlan.Id); + Assert.NotNull(unlimitedPlan); + Assert.False(unlimitedPlan.IsHidden); + } + + [Fact] + public async Task GetPlansAsync_FreePlanOrg_ExcludesHiddenPlans() + { + // Act + var plans = await SendRequestAsAsync>(r => r + .AsFreeOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "plans") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(plans); + Assert.True(plans.Count > 0); + + Assert.DoesNotContain(plans, p => p.IsHidden); + var freePlan = plans.FirstOrDefault(p => p.Id == _plans.FreePlan.Id); + Assert.NotNull(freePlan); + Assert.Equal(_plans.FreePlan.Name, freePlan.Name); + } + + [Fact] + public async Task GetPlansAsync_AdminUser_ReturnsAllPlansIncludingHidden() + { + // Act + var plans = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "plans") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(plans); + Assert.Equal(_plans.Plans.Count, plans.Count); + } + + [Fact] + public async Task GetPlansAsync_CurrentPlanOverlay_ReflectsOrgValues() + { + // Arrange + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(org); + + // Act + var plans = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "plans") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(plans); + var currentPlan = plans.FirstOrDefault(p => p.Id == org.PlanId); + Assert.NotNull(currentPlan); + Assert.Equal(org.PlanName, currentPlan.Name); + Assert.Equal(org.BillingPrice, currentPlan.Price); + Assert.Equal(org.MaxProjects, currentPlan.MaxProjects); + Assert.Equal(org.MaxUsers, currentPlan.MaxUsers); + Assert.Equal(org.RetentionDays, currentPlan.RetentionDays); + Assert.Equal(org.MaxEventsPerMonth, currentPlan.MaxEventsPerMonth); + Assert.Equal(org.HasPremiumFeatures, currentPlan.HasPremiumFeatures); + } + + [Fact] + public Task GetPlansAsync_NonExistentOrg_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", "000000000000000000000000", "plans") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task ChangePlanAsync_BillingDisabled_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "change-plan") + .Content(new ChangePlanRequest { PlanId = _plans.FreePlan.Id }) + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task ChangePlanAsync_LegacyQueryParams_BillingDisabled_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "change-plan") + .QueryString("planId", _plans.FreePlan.Id) + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task ChangePlanAsync_NonExistentOrg_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("organizations", "000000000000000000000000", "change-plan") + .Content(new ChangePlanRequest { PlanId = _plans.FreePlan.Id }) + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task GetInvoiceAsync_BillingDisabled_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", "invoice", "in_test_invoice_id") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task GetInvoicesAsync_BillingDisabled_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "invoices") + .StatusCodeShouldBeNotFound() + ); + } } diff --git a/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs b/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs index bf63b43d84..82fa6e27d6 100644 --- a/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs @@ -57,7 +57,6 @@ public async Task PostAsync_NewToken_MapsAllPropertiesToToken() Assert.Equal("Mapped test token", viewToken.Notes); Assert.True(viewToken.CreatedUtc > DateTime.MinValue); - // Verify persisted var token = await _tokenRepository.GetByIdAsync(viewToken.Id); Assert.NotNull(token); Assert.Equal("Mapped test token", token.Notes); @@ -131,6 +130,7 @@ public async Task PostAsync_NewTokenWithExpiry_MapsExpiresUtc() [Fact] public async Task PreventAccessTokenForTokenActions() { + // Arrange var token = await SendRequestAsAsync(r => r .Post() .AsGlobalAdminUser() @@ -149,6 +149,7 @@ public async Task PreventAccessTokenForTokenActions() Assert.False(token.IsDisabled); Assert.Equal(2, token.Scopes.Count); + // Act & Assert await SendRequestAsync(r => r .Post() .BearerToken(token.Id) @@ -186,6 +187,7 @@ await SendRequestAsync(r => r [Fact] public async Task CanDisableApiKey() { + // Arrange var token = await SendRequestAsAsync(r => r .Post() .AsGlobalAdminUser() @@ -203,13 +205,13 @@ public async Task CanDisableApiKey() Assert.Null(token.UserId); Assert.False(token.IsDisabled); Assert.Equal(2, token.Scopes.Count); - var updateToken = new UpdateToken { IsDisabled = true, Notes = "Disabling until next release" }; + // Act var updatedToken = await SendRequestAsAsync(r => r .Patch() .AsTestOrganizationUser() @@ -218,6 +220,7 @@ public async Task CanDisableApiKey() .StatusCodeShouldBeOk() ); + // Assert Assert.NotNull(updatedToken); Assert.True(updatedToken.IsDisabled); Assert.Equal(updateToken.Notes, updatedToken.Notes); @@ -244,6 +247,7 @@ await SendRequestAsync(r => r [Fact] public async Task SuspendingOrganizationWillDisableApiKey() { + // Arrange var token = await SendRequestAsAsync(r => r .Post() .AsGlobalAdminUser() @@ -271,6 +275,7 @@ public async Task SuspendingOrganizationWillDisableApiKey() Assert.False(tokenRecord.IsSuspended); Assert.Single(tokenRecord.Scopes); + // Act await SendRequestAsync(r => r .Post() .AsGlobalAdminUser() @@ -279,6 +284,7 @@ await SendRequestAsync(r => r .StatusCodeShouldBeOk() ); + // Assert var actualToken = await repository.GetByIdAsync(token.Id, o => o.Cache()); Assert.NotNull(actualToken); Assert.True(actualToken.IsSuspended); @@ -298,6 +304,7 @@ await SendRequestAsync(r => r [Fact] public async Task ShouldPreventAddingUserScopeToTokenWithoutElevatedRole() { + // Act var problemDetails = await SendRequestAsAsync(r => r .Post() .AsFreeOrganizationUser() @@ -311,6 +318,7 @@ public async Task ShouldPreventAddingUserScopeToTokenWithoutElevatedRole() .StatusCodeShouldBeUnprocessableEntity() ); + // Assert Assert.NotNull(problemDetails); Assert.Single(problemDetails.Errors); Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "scopes")); From 13c974e3ce7db789f218932e36816752bc091d27 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 22 Apr 2026 22:15:35 -0500 Subject: [PATCH 35/43] Replace API-driven premium filter detection with client-side check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove validateFilter API call, RequiresPremiumFeaturesState global state, and the \ hacks that triggered it on dashboard/stream/issues pages - Add premium-filter.ts with filterUsesPremiumFeatures() — pure function using the same free-field whitelist as PersistentEventQueryValidator on the backend - Derive requiresPremium in +layout.svelte so OrganizationNotifications reacts to URL filter changes without an extra network round-trip or effect - Extract inline handleError/handleUpgrade callbacks to named functions across dashboard, stream, issues, and notifications pages - Fix void retry() → async/await in upgrade-required-dialog onChangePlanClose - Replace per-component \(!!env.PUBLIC_STRIPE_PUBLISHABLE_KEY) with isStripeEnabled() from the billing module - Extract onOpenChange inline callback to named handleOpenChange function - Fix notifications handleUpgrade to use showUpgradeDialog() dialog instead of navigating away to the billing page - Suppress 426 from TanStack Query retry logic in root layout --- .../src/lib/features/events/premium-filter.ts | 47 +++++++++++++++++++ .../stack-options-dropdown-menu.svelte | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts new file mode 100644 index 0000000000..541f5700bc --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts @@ -0,0 +1,47 @@ +/** + * Free query fields that don't require a premium plan. + * Any field referenced in a filter that is NOT in this set requires premium features. + * Must be kept in sync with PersistentEventQueryValidator._freeQueryFields on the backend. + */ +const FREE_QUERY_FIELDS = new Set([ + 'date', + 'type', + 'reference', + 'reference_id', + 'organization', + 'organization_id', + 'project', + 'project_id', + 'stack', + 'stack_id', + 'status' +]); + +/** + * Extracts field names from a Lucene-style filter string. + * Matches patterns like `field:value` or `field:(value1 OR value2)`. + */ +function extractFilterFields(filter: string): string[] { + const fieldPattern = /(?:^|\s|[(!])(\w[\w.]*):/g; + const fields: string[] = []; + let match: null | RegExpExecArray; + + while ((match = fieldPattern.exec(filter)) !== null) { + fields.push(match[1]!); + } + + return fields; +} + +/** + * Returns true if the filter string references fields that require a premium plan. + * Uses client-side field detection to avoid an extra API call. + */ +export function filterUsesPremiumFeatures(filter: null | string | undefined): boolean { + if (!filter) { + return false; + } + + const fields = extractFilterFields(filter); + return fields.some((field) => !FREE_QUERY_FIELDS.has(field.toLowerCase())); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte index e8144f07e9..e2027d9c93 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte @@ -74,7 +74,7 @@ } if (response.status === 426) { - handleUpgradeRequired(response.problem, stack.organization_id); + handleUpgradeRequired(response.problem, stack.organization_id, () => promoteToExternal()); return; } From 16d2bc2f70438b55cca05fd072050787fa2123aa Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 22 Apr 2026 22:16:21 -0500 Subject: [PATCH 36/43] Refactor 426 upgrade handling: named callbacks, async retry, derived premium state - Replace void retry() with async/await in upgrade-required-dialog onChangePlanClose - Convert inline filterUsesPremiumFeatures() call to dollar-sign-derived in +layout - Extract inline handleError/handleUpgrade callbacks to named functions on dashboard, stream, issues, and notifications pages - Use isStripeEnabled() from billing module instead of per-component env check - Extract onOpenChange inline arrow to named handleOpenChange function - Suppress 426 from TanStack Query retry in root layout - Add retry callbacks to all 426 handlers (add project/org, invite user, webhooks) - Add showUpgradeDialog() for proactive upgrade prompts without a 426 response - Widen user-notification-settings-form upgrade prop to accept void return --- .../components/upgrade-required-dialog.svelte | 50 ++++++++++++++++--- .../src/lib/features/billing/index.ts | 2 +- .../billing/upgrade-required.svelte.ts | 25 +++++++++- .../user-notification-settings-form.svelte | 2 +- .../ClientApp/src/routes/(app)/+layout.svelte | 5 +- .../ClientApp/src/routes/(app)/+page.svelte | 15 +++++- .../(app)/account/notifications/+page.svelte | 7 ++- .../src/routes/(app)/issues/+page.svelte | 15 +++++- .../[organizationId]/users/+page.svelte | 2 +- .../(app)/organization/add/+page.svelte | 2 +- .../[projectId]/integrations/+page.svelte | 5 +- .../src/routes/(app)/project/add/+page.svelte | 2 +- .../src/routes/(app)/stream/+page.svelte | 15 +++++- .../ClientApp/src/routes/+layout.svelte | 2 +- 14 files changed, 123 insertions(+), 26 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte index 094f954c7f..915c7bf210 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte @@ -1,29 +1,65 @@ - + Upgrade Plan {upgradeRequiredDialog.message} - Cancel + Cancel Upgrade Plan + +{#if showChangePlan && organizationQuery.data} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts index 2f62715317..e5f2a1d6fe 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts @@ -17,4 +17,4 @@ export type { BillingPlan, CardMode, ChangePlanFormState, ChangePlanRequest, Cha // Context and hooks export { getStripePublishableKey, isStripeEnabled, loadStripeOnce, setStripeContext, type StripeContext, tryUseStripe, useStripe } from './stripe.svelte'; -export { handleUpgradeRequired, isUpgradeRequired } from './upgrade-required.svelte'; +export { handleUpgradeRequired, isUpgradeRequired, showUpgradeDialog } from './upgrade-required.svelte'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts index 99d8bd56c6..64e8330ed6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts @@ -4,12 +4,14 @@ interface UpgradeRequiredState { message: string; open: boolean; organizationId: string | undefined; + retryCallback: (() => Promise | void) | undefined; } const state: UpgradeRequiredState = $state({ message: '', open: false, - organizationId: undefined + organizationId: undefined, + retryCallback: undefined }); export const upgradeRequiredDialog = { @@ -21,9 +23,15 @@ export const upgradeRequiredDialog = { }, set open(value: boolean) { state.open = value; + if (!value) { + state.retryCallback = undefined; + } }, get organizationId() { return state.organizationId; + }, + get retryCallback() { + return state.retryCallback; } }; @@ -32,15 +40,17 @@ export const upgradeRequiredDialog = { * matching the legacy UI behavior: shows the backend message with * "Upgrade Plan" and "Cancel" buttons. * + * @param retryCallback - Optional callback invoked after a successful upgrade to retry the failed operation. * Returns true if the error was a 426 and was handled, false otherwise. */ -export function handleUpgradeRequired(error: unknown, organizationId: string | undefined): boolean { +export function handleUpgradeRequired(error: unknown, organizationId: string | undefined, retryCallback?: () => Promise | void): boolean { if (!isUpgradeRequired(error)) { return false; } state.message = error.title || 'Please upgrade your plan to continue.'; state.organizationId = organizationId; + state.retryCallback = retryCallback; state.open = true; return true; @@ -52,3 +62,14 @@ export function handleUpgradeRequired(error: unknown, organizationId: string | u export function isUpgradeRequired(error: unknown): error is ProblemDetails { return error instanceof ProblemDetails && error.status === 426; } + +/** + * Opens the upgrade dialog directly (without a 426 response). + * Use for proactive upgrade prompts like premium notification settings. + */ +export function showUpgradeDialog(organizationId: string, message?: string): void { + state.message = message || 'Please upgrade your plan to enable this feature.'; + state.organizationId = organizationId; + state.retryCallback = undefined; + state.open = true; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/user-notification-settings-form.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/user-notification-settings-form.svelte index 921b60f16b..9bd661dcff 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/user-notification-settings-form.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/user-notification-settings-form.svelte @@ -15,7 +15,7 @@ hasPremiumFeatures?: boolean; save: (settings: NotificationSettings) => Promise; settings?: NotificationSettings; - upgrade: () => Promise; + upgrade: () => Promise | void; } let { emailNotificationsEnabled = true, hasPremiumFeatures = false, save, settings, upgrade }: Props = $props(); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 27a921ad54..47eabd2a01 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -10,6 +10,9 @@ import { accessToken, gotoLogin } from '$features/auth/index.svelte'; import { UpgradeRequiredDialog } from '$features/billing'; import { invalidatePersistentEventQueries } from '$features/events/api.svelte'; + import { filterUsesPremiumFeatures } from '$features/events/premium-filter'; + + const requiresPremium = $derived(filterUsesPremiumFeatures(page.url.searchParams.get('filter'))); import { buildIntercomBootOptions, IntercomShell } from '$features/intercom'; import { shouldLoadIntercomOrganization } from '$features/intercom/config'; import { getOrganizationQuery, getOrganizationsQuery, invalidateOrganizationQueries } from '$features/organizations/api.svelte'; @@ -279,7 +282,7 @@ {#if showOrganizationNotifications.current} - + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index b4351ba2d2..43e3073a2e 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -37,8 +37,9 @@ import { parseDateMathRange } from '$features/shared/utils/datemath.js'; import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; + import { handleUpgradeRequired } from '$features/billing/upgrade-required.svelte'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, useFetchClientStatus } from '$shared/api/api.svelte'; - import { type FetchClientResponse, useFetchClient } from '@exceptionless/fetchclient'; + import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import ExternalLink from '@lucide/svelte/icons/external-link'; import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; @@ -46,6 +47,12 @@ import { throttle } from 'throttle-debounce'; let selectedEventId: null | string = $state(null); + + function handleEventError(problem: ProblemDetails) { + handleUpgradeRequired(problem, organization.current); + selectedEventId = null; + } + function rowclick(row: EventSummaryModel) { selectedEventId = row.id; } @@ -189,6 +196,10 @@ clientResponse = await client.getJSON[]>(`organizations/${organization.current}/events`, { params: eventsQueryParameters as Record }); + + if (clientResponse.problem) { + handleUpgradeRequired(clientResponse.problem, organization.current, () => loadData()); + } } const throttledLoadData = throttle(10000, loadData); @@ -312,7 +323,7 @@ >
- (selectedEventId = null)} /> +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte index 7955e2068f..4e4376ee6c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte @@ -7,6 +7,7 @@ import { Separator } from '$comp/ui/separator'; import { Skeleton } from '$comp/ui/skeleton'; import { Switch } from '$comp/ui/switch'; + import { showUpgradeDialog } from '$features/billing/upgrade-required.svelte'; import { getProjectsQuery, getProjectUserNotificationSettings, postProjectUserNotificationSettings } from '$features/projects/api.svelte'; import UserNotificationSettingsForm from '$features/projects/components/user-notification-settings-form.svelte'; import AlertDescription from '$features/shared/components/ui/alert/alert-description.svelte'; @@ -118,8 +119,10 @@ } } - async function handleUpgrade() { - console.log('TODO: Upgrade to premium features'); + function handleUpgrade() { + if (selectedProject?.organization_id) { + showUpgradeDialog(selectedProject.organization_id, 'Please upgrade your plan to enable occurrence level notifications.'); + } } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte index 2f66c284ee..41c6dde105 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -35,8 +35,9 @@ import TableStacksBulkActionsDropdownMenu from '$features/stacks/components/stacks-bulk-actions-dropdown-menu.svelte'; import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; + import { handleUpgradeRequired } from '$features/billing/upgrade-required.svelte'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, useFetchClientStatus } from '$shared/api/api.svelte'; - import { type FetchClientResponse, useFetchClient } from '@exceptionless/fetchclient'; + import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import ExternalLink from '@lucide/svelte/icons/external-link'; import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; @@ -47,6 +48,12 @@ // TODO: Update this page to use StackSummaryModel instead of EventSummaryModel. let selectedStackId = $state(); + + function handleStackError(problem: ProblemDetails) { + handleUpgradeRequired(problem, organization.current); + selectedStackId = undefined; + } + function rowClick(row: EventSummaryModel) { selectedStackId = row.id; } @@ -215,6 +222,10 @@ clientResponse = await client.getJSON[]>(`organizations/${organization.current}/events`, { params: eventsQueryParameters as Record }); + + if (clientResponse.problem) { + handleUpgradeRequired(clientResponse.problem, organization.current, () => loadData()); + } } const throttledLoadData = throttle(5000, loadData); @@ -339,7 +350,7 @@ >
- (selectedStackId = undefined)} /> +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte index fd563d0afc..c95cf47655 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte @@ -80,7 +80,7 @@ await addUserMutation.mutateAsync(email); toastId = toast.success('User invited successfully'); } catch (error: unknown) { - if (handleUpgradeRequired(error, organizationId)) { + if (handleUpgradeRequired(error, organizationId, () => inviteUser(email))) { return; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte index 05b6d0200a..e0c9b9d374 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte @@ -38,7 +38,7 @@ await goto(resolve('/(app)/organization/[organizationId]/manage', { organizationId: id })); return null; } catch (error: unknown) { - if (handleUpgradeRequired(error, organization.current)) { + if (handleUpgradeRequired(error, organization.current, () => form.handleSubmit())) { return null; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte index fbe035274b..16eed1c40d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte @@ -89,7 +89,7 @@ await newWebhook.mutateAsync(webhook); toastId = toast.success('Webhook added successfully'); } catch (error) { - if (handleUpgradeRequired(error, organization.current)) { + if (handleUpgradeRequired(error, organization.current, () => addWebhook(webhook))) { return; } @@ -106,9 +106,10 @@ await addSlackMutation.mutateAsync(code); toastId = toast.success('Successfully connected Slack integration.'); } catch (error) { - if (handleUpgradeRequired(error, organization.current)) { + if (handleUpgradeRequired(error, organization.current, () => addSlack())) { return; } + toastId = toast.error('Error connecting Slack integration. Please try again.'); } } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte index 959b32a23c..98dcb1e93a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte @@ -37,7 +37,7 @@ await goto(resolve('/(app)/project/[projectId]/configure', { projectId: id }) + '?redirect=true'); return null; } catch (error: unknown) { - if (handleUpgradeRequired(error, organization.current)) { + if (handleUpgradeRequired(error, organization.current, () => form.handleSubmit())) { return null; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index 5b81d822c7..7f5d60a1ee 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -31,8 +31,9 @@ import { getSharedTableOptions, isTableEmpty, removeTableData } from '$features/shared/table.svelte'; import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; + import { handleUpgradeRequired } from '$features/billing/upgrade-required.svelte'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, useFetchClientStatus } from '$shared/api/api.svelte'; - import { type FetchClientResponse, useFetchClient } from '@exceptionless/fetchclient'; + import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import ExternalLink from '@lucide/svelte/icons/external-link'; import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; @@ -42,6 +43,12 @@ import { redirectToEventsWithFilter } from '../redirect-to-events.svelte'; let selectedEventId: null | string = $state(null); + + function handleEventError(problem: ProblemDetails) { + handleUpgradeRequired(problem, organization.current); + selectedEventId = null; + } + function rowclick(row: EventSummaryModel) { selectedEventId = row.id; } @@ -193,6 +200,10 @@ } }); + if (clientResponse.problem && handleUpgradeRequired(clientResponse.problem, organization.current, () => loadData(true))) { + return; + } + if (clientResponse.ok) { if (clientResponse.meta.links.previous?.before) { before = clientResponse.meta.links.previous?.before; @@ -289,7 +300,7 @@ >
- (selectedEventId = null)} /> +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte index e3a46e1fdd..889400b434 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte @@ -82,7 +82,7 @@ return true; } - if ([400, 401, 403, 404, 410, 422].includes(status)) { + if ([400, 401, 403, 404, 410, 422, 426].includes(status)) { return false; } From ee124598998e88c238b38752cb282513c4aec7aa Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 22 Apr 2026 22:18:06 -0500 Subject: [PATCH 37/43] run format --- .../billing/components/upgrade-required-dialog.svelte | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte index 915c7bf210..2ad0b6378e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte @@ -44,10 +44,7 @@ } - + Upgrade Plan From dc6f4cc843be61a616d48155979339a2e14125eb Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 22 Apr 2026 22:57:46 -0500 Subject: [PATCH 38/43] Address PR feedback: surface ProblemDetails messages in dialogs - Angular change-plan dialog: extract title/message from ProblemDetails error responses instead of showing generic error message - Narrow invoice GetAsync catch to StripeException (avoid swallowing unexpected exceptions) - Remove redundant ex.Message from structured log templates (already captured by the exception parameter) - Sort imports alphabetically (formatter) --- .../billing/change-plan-controller.js | 2 ++ .../src/lib/features/events/premium-filter.ts | 34 +++++++++---------- .../ClientApp/src/routes/(app)/+page.svelte | 2 +- .../src/routes/(app)/issues/+page.svelte | 2 +- .../src/routes/(app)/stream/+page.svelte | 2 +- .../Controllers/OrganizationController.cs | 8 ++--- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp.angular/components/billing/change-plan-controller.js b/src/Exceptionless.Web/ClientApp.angular/components/billing/change-plan-controller.js index e02df72547..cdc4b31ab4 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/billing/change-plan-controller.js +++ b/src/Exceptionless.Web/ClientApp.angular/components/billing/change-plan-controller.js @@ -115,6 +115,8 @@ function onFailure(response) { if (response.error && response.error.message) { vm.paymentMessage = response.error.message; + } else if (response.data && (response.data.title || response.data.message)) { + vm.paymentMessage = response.data.title || response.data.message; } else { vm.paymentMessage = translateService.T("An error occurred while changing plans."); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts index 541f5700bc..ccb9fbd3a4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts @@ -5,18 +5,31 @@ */ const FREE_QUERY_FIELDS = new Set([ 'date', - 'type', - 'reference', - 'reference_id', 'organization', 'organization_id', 'project', 'project_id', + 'reference', + 'reference_id', 'stack', 'stack_id', - 'status' + 'status', + 'type' ]); +/** + * Returns true if the filter string references fields that require a premium plan. + * Uses client-side field detection to avoid an extra API call. + */ +export function filterUsesPremiumFeatures(filter: null | string | undefined): boolean { + if (!filter) { + return false; + } + + const fields = extractFilterFields(filter); + return fields.some((field) => !FREE_QUERY_FIELDS.has(field.toLowerCase())); +} + /** * Extracts field names from a Lucene-style filter string. * Matches patterns like `field:value` or `field:(value1 OR value2)`. @@ -32,16 +45,3 @@ function extractFilterFields(filter: string): string[] { return fields; } - -/** - * Returns true if the filter string references fields that require a premium plan. - * Uses client-side field detection to avoid an extra API call. - */ -export function filterUsesPremiumFeatures(filter: null | string | undefined): boolean { - if (!filter) { - return false; - } - - const fields = extractFilterFields(filter); - return fields.some((field) => !FREE_QUERY_FIELDS.has(field.toLowerCase())); -} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index 43e3073a2e..6e6501ef6d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -11,6 +11,7 @@ import { H3 } from '$comp/typography'; import { Button } from '$comp/ui/button'; import * as Sheet from '$comp/ui/sheet'; + import { handleUpgradeRequired } from '$features/billing/upgrade-required.svelte'; import { getOrganizationCountQuery } from '$features/events/api.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; import EventsOverview from '$features/events/components/events-overview.svelte'; @@ -37,7 +38,6 @@ import { parseDateMathRange } from '$features/shared/utils/datemath.js'; import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; - import { handleUpgradeRequired } from '$features/billing/upgrade-required.svelte'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, useFetchClientStatus } from '$shared/api/api.svelte'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import ExternalLink from '@lucide/svelte/icons/external-link'; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte index 41c6dde105..57bc9eb540 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -10,6 +10,7 @@ import { H3 } from '$comp/typography'; import { Button } from '$comp/ui/button'; import * as Sheet from '$comp/ui/sheet'; + import { handleUpgradeRequired } from '$features/billing/upgrade-required.svelte'; import { type GetEventsParams, getOrganizationCountQuery, getStackEventsQuery } from '$features/events/api.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; import EventsOverview from '$features/events/components/events-overview.svelte'; @@ -35,7 +36,6 @@ import TableStacksBulkActionsDropdownMenu from '$features/stacks/components/stacks-bulk-actions-dropdown-menu.svelte'; import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; - import { handleUpgradeRequired } from '$features/billing/upgrade-required.svelte'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, useFetchClientStatus } from '$shared/api/api.svelte'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import ExternalLink from '@lucide/svelte/icons/external-link'; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index 7f5d60a1ee..421a2ccb0b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -13,6 +13,7 @@ import { H3 } from '$comp/typography'; import { Button } from '$comp/ui/button'; import * as Sheet from '$comp/ui/sheet'; + import { handleUpgradeRequired } from '$features/billing/upgrade-required.svelte'; import EventsOverview from '$features/events/components/events-overview.svelte'; import { ProjectFilter, StatusFilter } from '$features/events/components/filters'; import { @@ -31,7 +32,6 @@ import { getSharedTableOptions, isTableEmpty, removeTableData } from '$features/shared/table.svelte'; import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; - import { handleUpgradeRequired } from '$features/billing/upgrade-required.svelte'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, useFetchClientStatus } from '$shared/api/api.svelte'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import ExternalLink from '@lucide/svelte/icons/external-link'; diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index da20aa9ee7..01db5235cf 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -229,7 +229,7 @@ public async Task> GetInvoiceAsync(string id) var invoiceService = new InvoiceService(client); stripeInvoice = await invoiceService.GetAsync(id); } - catch (Exception ex) + catch (StripeException ex) { _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}", id); } @@ -285,7 +285,7 @@ public async Task> GetInvoiceAsync(string id) } catch (StripeException ex) { - _logger.LogWarning(ex, "Failed to fetch price details for price: {PriceId}. Error: {ErrorMessage}", priceId, ex.Message); + _logger.LogWarning(ex, "Failed to fetch price details for price: {PriceId}", priceId); } } @@ -606,12 +606,12 @@ await Task.WhenAll( } catch (StripeException ex) { - _logger.LogCritical(ex, "An error occurred while trying to update your billing plan: {Message}", ex.Message); + _logger.LogCritical(ex, "An error occurred while trying to update your billing plan"); return Ok(ChangePlanResult.FailWithMessage(ex.Message)); } catch (Exception ex) { - _logger.LogCritical(ex, "An unexpected error occurred while trying to update your billing plan: {Message}", ex.Message); + _logger.LogCritical(ex, "An unexpected error occurred while trying to update your billing plan"); return Ok(ChangePlanResult.FailWithMessage("An error occurred while changing plans. Please try again.")); } From 7e2ed1afb36cd1e5323b7f6e977e8cac97276b68 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 22 Apr 2026 23:20:49 -0500 Subject: [PATCH 39/43] Add integration tests for ChangePlanAsync and CanDownGradeAsync - Unauthorized org access returns NotFound - Empty body with billing disabled returns NotFound - CanDownGradeAsync: too many users, too many projects, duplicate free plan, valid downgrade --- .../OrganizationControllerTests.cs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 71ac67461f..28ff0534c7 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -495,6 +495,122 @@ public Task ChangePlanAsync_NonExistentOrg_ReturnsNotFound() ); } + [Fact] + public Task ChangePlanAsync_UnauthorizedOrg_ReturnsNotFound() + { + // Free user should not be able to change plan for the test org they don't belong to + return SendRequestAsync(r => r + .AsFreeOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "change-plan") + .Content(new ChangePlanRequest { PlanId = _plans.FreePlan.Id }) + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task ChangePlanAsync_EmptyBody_BillingDisabled_ReturnsNotFound() + { + // Empty body should be accepted (falls back to query params) but billing disabled returns 404 + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "change-plan") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task CanDownGradeAsync_TooManyUsers_ReturnsFailure() + { + // Arrange — test org has 2 users (global admin + org user); free plan allows max 1 + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(org); + + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + + // Act + var result = await _billingManager.CanDownGradeAsync(org, _plans.FreePlan, user); + + // Assert + Assert.False(result.Success); + Assert.Contains("remove", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("user", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanDownGradeAsync_TooManyProjects_ReturnsFailure() + { + // Arrange — free org has 1 user and 1 project; add a second project so project check fails + var org = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); + Assert.NotNull(org); + + var extraProject = new Project + { + Name = "Extra Project", + OrganizationId = org.Id, + NextSummaryEndOfDayTicks = DateTime.UtcNow.Date.AddDays(1).AddHours(1).Ticks + }; + await _projectRepository.AddAsync(extraProject, o => o.ImmediateConsistency()); + + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); + Assert.NotNull(user); + + // Act + var result = await _billingManager.CanDownGradeAsync(org, _plans.FreePlan, user); + + // Assert + Assert.False(result.Success); + Assert.Contains("remove", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("project", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanDownGradeAsync_AlreadyHasFreePlan_ReturnsFailure() + { + // Arrange — create a second org for the free user, so they already have 1 free org + var freeUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); + Assert.NotNull(freeUser); + + var secondOrg = new Organization { Name = "Second Org" }; + _billingManager.ApplyBillingPlan(secondOrg, _plans.Plans.First(p => p.Id == "EX_SMALL"), freeUser); + secondOrg.StripeCustomerId = "cus_test"; + secondOrg.CardLast4 = "4242"; + secondOrg.SubscribeDate = DateTime.UtcNow; + secondOrg = await _organizationRepository.AddAsync(secondOrg, o => o.ImmediateConsistency()); + + freeUser.OrganizationIds.Add(secondOrg.Id); + await _userRepository.SaveAsync(freeUser, o => o.ImmediateConsistency()); + + // Act — try to downgrade second org to free plan (user already has FREE_ORG on free plan) + var result = await _billingManager.CanDownGradeAsync(secondOrg, _plans.FreePlan, freeUser); + + // Assert + Assert.False(result.Success); + Assert.Contains("free account", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanDownGradeAsync_ValidDowngrade_ReturnsSuccess() + { + // Arrange — the free org (1 user, 1 project) should be able to "downgrade" to small plan + var org = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); + Assert.NotNull(org); + + var smallPlan = _plans.Plans.FirstOrDefault(p => p.Id == "EX_SMALL"); + Assert.NotNull(smallPlan); + + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); + Assert.NotNull(user); + + // Act — "upgrading" from free to small, downgrade check should succeed + var result = await _billingManager.CanDownGradeAsync(org, smallPlan, user); + + // Assert + Assert.True(result.Success); + } + [Fact] public Task GetInvoiceAsync_BillingDisabled_ReturnsNotFound() { From 84f3dbf27f1005acb6902fb129c3cad273c8e158 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 22 Apr 2026 23:28:49 -0500 Subject: [PATCH 40/43] Sanitize invoice URL and add AAA comments to tests - encodeURIComponent on invoiceId in Stripe URL to prevent injection - Add // Act & Assert markers to ChangePlanAsync tests for consistency --- .../(app)/organization/[organizationId]/billing/+page.svelte | 2 +- .../Controllers/OrganizationControllerTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 102aedc7db..7cc588a361 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 @@ -59,7 +59,7 @@ } function handleViewStripeInvoice(invoiceId: string) { - window.open(`https://manage.stripe.com/invoices/in_${invoiceId}`, '_blank'); + window.open(`https://manage.stripe.com/invoices/in_${encodeURIComponent(invoiceId)}`, '_blank'); } diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 28ff0534c7..4e57fce77a 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -498,7 +498,7 @@ public Task ChangePlanAsync_NonExistentOrg_ReturnsNotFound() [Fact] public Task ChangePlanAsync_UnauthorizedOrg_ReturnsNotFound() { - // Free user should not be able to change plan for the test org they don't belong to + // Act & Assert — free user should not be able to change plan for the test org they don't belong to return SendRequestAsync(r => r .AsFreeOrganizationUser() .Post() @@ -511,7 +511,7 @@ public Task ChangePlanAsync_UnauthorizedOrg_ReturnsNotFound() [Fact] public Task ChangePlanAsync_EmptyBody_BillingDisabled_ReturnsNotFound() { - // Empty body should be accepted (falls back to query params) but billing disabled returns 404 + // Act & Assert — empty body falls back to query params; billing disabled returns 404 return SendRequestAsync(r => r .AsTestOrganizationUser() .Post() From 9e060c6bf8d89b423717b1059c192f0f3052a4d2 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 23 Apr 2026 00:02:18 -0500 Subject: [PATCH 41/43] fix: remove required keyword from ChangePlanRequest.PlanId The C# 'required' keyword combined with .NET's RespectNullableAnnotations caused System.Text.Json to reject valid JSON bodies with snake_case naming. Keep [Required] attribute for model validation. Fixes POST /organizations/{id}/change-plan JSON body deserialization. --- src/Exceptionless.Web/Models/ChangePlanRequest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/Models/ChangePlanRequest.cs b/src/Exceptionless.Web/Models/ChangePlanRequest.cs index 6c5f92e086..dda8e4efc5 100644 --- a/src/Exceptionless.Web/Models/ChangePlanRequest.cs +++ b/src/Exceptionless.Web/Models/ChangePlanRequest.cs @@ -5,7 +5,7 @@ namespace Exceptionless.Web.Models; public class ChangePlanRequest { [Required] - public required string PlanId { get; set; } + public string PlanId { get; set; } = String.Empty; public string? StripeToken { get; set; } From a4cb3f44e999929392a00282233cbcc7f73708b2 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 23 Apr 2026 00:04:58 -0500 Subject: [PATCH 42/43] fix: handle empty subscription items and sanitize Stripe errors - Cancel broken subscription with 0 items and create a new one instead of silently doing nothing - Replace raw StripeException.Message with generic error to avoid leaking Stripe internals to end users --- .../Controllers/OrganizationController.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 01db5235cf..9586c2c7ad 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -583,7 +583,13 @@ await Task.WhenAll( } else if (subscription is not null) { - _logger.LogWarning("Subscription {SubscriptionId} has no items for organization {OrganizationId}", subscription.Id, id); + _logger.LogWarning("Subscription {SubscriptionId} has no items for organization {OrganizationId}, canceling and recreating", subscription.Id, id); + await subscriptionService.CancelAsync(subscription.Id); + + create.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); + if (!String.IsNullOrWhiteSpace(model.CouponId)) + create.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; + await subscriptionService.CreateAsync(create); } else { @@ -607,7 +613,7 @@ await Task.WhenAll( catch (StripeException ex) { _logger.LogCritical(ex, "An error occurred while trying to update your billing plan"); - return Ok(ChangePlanResult.FailWithMessage(ex.Message)); + return Ok(ChangePlanResult.FailWithMessage("An error occurred while changing plans. Please try again or contact support.")); } catch (Exception ex) { From b81d6f376885ae95d71bd8558176f47fc8386a4e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 23 Apr 2026 08:16:19 -0500 Subject: [PATCH 43/43] fix: address review feedback on billing changes - ChangePlanRequest.PlanId: use null! instead of String.Empty default - Empty subscription items: update with new item instead of cancel+recreate - Log StripeException.Message via structured {Message} template - Keep sanitized user-facing error (no Stripe internals leak) --- .../Controllers/OrganizationController.cs | 12 +++++------- src/Exceptionless.Web/Models/ChangePlanRequest.cs | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 9586c2c7ad..0f85ea6ea6 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -583,13 +583,11 @@ await Task.WhenAll( } else if (subscription is not null) { - _logger.LogWarning("Subscription {SubscriptionId} has no items for organization {OrganizationId}, canceling and recreating", subscription.Id, id); - await subscriptionService.CancelAsync(subscription.Id); - - create.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); + _logger.LogWarning("Subscription {SubscriptionId} has no items for organization {OrganizationId}, adding new item", subscription.Id, id); + update.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); if (!String.IsNullOrWhiteSpace(model.CouponId)) - create.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; - await subscriptionService.CreateAsync(create); + update.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; + await subscriptionService.UpdateAsync(subscription.Id, update); } else { @@ -612,7 +610,7 @@ await Task.WhenAll( } catch (StripeException ex) { - _logger.LogCritical(ex, "An error occurred while trying to update your billing plan"); + _logger.LogCritical(ex, "An error occurred while trying to update your billing plan: {Message}", ex.Message); return Ok(ChangePlanResult.FailWithMessage("An error occurred while changing plans. Please try again or contact support.")); } catch (Exception ex) diff --git a/src/Exceptionless.Web/Models/ChangePlanRequest.cs b/src/Exceptionless.Web/Models/ChangePlanRequest.cs index dda8e4efc5..640ef1f60b 100644 --- a/src/Exceptionless.Web/Models/ChangePlanRequest.cs +++ b/src/Exceptionless.Web/Models/ChangePlanRequest.cs @@ -5,7 +5,7 @@ namespace Exceptionless.Web.Models; public class ChangePlanRequest { [Required] - public string PlanId { get; set; } = String.Empty; + public string PlanId { get; set; } = null!; public string? StripeToken { get; set; }