Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ee79cb9
feat: Next: Stripe billing and plan management
niemyjski Mar 17, 2026
cbbb917
Merge branch 'main' into feature/next-stripe
ejsmith Apr 16, 2026
f410162
Fix build
ejsmith Apr 16, 2026
da19330
Fix deploy not running
ejsmith Apr 16, 2026
189571b
test: trigger build to verify version increment
ejsmith Apr 16, 2026
548ae59
Conditionally add exporter
ejsmith Apr 16, 2026
2afec55
refactor: update OpenTelemetry configuration and remove Prometheus ex…
ejsmith Apr 16, 2026
acf5b09
fix: use PR branch HEAD for version calculation to prevent duplicate …
ejsmith Apr 16, 2026
c1150fc
fix: pass version from CI to Docker build via MinVerVersionOverride
ejsmith Apr 16, 2026
bebc9fb
Merge branch 'main' into feature/next-stripe
niemyjski Apr 18, 2026
da99c03
Upgrade Stripe to v51
niemyjski Apr 19, 2026
abacf70
Added local config override.
niemyjski Apr 19, 2026
de39b10
fix: pass mode to Stripe Elements when clientSecret is absent
niemyjski Apr 19, 2026
f4c5396
fix: Stripe PaymentElement rendering and dialog scrollability
niemyjski Apr 19, 2026
452a93a
build: upgrade Stripe.js to v9 and fix Stripe.net v51 breaking changes
niemyjski Apr 19, 2026
c2f1580
Merge branch 'main' into feature/next-stripe
niemyjski Apr 19, 2026
062eeee
fix: handle 426 Upgrade Required responses with modal dialog
niemyjski Apr 19, 2026
6723fd1
fix: resolve infinite loop in Change Plan dialog with untrack()\n\nUs…
niemyjski Apr 19, 2026
b6d648a
feat: redesign change plan dialog with improved tier grouping and sum…
niemyjski Apr 19, 2026
a826b29
fix: resolve billing plan change failures
niemyjski Apr 20, 2026
46b0a5f
feat: refine Change Plan dialog and add comprehensive Storybook stories
niemyjski Apr 20, 2026
6be51a1
feat: refactor Change Plan dialog to use standard UI components
niemyjski Apr 20, 2026
ff5db7c
feat: add follow-up hook to enforce task summary before completion
niemyjski Apr 20, 2026
6770e76
feat: enhance Change Plan dialog with state overrides and new stories
niemyjski Apr 20, 2026
c324e28
feat: refine Change Plan dialog UI and error display
niemyjski Apr 20, 2026
304912d
fix: handle \-price plans in tier filtering and yearly savings badge
niemyjski Apr 20, 2026
74ebc4e
refactor: remove redundant ex.Message from log templates
niemyjski Apr 20, 2026
bba4e40
docs: add billing & Stripe integration documentation
niemyjski Apr 20, 2026
29b81ef
fix(a11y): fix WCAG AA color contrast on primary buttons and badges
niemyjski Apr 20, 2026
2e1ce7b
fix: address PR review findings
niemyjski Apr 20, 2026
fcbf7d2
Clean up change-plan endpoint: remove dual binding, enforce ProblemDe…
niemyjski Apr 20, 2026
20a5281
fix: use instanceof ProblemDetails instead of isProblemDetails type g…
niemyjski Apr 20, 2026
a1a2a4f
Use proper HTTP status codes for ChangePlan pre-validation errors
niemyjski Apr 20, 2026
ba14c41
Refactor Change Plan dialog UX and optimize server-side Stripe operat…
niemyjski Apr 21, 2026
19fbdbb
Merge branch 'main' into feature/next-stripe
niemyjski Apr 21, 2026
aac0867
Improve UX in change-plan dialog by scrolling to relevant sections
niemyjski Apr 21, 2026
7187572
Fixed linting
niemyjski Apr 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Exceptionless.Core/Exceptionless.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.6" />
<PackageReference Include="Stripe.net" Version="47.4.0" />
<PackageReference Include="Stripe.net" Version="50.4.1" />
<PackageReference Include="System.DirectoryServices" Version="10.0.6" />
<PackageReference Include="UAParser" Version="3.1.47" />
<PackageReference Include="Foundatio.Repositories.Elasticsearch" Version="7.18.0-beta6" Condition="'$(ReferenceFoundatioRepositoriesSource)' == '' OR '$(ReferenceFoundatioRepositoriesSource)' == 'false'" />
Expand Down
21 changes: 21 additions & 0 deletions src/Exceptionless.Web/ClientApp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/Exceptionless.Web/ClientApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.29.0",
"@tanstack/svelte-query": "^6.1.16",
"@tanstack/svelte-query-devtools": "^6.1.16",
Expand All @@ -90,6 +91,7 @@
"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",
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script lang="ts">
import type { Stripe, StripeElements, StripeElementsOptions } from '@stripe/stripe-js';
import type { Snippet } from 'svelte';

