-
-
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 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 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| { | ||
| "appHostPath": "../src/Exceptionless.AppHost/Exceptionless.AppHost.csproj" | ||
| } | ||
| } |
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,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 | ||
| } | ||
| ] | ||
| } | ||
| } |
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
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
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,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 | | ||
|
|
||
|
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 | ||
|
|
||
|
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. | ||
|
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. | ||
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
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
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
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 |
|---|---|---|
| @@ -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' | ||
| }; |
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
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
58 changes: 58 additions & 0 deletions
58
...nless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.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,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> |
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.