-
-
Notifications
You must be signed in to change notification settings - Fork 508
feat: Next generation Stripe billing integration #2161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
niemyjski
wants to merge
37
commits into
main
Choose a base branch
from
feature/next-stripe
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 cbbb917
Merge branch 'main' into feature/next-stripe
ejsmith f410162
Fix build
ejsmith da19330
Fix deploy not running
ejsmith 189571b
test: trigger build to verify version increment
ejsmith 548ae59
Conditionally add exporter
ejsmith 2afec55
refactor: update OpenTelemetry configuration and remove Prometheus ex…
ejsmith acf5b09
fix: use PR branch HEAD for version calculation to prevent duplicate …
ejsmith c1150fc
fix: pass version from CI to Docker build via MinVerVersionOverride
ejsmith bebc9fb
Merge branch 'main' into feature/next-stripe
niemyjski da99c03
Upgrade Stripe to v51
niemyjski abacf70
Added local config override.
niemyjski de39b10
fix: pass mode to Stripe Elements when clientSecret is absent
niemyjski f4c5396
fix: Stripe PaymentElement rendering and dialog scrollability
niemyjski 452a93a
build: upgrade Stripe.js to v9 and fix Stripe.net v51 breaking changes
niemyjski c2f1580
Merge branch 'main' into feature/next-stripe
niemyjski 062eeee
fix: handle 426 Upgrade Required responses with modal dialog
niemyjski 6723fd1
fix: resolve infinite loop in Change Plan dialog with untrack()\n\nUs…
niemyjski b6d648a
feat: redesign change plan dialog with improved tier grouping and sum…
niemyjski a826b29
fix: resolve billing plan change failures
niemyjski 46b0a5f
feat: refine Change Plan dialog and add comprehensive Storybook stories
niemyjski 6be51a1
feat: refactor Change Plan dialog to use standard UI components
niemyjski ff5db7c
feat: add follow-up hook to enforce task summary before completion
niemyjski 6770e76
feat: enhance Change Plan dialog with state overrides and new stories
niemyjski c324e28
feat: refine Change Plan dialog UI and error display
niemyjski 304912d
fix: handle \-price plans in tier filtering and yearly savings badge
niemyjski 74ebc4e
refactor: remove redundant ex.Message from log templates
niemyjski bba4e40
docs: add billing & Stripe integration documentation
niemyjski 29b81ef
fix(a11y): fix WCAG AA color contrast on primary buttons and badges
niemyjski 2e1ce7b
fix: address PR review findings
niemyjski fcbf7d2
Clean up change-plan endpoint: remove dual binding, enforce ProblemDe…
niemyjski 20a5281
fix: use instanceof ProblemDetails instead of isProblemDetails type g…
niemyjski a1a2a4f
Use proper HTTP status codes for ChangePlan pre-validation errors
niemyjski ba14c41
Refactor Change Plan dialog UX and optimize server-side Stripe operat…
niemyjski 19fbdbb
Merge branch 'main' into feature/next-stripe
niemyjski aac0867
Improve UX in change-plan dialog by scrolling to relevant sections
niemyjski 7187572
Fixed linting
niemyjski File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
344 changes: 344 additions & 0 deletions
344
...Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte
Large diffs are not rendered by default.
Oops, something went wrong.
75 changes: 75 additions & 0 deletions
75
src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} |
1 change: 1 addition & 0 deletions
1
src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
16
src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
33
src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
9 changes: 9 additions & 0 deletions
9
src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>; |
86 changes: 86 additions & 0 deletions
86
src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.