feat: Next generation Stripe billing integration#2161
feat: Next generation Stripe billing integration#2161
Conversation
- 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.
985add7 to
ee79cb9
Compare
| catch (Exception ex) | ||
| { | ||
| _logger.LogWarning(ex, "Failed to fetch price details for price ID: {PriceId}. Exception: {Message}", priceId, ex.Message); | ||
| } |
There was a problem hiding this comment.
Pull request overview
This PR introduces a “next generation” Stripe billing integration spanning backend Stripe.net v50 updates, new billing endpoints/DTO mapping, and a new Svelte 5 billing UI (Stripe provider + change-plan dialog) wired into organization/project usage and billing pages.
Changes:
- Upgrades Stripe.net usage on the backend (invoice status handling, discounts, subscription updates, PaymentMethod support).
- Adds a new Svelte billing feature module (
StripeProvider,ChangePlanDialog) and hooks it into usage/billing routes. - Extends org client API with plan query + change-plan mutation; updates invoice mapping/tests accordingly.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs | Updates Stripe invoice test data to use Status instead of Paid. |
| tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs | Simplifies test comments (no functional change). |
| src/Exceptionless.Web/Mapping/InvoiceMapper.cs | Maps “paid” via Invoice.Status string comparison. |
| src/Exceptionless.Web/Controllers/OrganizationController.cs | Updates Stripe invoice retrieval/discount handling and modernizes change-plan flow to support PaymentMethods + Stripe.net 50 API changes. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte | “Change plan” now navigates to the org billing page. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte | “Change plan” now navigates to the org billing page. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte | Uses new billing module ChangePlanDialog and passes loaded org data into it. |
| src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte | Removes the legacy placeholder change-plan dialog component. |
| src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts | Adds getPlansQuery() and changePlanMutation() + related query keys/types. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts | Adds Stripe context + lazy singleton loader utilities. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts | Adds zod schema/types for the change-plan form. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts | Adds billing model re-exports + local billing form/params types. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts | Barrel exports for billing feature module. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts | Defines FREE_PLAN_ID. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte | Adds Stripe Elements provider wrapper with loading/error states. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte | Implements the plan change dialog UI including Stripe payment collection. |
| src/Exceptionless.Web/ClientApp/package.json | Adds Stripe JS + svelte-stripe dependencies. |
| src/Exceptionless.Web/ClientApp/package-lock.json | Locks Stripe JS + svelte-stripe dependencies. |
| src/Exceptionless.Web/ClientApp/STRIPE-INTEGRATION-PLAN.md | Adds the Stripe integration plan document. |
| src/Exceptionless.Core/Exceptionless.Core.csproj | Upgrades Stripe.net to 50.4.1. |
| .claude/agents/engineer.md | Adds guidance for rerunning flaky CI jobs via gh run rerun. |
Files not reviewed (1)
- src/Exceptionless.Web/ClientApp/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <!-- Stripe PaymentElement for new card --> | ||
| {#if needsPayment} | ||
| <StripeProvider | ||
| appearance={{ | ||
| theme: 'stripe', | ||
| variables: { | ||
| borderRadius: '6px' | ||
| } | ||
| }} | ||
| onElementsChange={(elements) => { | ||
| stripeElements = elements; | ||
| }} | ||
| onload={(loadedStripe) => { | ||
| stripe = loadedStripe; | ||
| }} | ||
| > | ||
| <div class="rounded-lg border p-4"> | ||
| <PaymentElement /> | ||
| </div> | ||
| </StripeProvider> | ||
| {/if} |
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}", id); | ||
| _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}. Exception: {Message}", id, ex.Message); |
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogWarning(ex, "Failed to fetch price details for price ID: {PriceId}. Exception: {Message}", priceId, ex.Message); |
There was a problem hiding this comment.
Pull request overview
This PR introduces a “next generation” Stripe billing integration across the backend (Stripe.net 50.x) and the Svelte client, enabling plan selection/changes and invoice display with the updated Stripe API surface.
Changes:
- Upgrades Stripe.net to 50.4.1 and updates invoice/plan-change logic to use
Status,Price,Discounts, andPaymentMethodAPIs. - Adds a new client-side billing feature module (Stripe context/provider + change plan dialog) and wires it into usage/billing pages.
- Adds client API helpers for fetching plans and performing plan changes, with cache invalidation.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs | Updates invoice mapping tests to reflect Stripe Status replacing Paid. |
| tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs | Simplifies test comments (no functional change). |
| src/Exceptionless.Web/Mapping/InvoiceMapper.cs | Switches paid logic to derive from Invoice.Status. |
| src/Exceptionless.Web/Controllers/OrganizationController.cs | Updates invoice retrieval and plan change flows for Stripe.net 50.x (Price/Discounts/PaymentMethod changes). |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte | Replaces placeholder “change plan” handler with navigation to billing page. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte | Same as above for org usage page. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte | Switches to the new billing module’s ChangePlanDialog and passes organization data. |
| src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte | Removes the old placeholder change-plan dialog component. |
| src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts | Adds getPlansQuery + changePlanMutation and associated query keys/types. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts | Adds Stripe context utilities and a singleton Stripe loader. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts | Adds Zod schema for the change-plan form. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts | Adds billing-focused types and re-exports generated API models. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts | Exposes billing module public API (components/hooks/constants/types). |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts | Introduces FREE_PLAN_ID. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte | Adds a Stripe <Elements> provider and sets Stripe context for children. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte | Implements the new plan change UX using Stripe PaymentElement + plans query + mutation. |
| src/Exceptionless.Web/ClientApp/package.json | Adds @stripe/stripe-js and svelte-stripe. |
| src/Exceptionless.Web/ClientApp/package-lock.json | Locks new Stripe dependencies. |
| src/Exceptionless.Core/Exceptionless.Core.csproj | Upgrades Stripe.net from 47.4.0 to 50.4.1. |
Files not reviewed (1)
- src/Exceptionless.Web/ClientApp/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Id = source.Id[3..], // Strip "in_" prefix | ||
| Date = source.Created, | ||
| Paid = source.Paid | ||
| Paid = String.Equals(source.Status, "paid", StringComparison.Ordinal) |
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}", id); | ||
| _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}. Exception: {Message}", id, ex.Message); |
| OrganizationName = organization.Name, | ||
| Date = stripeInvoice.Created, | ||
| Paid = stripeInvoice.Paid, | ||
| Paid = String.Equals(stripeInvoice.Status, "paid", StringComparison.Ordinal), |
| // Derived: Default plan is next tier (upsell) or current if at top tier | ||
| const defaultPlanId = $derived.by(() => { | ||
| if (!plansQuery.data) { | ||
| return undefined; | ||
| } | ||
| const currentPlanIndex = plansQuery.data.findIndex((p: BillingPlan) => p.id === organization.plan_id); | ||
| const nextPlan = plansQuery.data[currentPlanIndex + 1] ?? plansQuery.data[currentPlanIndex]; | ||
| return nextPlan?.id; | ||
| }); | ||
|
|
||
| // Derived state | ||
| const selectedPlanId = $derived(form.state.values.selectedPlanId || defaultPlanId); | ||
| const cardMode = $derived(form.state.values.cardMode); | ||
| 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 needsPayment = $derived(isPaidPlan && cardMode === 'new'); | ||
| const isCurrentPlan = $derived(selectedPlanId === organization.plan_id); | ||
|
|
||
| $effect(() => { | ||
| if (open) { | ||
| form.reset(); | ||
| form.setFieldValue('cardMode', hasExistingCard ? 'existing' : 'new'); | ||
| form.setFieldValue('couponId', ''); | ||
| form.setFieldValue('selectedPlanId', defaultPlanId ?? ''); | ||
| } |
| var priceService = new PriceService(client); | ||
| var priceCache = new Dictionary<string, Stripe.Price>(StringComparer.Ordinal); | ||
| foreach (var line in stripeInvoice.Lines.Data) | ||
| { | ||
| var item = new InvoiceLineItem { Amount = line.Amount / 100.0m, Description = line.Description }; | ||
| if (line.Plan is not null) | ||
|
|
||
| // 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; | ||
| if (!String.IsNullOrEmpty(priceId)) | ||
| { | ||
| string planName = line.Plan.Nickname ?? _billingManager.GetBillingPlan(line.Plan.Id)?.Name ?? line.Plan.Id; | ||
| item.Description = $"Exceptionless - {planName} Plan ({(line.Plan.Amount / 100.0):c}/{line.Plan.Interval})"; | ||
| try | ||
| { | ||
| if (!priceCache.TryGetValue(priceId, out var price)) | ||
| { | ||
| price = await priceService.GetAsync(priceId); | ||
| priceCache[priceId] = price; |
| var update = new SubscriptionUpdateOptions { Items = [] }; | ||
| var create = new SubscriptionCreateOptions { Customer = organization.StripeCustomerId, Items = [] }; | ||
| bool cardUpdated = false; | ||
|
|
||
| var customerUpdateOptions = new CustomerUpdateOptions { Description = organization.Name }; | ||
| if (!Request.IsGlobalAdmin()) | ||
| customerUpdateOptions.Email = CurrentUser.EmailAddress; | ||
|
|
||
| if (!String.IsNullOrEmpty(stripeToken)) | ||
| { | ||
| customerUpdateOptions.Source = stripeToken; | ||
| if (isPaymentMethod) | ||
| { | ||
| // Modern Svelte UI: Attach PaymentMethod and set as default | ||
| await paymentMethodService.AttachAsync(stripeToken, new PaymentMethodAttachOptions | ||
| { | ||
| Customer = organization.StripeCustomerId | ||
| }); | ||
| customerUpdateOptions.InvoiceSettings = new CustomerInvoiceSettingsOptions | ||
| { | ||
| DefaultPaymentMethod = stripeToken | ||
| }; | ||
| } | ||
| else | ||
| { | ||
| // Legacy Angular UI: Use Source for token | ||
| customerUpdateOptions.Source = stripeToken; | ||
| } | ||
| cardUpdated = true; | ||
| } | ||
|
|
||
| await customerService.UpdateAsync(organization.StripeCustomerId, customerUpdateOptions); | ||
|
|
||
| var subscriptionList = await subscriptionService.ListAsync(new SubscriptionListOptions { Customer = organization.StripeCustomerId }); | ||
| var subscription = subscriptionList.FirstOrDefault(s => !s.CanceledAt.HasValue); | ||
| if (subscription is not null) | ||
| { | ||
| update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Plan = planId }); | ||
| update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Price = planId }); | ||
| await subscriptionService.UpdateAsync(subscription.Id, update); | ||
| } | ||
| else | ||
| { | ||
| create.Items.Add(new SubscriptionItemOptions { Plan = planId }); | ||
| create.Items.Add(new SubscriptionItemOptions { Price = planId }); | ||
| await subscriptionService.CreateAsync(create); | ||
| } |
| } | ||
|
|
||
| function formatPrice(price: number): string { | ||
| return price === 0 ? 'Free' : `$${price}/month`; |
| _stripeInstance = await _stripePromise; | ||
| return _stripeInstance; |
| @@ -434,17 +471,45 @@ | |||
|
|
|||
| var createCustomer = new CustomerCreateOptions | |||
| { | |||
| Source = stripeToken, | |||
| Plan = planId, | |||
| Description = organization.Name, | |||
| Email = CurrentUser.EmailAddress | |||
| }; | |||
|
|
|||
| if (!String.IsNullOrWhiteSpace(couponId)) | |||
| createCustomer.Coupon = couponId; | |||
| // Handle both legacy tokens and modern PaymentMethod IDs for backwards compatibility | |||
| if (isPaymentMethod) | |||
| { | |||
| // Modern Svelte UI: Uses PaymentMethod from createPaymentMethod() | |||
| createCustomer.PaymentMethod = stripeToken; | |||
| createCustomer.InvoiceSettings = new CustomerInvoiceSettingsOptions | |||
| { | |||
| DefaultPaymentMethod = stripeToken | |||
| }; | |||
| } | |||
| else | |||
| { | |||
| // Legacy Angular UI: Uses token from createToken() | |||
| createCustomer.Source = stripeToken; | |||
| } | |||
|
|
|||
| var customer = await customerService.CreateAsync(createCustomer); | |||
|
|
|||
| // Create subscription separately (Plan on CustomerCreateOptions is deprecated) | |||
| var subscriptionOptions = new SubscriptionCreateOptions | |||
| { | |||
| Customer = customer.Id, | |||
| Items = [new SubscriptionItemOptions { Price = planId }] | |||
| }; | |||
|
|
|||
| if (isPaymentMethod) | |||
| subscriptionOptions.DefaultPaymentMethod = 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 }]; | |||
|
|
|||
| await subscriptionService.CreateAsync(subscriptionOptions); | |||
|
|
|||
There was a problem hiding this comment.
Pull request overview
This PR introduces a new Stripe-based billing UX in the Svelte app and updates the backend Stripe integration to newer Stripe.net APIs, alongside some infra/telemetry adjustments.
Changes:
- Adds new billing feature module on the frontend (Stripe provider + change-plan dialog) and wires it into usage/billing pages.
- Updates backend invoice/payment handling to Stripe.net 50.x patterns (invoice status, discounts, line item pricing/price lookup).
- Updates dependencies/config (Stripe.net upgrade, adds
@stripe/stripe-js+svelte-stripe, adjusts OpenTelemetry Prometheus wiring and deploy workflow conditions).
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs | Updates invoice mapping tests to use Status instead of Paid. |
| tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs | Simplifies test comments (no functional change). |
| src/Exceptionless.Web/Startup.cs | Removes Prometheus scraping endpoint middleware. |
| src/Exceptionless.Web/Mapping/InvoiceMapper.cs | Maps Paid from Invoice.Status == "paid". |
| src/Exceptionless.Web/Exceptionless.Web.csproj | Removes Prometheus exporter package reference; formatting changes. |
| src/Exceptionless.Web/Controllers/OrganizationController.cs | Updates invoice and plan-change flows for Stripe.net 50.x (prices/discounts/payment methods). |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte | Navigates to org billing page with changePlan=true. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte | Navigates to org billing page with changePlan=true. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte | Switches to new billing ChangePlanDialog and passes organization data. |
| src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte | Deletes placeholder dialog (replaced by billing feature). |
| src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts | Adds plans query + change-plan mutation/query keys. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts | Adds Stripe context + lazy loader utilities. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts | Adds Zod schema for change-plan form. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts | Adds billing types and form state types. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts | Adds billing feature barrel exports. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts | Adds FREE_PLAN_ID constant. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte | Adds Stripe Elements provider component. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte | Adds new Stripe-backed change-plan dialog UI. |
| src/Exceptionless.Web/ClientApp/package.json | Adds Stripe JS dependencies. |
| src/Exceptionless.Web/ClientApp/package-lock.json | Locks Stripe JS dependencies. |
| src/Exceptionless.Web/ApmExtensions.cs | Removes Prometheus exporter from OpenTelemetry metrics pipeline. |
| src/Exceptionless.Job/Program.cs | Removes Prometheus scraping endpoint middleware from job host. |
| src/Exceptionless.Job/Exceptionless.Job.csproj | Removes Prometheus exporter package reference; formatting changes. |
| src/Exceptionless.Core/Exceptionless.Core.csproj | Upgrades Stripe.net from 47.4.0 to 50.4.1; formatting changes. |
| .vscode/settings.json | Updates workspace editor/TypeScript SDK settings. |
| .github/workflows/build.yaml | Updates deploy job condition to allow dev deploys from a configured PR branch. |
Files not reviewed (1)
- src/Exceptionless.Web/ClientApp/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| form.setFieldValue('selectedPlanId', defaultPlanId ?? ''); | ||
| } | ||
| }); | ||
|
|
| function formatPrice(price: number): string { | ||
| return price === 0 ? 'Free' : `$${price}/month`; | ||
| } | ||
|
|
|
|
||
| app.UseHealthChecks("/health", new HealthCheckOptions | ||
| { | ||
| Predicate = hcr => !String.IsNullOrEmpty(jobOptions.JobName) ? hcr.Tags.Contains(jobOptions.JobName) : hcr.Tags.Contains("AllJobs") |
| // Create subscription separately (Plan on CustomerCreateOptions is deprecated) | ||
| var subscriptionOptions = new SubscriptionCreateOptions | ||
| { | ||
| Customer = customer.Id, | ||
| Items = [new SubscriptionItemOptions { Price = planId }] |
| var subscriptionList = await subscriptionService.ListAsync(new SubscriptionListOptions { Customer = organization.StripeCustomerId }); | ||
| var subscription = subscriptionList.FirstOrDefault(s => !s.CanceledAt.HasValue); | ||
| if (subscription is not null) | ||
| { | ||
| update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Plan = planId }); | ||
| update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Price = planId }); | ||
| await subscriptionService.UpdateAsync(subscription.Id, update); | ||
| } | ||
| else | ||
| { | ||
| create.Items.Add(new SubscriptionItemOptions { Plan = planId }); | ||
| create.Items.Add(new SubscriptionItemOptions { Price = planId }); | ||
| await subscriptionService.CreateAsync(create); |
| <StripeProvider | ||
| appearance={{ | ||
| theme: 'stripe', | ||
| variables: { | ||
| borderRadius: '6px' | ||
| } | ||
| }} | ||
| onElementsChange={(elements) => { | ||
| stripeElements = elements; | ||
| }} | ||
| onload={(loadedStripe) => { | ||
| stripe = loadedStripe; | ||
| }} | ||
| > | ||
| <div class="rounded-lg border p-4"> | ||
| <PaymentElement /> | ||
| </div> | ||
| </StripeProvider> |
| } | ||
| }); | ||
| app.UseStatusCodePages(); | ||
|
|
||
| app.UseOpenTelemetryPrometheusScrapingEndpoint(); | ||
|
|
||
| app.UseHealthChecks("/health", new HealthCheckOptions | ||
| { | ||
| Predicate = hcr => hcr.Tags.Contains("Critical") || (options.RunJobsInProcess && hcr.Tags.Contains("AllJobs")) |
| services.AddOpenTelemetry().WithMetrics(b => | ||
| { | ||
| b.SetResourceBuilder(resourceBuilder); | ||
|
|
||
| b.AddHttpClientInstrumentation(); | ||
| b.AddAspNetCoreInstrumentation(); | ||
| b.AddMeter("Exceptionless", "Foundatio"); | ||
| b.AddMeter("System.Runtime"); | ||
| b.AddRuntimeInstrumentation(); | ||
| b.AddProcessInstrumentation(); | ||
|
|
||
| if (config.Console) | ||
| b.AddConsoleExporter((_, metricReaderOptions) => | ||
| { | ||
| // The ConsoleMetricExporter defaults to a manual collect cycle. | ||
| // This configuration causes metrics to be exported to stdout on a 10s interval. | ||
| metricReaderOptions.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 10000; | ||
| }); | ||
|
|
||
| b.AddPrometheusExporter(); | ||
|
|
||
| if (config.EnableExporter) | ||
| b.AddOtlpExporter(); | ||
| }); |
| var priceService = new PriceService(client); | ||
| var priceCache = new Dictionary<string, Stripe.Price>(StringComparer.Ordinal); | ||
| foreach (var line in stripeInvoice.Lines.Data) | ||
| { | ||
| var item = new InvoiceLineItem { Amount = line.Amount / 100.0m, Description = line.Description }; | ||
| if (line.Plan is not null) | ||
|
|
||
| // 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; | ||
| if (!String.IsNullOrEmpty(priceId)) | ||
| { | ||
| string planName = line.Plan.Nickname ?? _billingManager.GetBillingPlan(line.Plan.Id)?.Name ?? line.Plan.Id; | ||
| item.Description = $"Exceptionless - {planName} Plan ({(line.Plan.Amount / 100.0):c}/{line.Plan.Interval})"; | ||
| try | ||
| { | ||
| if (!priceCache.TryGetValue(priceId, out var price)) | ||
| { | ||
| price = await priceService.GetAsync(priceId); | ||
| priceCache[priceId] = price; | ||
| } |
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}", id); | ||
| _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}. Exception: {Message}", id, ex.Message); |
There was a problem hiding this comment.
Pull request overview
This PR introduces a next-generation Stripe-based billing flow across the Svelte app and the organization API, updating both frontend plan-change UX and backend Stripe integration to newer Stripe.net APIs.
Changes:
- Adds a new Billing feature module (Stripe context/provider + ChangePlanDialog) and wires it into the organization billing page and usage pages.
- Updates backend Stripe integration (Stripe.net 50.x) for invoice/payment/plan handling and updates invoice mapping/tests accordingly.
- Updates build/ops tooling (Docker MinVer build arg, workflow tweaks) and removes Prometheus OpenTelemetry exporter/scraping.
Reviewed changes
Copilot reviewed 26 out of 27 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs | Updates tests to reflect invoice Status -> paid mapping. |
| tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs | Simplifies test comments (no behavior change). |
| src/Exceptionless.Web/Startup.cs | Removes OTEL Prometheus scraping endpoint middleware. |
| src/Exceptionless.Web/Mapping/InvoiceMapper.cs | Maps invoice “paid” from Stripe Status instead of Paid. |
| src/Exceptionless.Web/Exceptionless.Web.csproj | Removes Prometheus exporter package + formatting changes. |
| src/Exceptionless.Web/Controllers/OrganizationController.cs | Updates Stripe invoice/plan change logic for Stripe.net 50.x and PaymentMethod support. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte | Implements navigation to billing page on “Change plan”. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte | Implements navigation to billing page on “Change plan”. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte | Switches to new billing ChangePlanDialog component and passes organization data. |
| src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte | Removes placeholder dialog from organizations feature. |
| src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts | Adds plans query + change-plan mutation and query keys. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts | Adds Stripe lazy-loader + context hooks. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts | Adds Zod schema for change-plan form. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts | Adds billing feature types and re-exports generated API types. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts | Public entrypoint for billing feature exports. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts | Adds FREE plan id constant. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte | Adds provider wrapping Stripe Elements + loading/error UI. |
| src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte | Adds full Stripe Elements-based plan change dialog. |
| src/Exceptionless.Web/ClientApp/package.json | Adds Stripe JS dependencies. |
| src/Exceptionless.Web/ClientApp/package-lock.json | Locks Stripe JS dependencies. |
| src/Exceptionless.Web/ApmExtensions.cs | Removes Prometheus exporter wiring. |
| src/Exceptionless.Job/Program.cs | Removes OTEL Prometheus scraping endpoint middleware. |
| src/Exceptionless.Job/Exceptionless.Job.csproj | Removes Prometheus exporter package + formatting changes. |
| src/Exceptionless.Core/Exceptionless.Core.csproj | Upgrades Stripe.net to 50.4.1. |
| Dockerfile | Adds MinVerVersionOverride build arg passthrough. |
| .vscode/settings.json | Disables format-on-save and changes TypeScript SDK path setting key. |
| .github/workflows/build.yaml | Ensures checkout uses correct ref and passes MinVer build arg into Docker builds; expands deploy conditions. |
Files not reviewed (1)
- src/Exceptionless.Web/ClientApp/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "svelte": "html" | ||
| }, | ||
| "typescript.tsdk": "\\src\\Exceptionless.Web\\ClientApp\\node_modules\\typescript\\lib", | ||
| "js/ts.tsdk.path": "src/Exceptionless.Web/ClientApp/node_modules/typescript/lib", |
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}", id); | ||
| _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}. Exception: {Message}", id, ex.Message); |
| services.AddOpenTelemetry().WithMetrics(b => | ||
| { | ||
| b.SetResourceBuilder(resourceBuilder); | ||
|
|
||
| b.AddHttpClientInstrumentation(); | ||
| b.AddAspNetCoreInstrumentation(); | ||
| b.AddMeter("Exceptionless", "Foundatio"); | ||
| b.AddMeter("System.Runtime"); | ||
| b.AddRuntimeInstrumentation(); | ||
| b.AddProcessInstrumentation(); | ||
|
|
||
| if (config.Console) | ||
| b.AddConsoleExporter((_, metricReaderOptions) => | ||
| { | ||
| // The ConsoleMetricExporter defaults to a manual collect cycle. | ||
| // This configuration causes metrics to be exported to stdout on a 10s interval. | ||
| metricReaderOptions.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 10000; | ||
| }); | ||
|
|
||
| b.AddPrometheusExporter(); | ||
|
|
||
| if (config.EnableExporter) | ||
| b.AddOtlpExporter(); | ||
| }); |
| const response = await client.postJSON<ChangePlanResult>(`organizations/${request.route.organizationId}/change-plan`, undefined, { | ||
| params | ||
| }); |
| function formatPrice(price: number): string { | ||
| return price === 0 ? 'Free' : `$${price}/month`; | ||
| } | ||
|
|
| export interface ChangePlanParams extends Record<string, string | undefined> { | ||
| /** 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; | ||
| } |
Stripe v9 requires either clientSecret OR mode when calling stripe.elements(). The previous code passed neither when no clientSecret was provided, causing PaymentElement to fail to render. Uses a discriminated union Props type to enforce mutual exclusivity of clientSecret and mode at compile time, matching Stripe's own type constraints. Defaults to mode='setup' for collecting payment methods for future use.
Fix two issues preventing the Stripe PaymentElement from rendering in the Change Plan dialog: 1. Missing currency in Stripe Elements options: Stripe.js v9+ requires a currency string when using mode: 'setup'. Added currency: 'usd' to the elements creation options. 2. svelte-stripe Svelte 5 incompatibility: svelte-stripe's <Elements> and <PaymentElement> components use $bindable()/onMount patterns that don't trigger Svelte 5 template re-renders from async callbacks. Replaced both components with an imperative approach that loads Stripe, creates elements, and mounts the PaymentElement directly to the DOM via onMount, bypassing Svelte's reactive template system entirely. Additionally fixed the Change Plan dialog not being scrollable by adding max-h-[90vh] and overflow-y-auto to the Dialog.Content wrapper. Changes: - stripe-provider.svelte: Rewritten to imperatively mount PaymentElement without svelte-stripe components or Svelte reactive conditionals - change-plan-dialog.svelte: Removed svelte-stripe PaymentElement import, updated StripeProvider usage (no longer takes children), added dialog scroll constraints, removed debug markup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- 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.
Summary
ChangePlanDialogandStripeProvidergetBilling,changePlan,getInvoicesChanges