Skip to content
Open
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
47 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
2639dc2
Add integration tests for organization billing endpoints
niemyjski Apr 23, 2026
13c974e
Replace API-driven premium filter detection with client-side check
niemyjski Apr 23, 2026
16d2bc2
Refactor 426 upgrade handling: named callbacks, async retry, derived …
niemyjski Apr 23, 2026
ee12459
run format
niemyjski Apr 23, 2026
dc6f4cc
Address PR feedback: surface ProblemDetails messages in dialogs
niemyjski Apr 23, 2026
7e2ed1a
Add integration tests for ChangePlanAsync and CanDownGradeAsync
niemyjski Apr 23, 2026
84f3dbf
Sanitize invoice URL and add AAA comments to tests
niemyjski Apr 23, 2026
9e060c6
fix: remove required keyword from ChangePlanRequest.PlanId
niemyjski Apr 23, 2026
a4cb3f4
fix: handle empty subscription items and sanitize Stripe errors
niemyjski Apr 23, 2026
b81d6f3
fix: address review feedback on billing changes
niemyjski Apr 23, 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
}
]
}
}
Comment on lines +1 to +11
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
203 changes: 203 additions & 0 deletions docs/billing-stripe-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# 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)`
**Body** (JSON, preferred):

| 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 |
Comment on lines +59 to +62
Comment on lines +55 to +62

Legacy Angular clients may pass these as query string parameters instead.

**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**:

- `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

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**~~ — 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.

## 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 @@ -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.");
}
Expand Down
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