Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 .aspire/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"appHostPath": "../src/Exceptionless.AppHost/Exceptionless.AppHost.csproj"
}
}
11 changes: 11 additions & 0 deletions .github/hooks/follow-up.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
}
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "aspire",
"request": "launch",
"name": "Aspire",
"program": "${workspaceFolder}/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj"
"program": ""
},
Comment thread
niemyjski marked this conversation as resolved.
{
"name": "Web",
Expand Down
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
201 changes: 201 additions & 0 deletions docs/billing-stripe-integration.md
Original file line number Diff line number Diff line change
@@ -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 |

Comment thread
niemyjski marked this conversation as resolved.
**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

Comment thread
niemyjski marked this conversation as resolved.
**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 `<Elements>` / `<PaymentElement>` 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.
Comment thread
niemyjski marked this conversation as resolved.

## 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.
4 changes: 2 additions & 2 deletions 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="51.0.0" />
<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.1"
Expand All @@ -40,4 +40,4 @@
Include="..\..\..\..\Foundatio\Foundatio.Repositories\src\Foundatio.Repositories.Elasticsearch\Foundatio.Repositories.Elasticsearch.csproj"
Condition="'$(ReferenceFoundatioRepositoriesSource)' == 'true'" />
</ItemGroup>
</Project>
</Project>
2 changes: 1 addition & 1 deletion src/Exceptionless.Job/Exceptionless.Job.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@
<ItemGroup>
<Compile Include="..\Exceptionless.Web\ApmExtensions.cs" Link="ApmExtensions.cs" />
</ItemGroup>
</Project>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js
Original file line number Diff line number Diff line change
@@ -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'
};
10 changes: 10 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.

1 change: 1 addition & 0 deletions src/Exceptionless.Web/ClientApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"@exceptionless/fetchclient": "^0.44.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",
Expand Down
4 changes: 2 additions & 2 deletions src/Exceptionless.Web/ClientApp/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%);
Expand Down Expand Up @@ -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%);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script lang="ts">
import type { ViewOrganization } from '$features/organizations/models';
import type { BillingPlan } from '$lib/generated/api';

import { Button } from '$comp/ui/button';
import { accessToken } from '$features/auth/index.svelte';
import { queryKeys } from '$features/organizations/api.svelte';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import { untrack } from 'svelte';

import ChangePlanDialog from './change-plan-dialog.svelte';

interface Props {
initialCouponCode?: string;
initialCouponOpen?: boolean;
initialFormError?: string;
organization: ViewOrganization;
plans: BillingPlan[];
}

let { initialCouponCode, initialCouponOpen, initialFormError, organization, plans }: Props = $props();

accessToken.current = 'storybook-mock-token';

const hasPlans = untrack(() => plans.length > 0);

const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: hasPlans,
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
staleTime: Infinity
}
}
});

untrack(() => {
if (plans.length > 0) {
queryClient.setQueryData(queryKeys.plans(organization.id), plans);
}
});

let open = $state(true);

function handleClose() {
open = false;
}
</script>

<QueryClientProvider client={queryClient}>
<Button variant="outline" onclick={() => (open = true)}>Open dialog</Button>

{#if open}
<ChangePlanDialog onclose={handleClose} {organization} {initialCouponCode} {initialCouponOpen} {initialFormError} />
{/if}
</QueryClientProvider>
Loading
Loading