import ErrorMessage from '$comp/error-message.svelte';
import { Skeleton } from '$comp/ui/skeleton';
import { loadStripeOnce, setStripeContext } from '$features/billing/stripe.svelte';
import { onMount } from 'svelte';
import { Elements } from 'svelte-stripe';

interface Props {
/** Optional appearance theme for Stripe Elements */
appearance?: StripeElementsOptions['appearance'];
/** Content to render inside Elements */
children: Snippet;
/** Optional client secret for PaymentIntent/SetupIntent mode */
clientSecret?: string;
/** Callback when Stripe Elements state changes */
onElementsChange?: (elements: StripeElements | undefined) => void;
/** Callback when Stripe finishes loading */
onload?: (stripe: Stripe) => void;
}

let { appearance, children, clientSecret, onElementsChange, onload }: Props = $props();

let stripe = $state<null | Stripe>(null);
let elements = $state<StripeElements | undefined>(undefined);
let isLoading = $state(true);
let error = $state<null | string>(null);

// Set up context for child components using useStripe()
setStripeContext({
get elements() {
return elements ?? null;
},
get error() {
return error;
},
get isLoading() {
return isLoading;
},
get stripe() {
return stripe;
}
});

onMount(async () => {
try {
stripe = await loadStripeOnce();
if (!stripe) {
error = 'Stripe is not configured. Please contact support.';
} else {
onload?.(stripe);
}
} catch (ex) {
error = ex instanceof Error ? ex.message : 'Failed to load payment system';
} finally {
isLoading = false;
}
});

$effect(() => {
onElementsChange?.(elements);
});
</script>

{#if isLoading}
<Skeleton class="h-32 w-full" />
{:else if error}
<ErrorMessage message={error} />
{:else if stripe}
<Elements {stripe} {clientSecret} {appearance} bind:elements>
{@render children()}
</Elements>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const FREE_PLAN_ID = 'EX_FREE';
16 changes: 16 additions & 0 deletions src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts
Original file line number Diff line number Diff line change
@@ -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';
33 changes: 33 additions & 0 deletions src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<typeof ChangePlanSchema>;
Original file line number Diff line number Diff line change
@@ -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 | Stripe> = null;
let _stripeInstance: null | Stripe = null;

/**
* Load Stripe instance lazily. Returns cached instance if already loaded.
*/
export async function loadStripeOnce(): Promise<null | Stripe> {
if (_stripeInstance) {
return _stripeInstance;
}

if (!isStripeEnabled()) {
return null;
}

if (!_stripePromise) {
_stripePromise = loadStripe(env.PUBLIC_STRIPE_PUBLISHABLE_KEY!);
}

_stripeInstance = await _stripePromise;
return _stripeInstance;
Comment thread
niemyjski marked this conversation as resolved.
Outdated
}

/**
* 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<StripeContext | undefined>(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<StripeContext | undefined>(STRIPE_CONTEXT_KEY);
if (!ctx) {
throw new Error('useStripe() must be called within a StripeProvider component');
}
return ctx;
}
Loading
Loading