diff --git a/.aspire/settings.json b/.aspire/settings.json index 68a4733ba2..52800c54eb 100644 --- a/.aspire/settings.json +++ b/.aspire/settings.json @@ -1,3 +1,3 @@ { "appHostPath": "../src/Exceptionless.AppHost/Exceptionless.AppHost.csproj" -} \ No newline at end of file +} diff --git a/.github/hooks/follow-up.json b/.github/hooks/follow-up.json new file mode 100644 index 0000000000..e421098bef --- /dev/null +++ b/.github/hooks/follow-up.json @@ -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 + } + ] + } +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 90a926b9f8..501508a7c1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "type": "aspire", "request": "launch", "name": "Aspire", - "program": "${workspaceFolder}/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj" + "program": "" }, { "name": "Web", diff --git a/.vscode/settings.json b/.vscode/settings.json index 2353435158..dcd5362e1d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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}", diff --git a/docs/billing-stripe-integration.md b/docs/billing-stripe-integration.md new file mode 100644 index 0000000000..d784d66a65 --- /dev/null +++ b/docs/billing-stripe-integration.md @@ -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 | + +**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 + +**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 `` / `` 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. + +## 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. diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index d24e70e376..844453e467 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -31,7 +31,7 @@ - + - \ No newline at end of file + diff --git a/src/Exceptionless.Job/Exceptionless.Job.csproj b/src/Exceptionless.Job/Exceptionless.Job.csproj index 138c2310da..59db7eafc8 100644 --- a/src/Exceptionless.Job/Exceptionless.Job.csproj +++ b/src/Exceptionless.Job/Exceptionless.Job.csproj @@ -32,4 +32,4 @@ - + \ No newline at end of file diff --git a/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js b/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js index cb5f7561c0..ab0ba22699 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js +++ b/src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js @@ -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) { diff --git a/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js b/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js index e3843c5d9a..f5a391f3ea 100644 --- a/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js +++ b/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js @@ -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' }; diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index 009ca86816..4c158ee38b 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -12,6 +12,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", @@ -1994,6 +1995,15 @@ "node": ">=14.17" } }, + "node_modules/@stripe/stripe-js": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.2.0.tgz", + "integrity": "sha512-YSzLC0t6VS9MDdPTynSMqU8IxrItFUjkDORALFT6sSMR/XZ5Vgm3RDp/Gk7z727MC4A9s4MFVel0gF0c7+kdrg==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 74ea8a3618..8cdfa3b1d4 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -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", diff --git a/src/Exceptionless.Web/ClientApp/src/app.css b/src/Exceptionless.Web/ClientApp/src/app.css index 8cd26aeb5c..ed44232e36 100644 --- a/src/Exceptionless.Web/ClientApp/src/app.css +++ b/src/Exceptionless.Web/ClientApp/src/app.css @@ -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%); @@ -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%); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte new file mode 100644 index 0000000000..d3d42d9fa0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-harness.svelte @@ -0,0 +1,58 @@ + + + + + + {#if open} + + {/if} + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts new file mode 100644 index 0000000000..50df70e260 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts @@ -0,0 +1,466 @@ +import type { ViewOrganization } from '$features/organizations/models'; +import type { BillingPlan } from '$lib/generated/api'; +import type { Meta, StoryObj } from '@storybook/sveltekit'; + +import Harness from './change-plan-dialog-harness.svelte'; + +const MOCK_PLANS: BillingPlan[] = [ + { + description: 'Free', + has_premium_features: false, + id: 'EX_FREE', + is_hidden: false, + max_events_per_month: 3000, + max_projects: 1, + max_users: 1, + name: 'Free', + price: 0, + retention_days: 3 + }, + { + description: 'Small ($15/month)', + has_premium_features: true, + id: 'EX_SMALL', + is_hidden: false, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + name: 'Small', + price: 15, + retention_days: 30 + }, + { + description: 'Small Yearly ($165/year - Save $15)', + has_premium_features: true, + id: 'EX_SMALL_YEARLY', + is_hidden: false, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + name: 'Small (Yearly)', + price: 165, + retention_days: 30 + }, + { + description: 'Medium ($49/month)', + has_premium_features: true, + id: 'EX_MEDIUM', + is_hidden: false, + max_events_per_month: 75000, + max_projects: 15, + max_users: 25, + name: 'Medium', + price: 49, + retention_days: 90 + }, + { + description: 'Medium Yearly ($539/year - Save $49)', + has_premium_features: true, + id: 'EX_MEDIUM_YEARLY', + is_hidden: false, + max_events_per_month: 75000, + max_projects: 15, + max_users: 25, + name: 'Medium (Yearly)', + price: 539, + retention_days: 90 + }, + { + description: 'Large ($99/month)', + has_premium_features: true, + id: 'EX_LARGE', + is_hidden: false, + max_events_per_month: 250000, + max_projects: -1, + max_users: -1, + name: 'Large', + price: 99, + retention_days: 180 + }, + { + description: 'Large Yearly ($1,089/year - Save $99)', + has_premium_features: true, + id: 'EX_LARGE_YEARLY', + is_hidden: false, + max_events_per_month: 250000, + max_projects: -1, + max_users: -1, + name: 'Large (Yearly)', + price: 1089, + retention_days: 180 + }, + { + description: 'Extra Large ($199/month)', + has_premium_features: true, + id: 'EX_XL', + is_hidden: false, + max_events_per_month: 1000000, + max_projects: -1, + max_users: -1, + name: 'Extra Large', + price: 199, + retention_days: 180 + }, + { + description: 'Extra Large Yearly ($2,189/year - Save $199)', + has_premium_features: true, + id: 'EX_XL_YEARLY', + is_hidden: false, + max_events_per_month: 1000000, + max_projects: -1, + max_users: -1, + name: 'Extra Large (Yearly)', + price: 2189, + retention_days: 180 + }, + { + description: 'Enterprise ($499/month)', + has_premium_features: true, + id: 'EX_ENT', + is_hidden: false, + max_events_per_month: 3000000, + max_projects: -1, + max_users: -1, + name: 'Enterprise', + price: 499, + retention_days: 180 + }, + { + description: 'Enterprise Yearly ($5,489/year - Save $499)', + has_premium_features: true, + id: 'EX_ENT_YEARLY', + is_hidden: false, + max_events_per_month: 3000000, + max_projects: -1, + max_users: -1, + name: 'Enterprise (Yearly)', + price: 5489, + retention_days: 180 + } +]; + +/** Helper to build a ViewOrganization with sensible defaults. */ +function makeOrg(overrides: Partial = {}): ViewOrganization { + return { + billing_change_date: null, + billing_changed_by_user_id: null, + billing_price: 0, + billing_status: 0 as never, + bonus_events_per_month: 0, + bonus_expiration: null, + card_last4: null, + created_utc: '2024-01-15T00:00:00Z', + data: null, + event_count: 427, + has_premium_features: false, + id: '507f1f77bcf86cd799439011', + invites: [], + is_over_monthly_limit: false, + is_over_request_limit: false, + is_suspended: false, + is_throttled: false, + max_events_per_month: 3000, + max_projects: 1, + max_users: 1, + name: 'Acme Corp', + plan_description: 'Free plan', + plan_id: 'EX_FREE', + plan_name: 'Free', + project_count: 1, + retention_days: 3, + stack_count: 12, + subscribe_date: null, + suspension_code: null, + suspension_date: null, + suspension_notes: null, + updated_utc: '2025-04-10T00:00:00Z', + usage: [], + usage_hours: [], + ...overrides + }; +} + +const meta = { + component: Harness, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + title: 'Features/Billing/ChangePlanDialog' +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** Plans failed to load — shows error message. */ +export const ErrorLoadingPlans: Story = { + args: { + organization: makeOrg(), + plans: [] as BillingPlan[] + }, + name: 'Error loading plans' +}; + +/** Free-plan org, dialog open — upsells to the first paid tier (Small). */ +export const Default: Story = { + args: { + organization: makeOrg(), + plans: MOCK_PLANS + } +}; + +/** Paid Small monthly org selects a different paid plan (Large monthly). */ +export const ChangePlan: Story = { + args: { + organization: makeOrg({ + billing_price: 15, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + plan_id: 'EX_SMALL', + plan_name: 'Small', + retention_days: 30, + subscribe_date: '2024-06-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Change plan' +}; + +/** Small monthly org switches to yearly billing (same tier). */ +export const IntervalSwitch: Story = { + args: { + organization: makeOrg({ + billing_price: 15, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + plan_id: 'EX_SMALL', + plan_name: 'Small', + retention_days: 30, + subscribe_date: '2024-06-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Interval switch (Small yearly)' +}; + +/** Paid org keeps current plan but wants to update their payment method. */ +export const UpdateCardOnly: Story = { + args: { + organization: makeOrg({ + billing_price: 49, + card_last4: '1234', + has_premium_features: true, + max_events_per_month: 75000, + max_projects: 15, + max_users: 25, + plan_id: 'EX_MEDIUM', + plan_name: 'Medium', + retention_days: 90, + subscribe_date: '2024-03-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Update card only' +}; + +/** Paid org keeps current plan and wants to apply a coupon. */ +export const ApplyCouponOnly: Story = { + args: { + organization: makeOrg({ + billing_price: 99, + card_last4: '5678', + has_premium_features: true, + max_events_per_month: 250000, + max_projects: -1, + max_users: -1, + plan_id: 'EX_LARGE', + plan_name: 'Large', + retention_days: 180, + subscribe_date: '2024-01-15T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Apply coupon only' +}; + +/** Paid org changes plan, updates card, and applies a coupon — all at once. */ +export const PlanCardCoupon: Story = { + args: { + organization: makeOrg({ + billing_price: 15, + card_last4: '9999', + has_premium_features: true, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + plan_id: 'EX_SMALL', + plan_name: 'Small', + retention_days: 30, + subscribe_date: '2024-09-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Plan + card + coupon' +}; + +/** Free-plan org upgrading to a paid plan for the first time (no card on file). */ +export const FirstTimePaid: Story = { + args: { + organization: makeOrg({ + billing_price: 0, + card_last4: null, + plan_id: 'EX_FREE', + plan_name: 'Free' + }), + plans: MOCK_PLANS + }, + name: 'First-time paid' +}; + +/** Paid org selecting the Free plan to downgrade. */ +export const DowngradeToFree: Story = { + args: { + organization: makeOrg({ + billing_price: 199, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 1000000, + max_projects: -1, + max_users: -1, + plan_id: 'EX_XL', + plan_name: 'Extra Large', + retention_days: 180, + subscribe_date: '2023-11-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Downgrade to Free' +}; + +/** Coupon input expanded — user clicked "Have a coupon code?". */ +export const CouponInputOpen: Story = { + args: { + initialCouponOpen: true, + organization: makeOrg({ + billing_price: 49, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 75000, + max_projects: 15, + max_users: 25, + plan_id: 'EX_MEDIUM', + plan_name: 'Medium', + retention_days: 90, + subscribe_date: '2024-03-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Coupon input open' +}; + +/** Coupon code applied — shows success alert with code and "Remove" action. */ +export const CouponApplied: Story = { + args: { + initialCouponCode: 'SAVE20', + organization: makeOrg({ + billing_price: 49, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 75000, + max_projects: 15, + max_users: 25, + plan_id: 'EX_MEDIUM', + plan_name: 'Medium', + retention_days: 90, + subscribe_date: '2024-03-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Coupon applied' +}; + +/** Invalid coupon — backend returned an error for the submitted coupon code. */ +export const ErrorInvalidCoupon: Story = { + args: { + initialCouponCode: 'EXPIRED99', + initialFormError: "No such coupon: 'EXPIRED99'. Please check the code and try again.", + organization: makeOrg({ + billing_price: 15, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + plan_id: 'EX_SMALL', + plan_name: 'Small', + retention_days: 30, + subscribe_date: '2024-06-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Error: invalid coupon' +}; + +/** Payment failed — Stripe rejected the card during plan change. */ +export const ErrorPaymentFailed: Story = { + args: { + initialFormError: 'Your card was declined. Please try a different payment method.', + organization: makeOrg({ + billing_price: 0, + card_last4: null, + plan_id: 'EX_FREE', + plan_name: 'Free' + }), + plans: MOCK_PLANS + }, + name: 'Error: payment failed' +}; + +/** Downgrade blocked — too many users or projects for the target plan. */ +export const ErrorDowngradeBlocked: Story = { + args: { + initialFormError: 'Please remove 3 users and try again.', + organization: makeOrg({ + billing_price: 199, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 1000000, + max_projects: -1, + max_users: -1, + plan_id: 'EX_XL', + plan_name: 'Extra Large', + retention_days: 180, + subscribe_date: '2023-11-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Error: downgrade blocked' +}; + +/** Generic plan change failure — API returned a non-success result (network error, server error, etc.). */ +export const ErrorPlanChangeFailed: Story = { + args: { + initialFormError: 'An unexpected error occurred while changing your plan. Please try again or contact support.', + organization: makeOrg({ + billing_price: 15, + card_last4: '4242', + has_premium_features: true, + max_events_per_month: 15000, + max_projects: 5, + max_users: 10, + plan_id: 'EX_SMALL', + plan_name: 'Small', + retention_days: 30, + subscribe_date: '2024-06-01T00:00:00Z' + }), + plans: MOCK_PLANS + }, + name: 'Error: plan change failed' +}; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte new file mode 100644 index 0000000000..e0d33be202 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte @@ -0,0 +1,849 @@ + + + { + if (!v) onclose(); + }} +> + + + + + Manage subscription + + + {organization.name} + · + {currentSubtitle} + + + + {#if !isStripeEnabled()} +
+ +
+ {:else if plansQuery.isLoading} +
+ + +
+ {:else if plansQuery.error} +
+ +
+ {:else if plansQuery.data} +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > +
+
+
+
Plan
+ All changes prorated +
+ + setInterval(value as 'month' | 'year')} class="w-full"> + + Monthly + + Yearly + {#if yearlySavingsLabel} + {yearlySavingsLabel} + {/if} + + + + +
+ {#each tiers as tier, tierIdx (tier.id)} + {@const planForInterval = interval === 'year' && tier.yearly ? tier.yearly : tier.monthly} + {@const price = tierPrice(tier, interval)} + {@const isCurrent = tier.id === currentTierId && (interval === currentInterval || !tier.yearly)} + {@const isSelected = tier.id === selectedTierId} + + {/each} + + +
+
+ + {#if isPaidPlan} +
+
+
Payment method
+ {#if hasExistingCard && paymentExpanded} + + {/if} +
+ + {#if hasExistingCard && !paymentExpanded} +
+
+ + + Paying with + ···· {organization.card_last4} + +
+ +
+ {:else} +
+ { + stripeElements = elements; + }} + onload={(loadedStripe) => { + stripe = loadedStripe; + }} + /> +
+ {/if} +
+ {/if} + + {#if !isFreeSelected} +
+
+
Coupon
+ {#if couponOpen && !couponApplied} + + {/if} +
+ + {#if couponApplied} + + + + {couponApplied} + — will be applied + + + + + {:else if couponOpen} +
+ { + couponError = null; + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onCouponApply(); + } + }} + /> + +
+ {#if couponError} + + {/if} + {:else} + + {/if} +
+ {/if} +
+ + + state.errors}> + {#snippet children(errors)} + + {/snippet} + + + {#if initialFormError} + + {/if} + +
+ {#if !anyDirty} + No changes yet + {/if} + {#if planDirty} + {@const intervalChanged = intervalOf(organization.plan_id) !== intervalOf(selectedPlanId)} + {@const includeInt = intervalChanged || organization.plan_id === FREE_PLAN_ID || isFreeSelected} +
+ Plan + + {#if isFreeSelected} + {planLabel(organization.plan_id, { includeInterval: true })} + + Free + · immediate, prorated credit + {:else if organization.plan_id === FREE_PLAN_ID} + Start {planLabel(selectedPlanId, { includeInterval: true })} + {#if selectedPlan}· ${formatPrice(selectedPlan.price)}{interval === 'year' ? '/yr' : '/mo'}{/if} + {:else} + {planLabel(organization.plan_id, { includeInterval: includeInt })} + + {planLabel(selectedPlanId, { includeInterval: includeInt })} + {#if selectedPlan}· ${formatPrice(selectedPlan.price)}{interval === 'year' ? '/yr' : '/mo'} · prorated today{/if} + {/if} + +
+ {/if} + {#if paymentDirty} +
+ Payment + + ···· {organization.card_last4} + + new payment method + +
+ {/if} + {#if couponDirty} +
+ Coupon + + {couponApplied} applied + +
+ {/if} +
+ + state.isSubmitting}> + {#snippet children(isSubmitting)} +
+ + +
+ {/snippet} +
+
+
+ {/if} +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte new file mode 100644 index 0000000000..68adf277d4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte @@ -0,0 +1,117 @@ + + +
+ +
+ +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte new file mode 100644 index 0000000000..094f954c7f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte @@ -0,0 +1,29 @@ + + + + + + Upgrade Plan + {upgradeRequiredDialog.message} + + + Cancel + Upgrade Plan + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts new file mode 100644 index 0000000000..34cdb37935 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/constants.ts @@ -0,0 +1 @@ +export const FREE_PLAN_ID = 'EX_FREE'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts new file mode 100644 index 0000000000..2f62715317 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts @@ -0,0 +1,20 @@ +/** + * Billing feature module - Stripe integration for plan management. + */ + +// Components +export { default as ChangePlanDialog } from './components/change-plan-dialog.svelte'; + +export { default as StripeProvider } from './components/stripe-provider.svelte'; + +// Upgrade required handling +export { default as UpgradeRequiredDialog } from './components/upgrade-required-dialog.svelte'; + +// Constants +export { FREE_PLAN_ID } from './constants'; +// Models +export type { BillingPlan, CardMode, ChangePlanFormState, ChangePlanRequest, ChangePlanResult } from './models'; + +// Context and hooks +export { getStripePublishableKey, isStripeEnabled, loadStripeOnce, setStripeContext, type StripeContext, tryUseStripe, useStripe } from './stripe.svelte'; +export { handleUpgradeRequired, isUpgradeRequired } from './upgrade-required.svelte'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts new file mode 100644 index 0000000000..de988551bf --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/models.ts @@ -0,0 +1,19 @@ +/** + * Billing models - re-exports from generated types plus billing-specific types. + */ + +export type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/generated/api'; + +/** + * Card mode for the payment form. + */ +export type CardMode = 'existing' | 'new'; + +/** + * State for the change plan form. + */ +export interface ChangePlanFormState { + cardMode: CardMode; + couponId: string; + selectedPlanId: null | string; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts new file mode 100644 index 0000000000..8782768fb2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/schemas.ts @@ -0,0 +1,9 @@ +import { type infer as Infer, object, string, enum as zodEnum } from 'zod'; + +export const ChangePlanSchema = object({ + cardMode: zodEnum(['existing', 'new']), + couponId: string(), + selectedPlanId: string().min(1, 'Please select a plan.') +}); + +export type ChangePlanFormData = Infer; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts new file mode 100644 index 0000000000..542b3756c5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts @@ -0,0 +1,94 @@ +/** + * Stripe context and hooks for billing integration. + * + * Provides lazy-loaded Stripe instance with context-based access following + * the svelte-intercom provider pattern. + */ + +import type { Stripe, StripeElements } from '@stripe/stripe-js'; + +import { env } from '$env/dynamic/public'; +import { loadStripe } from '@stripe/stripe-js'; +import { getContext, setContext } from 'svelte'; + +const STRIPE_CONTEXT_KEY = Symbol('stripe-context'); + +export interface StripeContext { + readonly elements: null | StripeElements; + readonly error: null | string; + readonly isLoading: boolean; + readonly stripe: null | Stripe; +} + +/** + * Get the Stripe publishable key from environment. + */ +export function getStripePublishableKey(): string | undefined { + return env.PUBLIC_STRIPE_PUBLISHABLE_KEY; +} + +/** + * Check if Stripe is enabled via environment configuration. + */ +export function isStripeEnabled(): boolean { + return !!env.PUBLIC_STRIPE_PUBLISHABLE_KEY; +} + +// Singleton to prevent multiple Stripe loads +let _stripePromise: null | Promise = null; +let _stripeInstance: null | Stripe = null; + +/** + * Load Stripe instance lazily. Returns cached instance if already loaded. + * Resets on failure so subsequent calls can retry (e.g. after a transient network error). + */ +export async function loadStripeOnce(): Promise { + if (_stripeInstance) { + return _stripeInstance; + } + + if (!isStripeEnabled()) { + return null; + } + + if (!_stripePromise) { + _stripePromise = loadStripe(env.PUBLIC_STRIPE_PUBLISHABLE_KEY!); + } + + try { + _stripeInstance = await _stripePromise; + return _stripeInstance; + } catch (error: unknown) { + // Reset so the next call can retry instead of re-awaiting the rejected promise + _stripePromise = null; + _stripeInstance = null; + throw error; + } +} + +/** + * Set the Stripe context. Called by StripeProvider. + */ +export function setStripeContext(ctx: StripeContext): void { + setContext(STRIPE_CONTEXT_KEY, ctx); +} + +/** + * Try to get the Stripe context without throwing. + * Returns null if not within a StripeProvider. + */ +export function tryUseStripe(): null | StripeContext { + return getContext(STRIPE_CONTEXT_KEY) ?? null; +} + +/** + * Get the Stripe context. Must be called within a StripeProvider. + * @throws Error if called outside of StripeProvider + */ +export function useStripe(): StripeContext { + const ctx = getContext(STRIPE_CONTEXT_KEY); + if (!ctx) { + throw new Error('useStripe() must be called within a StripeProvider component'); + } + return ctx; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts new file mode 100644 index 0000000000..99d8bd56c6 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts @@ -0,0 +1,54 @@ +import { ProblemDetails } from '@exceptionless/fetchclient'; + +interface UpgradeRequiredState { + message: string; + open: boolean; + organizationId: string | undefined; +} + +const state: UpgradeRequiredState = $state({ + message: '', + open: false, + organizationId: undefined +}); + +export const upgradeRequiredDialog = { + get message() { + return state.message; + }, + get open() { + return state.open; + }, + set open(value: boolean) { + state.open = value; + }, + get organizationId() { + return state.organizationId; + } +}; + +/** + * Handles a 426 Upgrade Required response by opening a confirmation dialog + * matching the legacy UI behavior: shows the backend message with + * "Upgrade Plan" and "Cancel" buttons. + * + * Returns true if the error was a 426 and was handled, false otherwise. + */ +export function handleUpgradeRequired(error: unknown, organizationId: string | undefined): boolean { + if (!isUpgradeRequired(error)) { + return false; + } + + state.message = error.title || 'Please upgrade your plan to continue.'; + state.organizationId = organizationId; + state.open = true; + + return true; +} + +/** + * Checks if a ProblemDetails error represents a 426 Upgrade Required response. + */ +export function isUpgradeRequired(error: unknown): error is ProblemDetails { + return error instanceof ProblemDetails && error.status === 426; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts index b3be2e34c2..70044f9bdf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts @@ -1,4 +1,5 @@ import type { WebSocketMessageValue } from '$features/websockets/models'; +import type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/generated/api'; import type { QueryClient } from '@tanstack/svelte-query'; import { accessToken } from '$features/auth/index.svelte'; @@ -22,12 +23,14 @@ export async function invalidateOrganizationQueries(queryClient: QueryClient, me export const queryKeys = { adminSearch: (params: GetAdminSearchOrganizationsParams) => [...queryKeys.list(params.mode), 'admin', { ...params }] as const, + changePlan: (id: string | undefined) => [...queryKeys.type, id, 'change-plan'] as const, deleteOrganization: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const, id: (id: string | undefined, mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, id, { mode }] as const) : ([...queryKeys.type, id] as const)), ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const, invoice: (id: string | undefined) => [...queryKeys.type, 'invoice', id] as const, invoices: (id: string | undefined) => [...queryKeys.type, id, 'invoices'] as const, list: (mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, 'list', { mode }] as const) : ([...queryKeys.type, 'list'] as const)), + plans: (id: string | undefined) => [...queryKeys.type, id, 'plans'] as const, postOrganization: () => [...queryKeys.type, 'post-organization'] as const, setBonusOrganization: (id: string | undefined) => [...queryKeys.type, id, 'set-bonus'] as const, suspendOrganization: (id: string | undefined) => [...queryKeys.type, id, 'suspend'] as const, @@ -41,6 +44,12 @@ export interface AddOrganizationUserRequest { }; } +export interface ChangePlanMutationRequest { + route: { + organizationId: string; + }; +} + export interface DeleteOrganizationRequest { route: { ids: string[]; @@ -110,6 +119,12 @@ export interface GetOrganizationsRequest { params?: GetOrganizationsParams; } +export interface GetPlansRequest { + route: { + organizationId: string; + }; +} + export interface PatchOrganizationRequest { route: { id: string; @@ -151,6 +166,31 @@ export function addOrganizationUser(request: AddOrganizationUserRequest) { })); } +/** + * Mutation to change an organization's billing plan. + */ +export function changePlanMutation(request: ChangePlanMutationRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + mutationFn: async (params: ChangePlanRequest) => { + const client = useFetchClient(); + const response = await client.postJSON(`organizations/${request.route.organizationId}/change-plan`, params); + + return response.data!; + }, + mutationKey: queryKeys.changePlan(request.route.organizationId), + onSuccess: () => { + // Invalidate organization data to reflect new plan + // WebSocket OrganizationChanged also fires, but we invalidate here for immediate feedback + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, undefined) }); + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, 'stats') }); + queryClient.invalidateQueries({ queryKey: queryKeys.list(undefined) }); + } + })); +} + export function deleteOrganization(request: DeleteOrganizationRequest) { const queryClient = useQueryClient(); @@ -325,6 +365,24 @@ export function getOrganizationsQuery(request: GetOrganizationsRequest) { })); } +/** + * Query to fetch available billing plans for an organization. + */ +export function getPlansQuery(request: GetPlansRequest) { + return createQuery(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON(`organizations/${request.route.organizationId}/plans`, { + signal + }); + + return response.data!; + }, + queryKey: queryKeys.plans(request.route.organizationId) + })); +} + export function patchOrganization(request: PatchOrganizationRequest) { const queryClient = useQueryClient(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte deleted file mode 100644 index 4a172c69db..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/change-plan-dialog.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - Change Plan - -

We're still working on this feature in the new app. In the meantime, you can update your plan in our previous app.

-
-
- - Close - OK - -
-
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte index f1e9d3b55c..8e41e69c3d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte @@ -3,6 +3,7 @@ import { resolve } from '$app/paths'; import Button from '$comp/ui/button/button.svelte'; import * as DropdownMenu from '$comp/ui/dropdown-menu'; + import { handleUpgradeRequired } from '$features/billing'; import Reference from '@lucide/svelte/icons/link-2'; import Settings from '@lucide/svelte/icons/settings'; import Delete from '@lucide/svelte/icons/trash'; @@ -73,11 +74,10 @@ } if (response.status === 426) { - toast.error( - 'Promote to External is a premium feature used to promote an error stack to an external system. Please upgrade your plan to enable this feature.' + handleUpgradeRequired( + { status: 426, title: 'Promote to External is a premium feature. Please upgrade your plan to enable this feature.' } as never, + stack.organization_id ); - //await confirmUpgradePlan(message, tack.organization_id); - //await promoteToExternal(); return; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 856890de12..965391b22a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -38,6 +38,13 @@ export interface ChangePasswordModel { password: string; } +export interface ChangePlanRequest { + plan_id: string; + stripe_token?: null | string; + last4?: null | string; + coupon_id?: null | string; +} + export interface ChangePlanResult { success: boolean; message?: null | string; @@ -125,9 +132,9 @@ export interface NewProject { export interface NewToken { /** @pattern ^[a-fA-F0-9]{24}$ */ - organization_id?: null | string; + organization_id: string; /** @pattern ^[a-fA-F0-9]{24}$ */ - project_id?: null | string; + project_id: string; /** @pattern ^[a-fA-F0-9]{24}$ */ default_project_id?: null | string; scopes: string[]; @@ -196,7 +203,7 @@ export interface PersistentEvent { */ created_utc: string; /** Used to store primitive data type custom data values for searching the event. */ - idx: Record; + idx?: null | Record; /** The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types. */ type?: null | string; /** The event source (ie. machine name, log name, feature name). */ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 66e65ceef1..3f66a51307 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -61,6 +61,17 @@ export type ChangePasswordModelFormData = Infer< typeof ChangePasswordModelSchema >; +export const ChangePlanRequestSchema = object({ + plan_id: string().min(1, "Plan id is required"), + stripe_token: string() + .min(1, "Stripe token is required") + .nullable() + .optional(), + last4: string().min(1, '"last4" is required').nullable().optional(), + coupon_id: string().min(1, "Coupon id is required").nullable().optional(), +}); +export type ChangePlanRequestFormData = Infer; + export const ChangePlanResultSchema = object({ success: boolean(), message: string().min(1, "Message is required").nullable().optional(), @@ -80,8 +91,8 @@ export const CountResultSchema = object({ aggregations: record( string(), lazy(() => IAggregateSchema), - ).optional(), - data: record(string(), unknown()).nullable().optional(), + ), + data: record(string(), unknown()).nullable(), }); export type CountResultFormData = Infer; @@ -164,14 +175,10 @@ export type NewProjectFormData = Infer; export const NewTokenSchema = object({ organization_id: string() .length(24, "Organization id must be exactly 24 characters") - .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format") - .nullable() - .optional(), + .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format"), project_id: string() .length(24, "Project id must be exactly 24 characters") - .regex(/^[a-fA-F0-9]{24}$/, "Project id has invalid format") - .nullable() - .optional(), + .regex(/^[a-fA-F0-9]{24}$/, "Project id has invalid format"), default_project_id: string() .length(24, "Default project id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Default project id has invalid format") @@ -243,7 +250,7 @@ export const PersistentEventSchema = object({ .regex(/^[a-fA-F0-9]{24}$/, "Stack id has invalid format"), is_first_occurrence: boolean(), created_utc: iso.datetime(), - idx: record(string(), unknown()), + idx: record(string(), unknown()).nullable().optional(), type: string() .min(1, "Type is required") .max(100, "Type must be at most 100 characters") diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 66c40f2fdb..27a921ad54 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -8,6 +8,7 @@ import { env } from '$env/dynamic/public'; import { getIntercomTokenQuery } from '$features/auth/api.svelte'; import { accessToken, gotoLogin } from '$features/auth/index.svelte'; + import { UpgradeRequiredDialog } from '$features/billing'; import { invalidatePersistentEventQueries } from '$features/events/api.svelte'; import { buildIntercomBootOptions, IntercomShell } from '$features/intercom'; import { shouldLoadIntercomOrganization } from '$features/intercom/config'; @@ -302,4 +303,6 @@ {@render appShell(openChat)} {/snippet} + + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte index ece476ce75..96995a009c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte @@ -6,6 +6,7 @@ import { page } from '$app/state'; import * as FacetedFilter from '$comp/faceted-filter'; import { H3 } from '$comp/typography'; + import { handleUpgradeRequired } from '$features/billing'; import EventsOverview from '$features/events/components/events-overview.svelte'; import { organization } from '$features/organizations/context.svelte'; import { watch } from 'runed'; @@ -27,8 +28,9 @@ } async function handleError(problem: ProblemDetails) { - if (problem.status === 426) { - // TODO: Show a message to the user that they need to upgrade their subscription. + if (handleUpgradeRequired(problem, organization.current)) { + await goto(resolve('/(app)')); + return; } toast.error(`The event "${page.params.eventId}" could not be found.`); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte index d08fb3a2f1..102aedc7db 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte @@ -9,8 +9,8 @@ import { Skeleton } from '$comp/ui/skeleton'; import * as Table from '$comp/ui/table'; import { env } from '$env/dynamic/public'; + import { ChangePlanDialog } from '$features/billing'; import { getInvoicesQuery, getOrganizationQuery } from '$features/organizations/api.svelte'; - import ChangePlanDialog from '$features/organizations/components/dialogs/change-plan-dialog.svelte'; import { organization } from '$features/organizations/context.svelte'; import GlobalUser from '$features/users/components/global-user.svelte'; import CreditCard from '@lucide/svelte/icons/credit-card'; @@ -42,10 +42,18 @@ schema: { changePlan: 'boolean' } }); + let changePlanDialogOpen = $state(!!params.changePlan); + function handleChangePlan() { + changePlanDialogOpen = true; params.changePlan = true; } + function handleChangePlanClose() { + changePlanDialogOpen = false; + params.changePlan = false; + } + function handleOpenInvoice(invoiceId: string) { window.open(resolve('/(app)/payment/[id]', { id: invoiceId }), '_blank'); } @@ -157,6 +165,6 @@ {/if} -{#if params.changePlan} - +{#if changePlanDialogOpen && organizationQuery.data} + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte index 1c3e69d641..cf7ebec96f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte @@ -1,4 +1,5 @@