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/.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..1595308c0b --- /dev/null +++ b/docs/billing-stripe-integration.md @@ -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 | +| --- | --- | --- | +| `plan_id` | string | Target plan ID (e.g., `EX_MEDIUM`, `EX_LARGE_YEARLY`) | +| `stripe_token` | string? | `pm_` PaymentMethod ID (Svelte) or `tok_` token (Angular) | +| `last4` | string? | Last 4 digits of card (display only) | +| `coupon_id` | string? | Stripe coupon code | + +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: (success: boolean) => void` — callback when dialog closes +- `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. + +### `showBillingDialogOnUpgradeProblem(error, organizationId, retryCallback?)` + +Utility used across 6 route pages to intercept `ProblemDetails` with `status: 426` and open the upgrade dialog. No-ops when the error is not a 426. + +## Stripe SDK Migration Notes (v47 → v51) + +### Stripe API 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 local `_billingManager.GetBillingPlan()` lookup +4. **`CustomerCreateOptions.Plan` removed** → Create subscription separately +5. **`CustomerCreateOptions.Coupon` removed** → Use `SubscriptionDiscountOptions` +6. **`SubscriptionItemOptions.Plan` removed** → Use `SubscriptionItemOptions.Price` +7. **`CustomerCreateOptions.Source`** → Use `CustomerCreateOptions.PaymentMethod` for `pm_` tokens + +### 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) is 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. diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index ba87b5537c..844559053a 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -31,7 +31,7 @@ - + diff --git a/src/Exceptionless.Job/Exceptionless.Job.csproj b/src/Exceptionless.Job/Exceptionless.Job.csproj index 3a1ee6d10b..9c9ea2754c 100644 --- a/src/Exceptionless.Job/Exceptionless.Job.csproj +++ b/src/Exceptionless.Job/Exceptionless.Job.csproj @@ -30,4 +30,4 @@ - + \ No newline at end of file diff --git a/src/Exceptionless.Web/ClientApp.angular/components/billing/change-plan-controller.js b/src/Exceptionless.Web/ClientApp.angular/components/billing/change-plan-controller.js index e02df72547..cdc4b31ab4 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/billing/change-plan-controller.js +++ b/src/Exceptionless.Web/ClientApp.angular/components/billing/change-plan-controller.js @@ -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."); } 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..c04d6c54e4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte @@ -0,0 +1,852 @@ + + + { + if (!v) onclose(false); + }} +> + + + + + 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}· {interval === 'year' ? '/yr' : '/mo'}{/if} + {:else} + {planLabel(organization.plan_id, { includeInterval: includeInt })} + + {planLabel(selectedPlanId, { includeInterval: includeInt })} + {#if selectedPlan}· {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..759485c2e0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/stripe-provider.svelte @@ -0,0 +1,106 @@ + + +
+ +
+ +
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..6cce41441f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/upgrade-required-dialog.svelte @@ -0,0 +1,63 @@ + + + + + + Upgrade Plan + {upgradeRequiredDialog.message} + + + Cancel + Upgrade Plan + + + + +{#if showChangePlan && organizationQuery.data} + +{/if} 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..173803ced1 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/index.ts @@ -0,0 +1,7 @@ +export { default as ChangePlanDialog } from './components/change-plan-dialog.svelte'; +export { default as StripeProvider } from './components/stripe-provider.svelte'; +export { default as UpgradeRequiredDialog } from './components/upgrade-required-dialog.svelte'; +export { FREE_PLAN_ID } from './constants'; +export type { BillingPlan, CardMode, ChangePlanFormState, ChangePlanRequest, ChangePlanResult } from './models'; +export { getStripePublishableKey, isStripeEnabled, loadStripeOnce, setStripeContext, type StripeContext, tryUseStripe, useStripe } from './stripe.svelte'; +export { isUpgradeRequired, showBillingDialogOnUpgradeProblem, showUpgradeDialog } 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..c7b7c16451 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts @@ -0,0 +1,70 @@ +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; +} + +export function getStripePublishableKey(): string | undefined { + return env.PUBLIC_STRIPE_PUBLISHABLE_KEY; +} + +export function isStripeEnabled(): boolean { + return !!env.PUBLIC_STRIPE_PUBLISHABLE_KEY; +} + +let _stripePromise: null | Promise = null; +let _stripeInstance: null | Stripe = null; + +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; + + if (!_stripeInstance) { + _stripePromise = null; + } + + 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; + } +} + +export function setStripeContext(ctx: StripeContext): void { + setContext(STRIPE_CONTEXT_KEY, ctx); +} + +export function tryUseStripe(): null | StripeContext { + return getContext(STRIPE_CONTEXT_KEY) ?? null; +} + +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..dd979d4571 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/upgrade-required.svelte.ts @@ -0,0 +1,63 @@ +import { ProblemDetails } from '@exceptionless/fetchclient'; + +interface UpgradeRequiredState { + message: string; + open: boolean; + organizationId: string | undefined; + retryCallback: (() => Promise | void) | undefined; +} + +const state: UpgradeRequiredState = $state({ + message: '', + open: false, + organizationId: undefined, + retryCallback: 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; + }, + reset() { + state.open = false; + state.message = ''; + state.organizationId = undefined; + state.retryCallback = undefined; + }, + get retryCallback() { + return state.retryCallback; + } +}; + +export function isUpgradeRequired(error: unknown): error is ProblemDetails { + return error instanceof ProblemDetails && error.status === 426; +} + +export function showBillingDialogOnUpgradeProblem(error: unknown, organizationId: string | undefined, retryCallback?: () => Promise | void): boolean { + if (!isUpgradeRequired(error)) { + return false; + } + + state.message = error.title || 'Please upgrade your plan to continue.'; + state.organizationId = organizationId; + state.retryCallback = retryCallback; + state.open = true; + + return true; +} + +export function showUpgradeDialog(organizationId: string, message?: string): void { + state.message = message || 'Please upgrade your plan to enable this feature.'; + state.organizationId = organizationId; + state.retryCallback = undefined; + state.open = true; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts new file mode 100644 index 0000000000..ccb9fbd3a4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts @@ -0,0 +1,47 @@ +/** + * Free query fields that don't require a premium plan. + * Any field referenced in a filter that is NOT in this set requires premium features. + * Must be kept in sync with PersistentEventQueryValidator._freeQueryFields on the backend. + */ +const FREE_QUERY_FIELDS = new Set([ + 'date', + 'organization', + 'organization_id', + 'project', + 'project_id', + 'reference', + 'reference_id', + 'stack', + 'stack_id', + 'status', + 'type' +]); + +/** + * Returns true if the filter string references fields that require a premium plan. + * Uses client-side field detection to avoid an extra API call. + */ +export function filterUsesPremiumFeatures(filter: null | string | undefined): boolean { + if (!filter) { + return false; + } + + const fields = extractFilterFields(filter); + return fields.some((field) => !FREE_QUERY_FIELDS.has(field.toLowerCase())); +} + +/** + * Extracts field names from a Lucene-style filter string. + * Matches patterns like `field:value` or `field:(value1 OR value2)`. + */ +function extractFilterFields(filter: string): string[] { + const fieldPattern = /(?:^|\s|[(!])(\w[\w.]*):/g; + const fields: string[] = []; + let match: null | RegExpExecArray; + + while ((match = fieldPattern.exec(filter)) !== null) { + fields.push(match[1]!); + } + + return fields; +} 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..87d1c69897 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; @@ -146,11 +161,30 @@ export function addOrganizationUser(request: AddOrganizationUserRequest) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, undefined) }); + // Also invalidate the user list for this org (different query namespace from Organization) queryClient.invalidateQueries({ queryKey: ['User', 'organization', request.route.organizationId] }); } })); } +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: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, undefined) }); + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, 'stats') }); + queryClient.invalidateQueries({ queryKey: queryKeys.plans(request.route.organizationId) }); + } + })); +} + export function deleteOrganization(request: DeleteOrganizationRequest) { const queryClient = useQueryClient(); @@ -184,6 +218,7 @@ export function deleteOrganizationUser(request: DeleteOrganizationUserRequest) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, undefined) }); + // Also invalidate the user list for this org (different query namespace from Organization) queryClient.invalidateQueries({ queryKey: ['User', 'organization', request.route.organizationId] }); } })); @@ -325,6 +360,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/projects/components/user-notification-settings-form.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/user-notification-settings-form.svelte index 921b60f16b..9bd661dcff 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/user-notification-settings-form.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/user-notification-settings-form.svelte @@ -15,7 +15,7 @@ hasPremiumFeatures?: boolean; save: (settings: NotificationSettings) => Promise; settings?: NotificationSettings; - upgrade: () => Promise; + upgrade: () => Promise | void; } let { emailNotificationsEnabled = true, hasPremiumFeatures = false, save, settings, upgrade }: Props = $props(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/formatters.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/formatters.ts new file mode 100644 index 0000000000..78f849bcc5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/formatters.ts @@ -0,0 +1,3 @@ +export function formatCurrency(value: number, currency = 'USD', locale: Intl.LocalesArgument = 'en-US'): string { + return new Intl.NumberFormat(locale, { currency, maximumFractionDigits: 0, style: 'currency' }).format(value); +} 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..5d8699e4f3 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 { showBillingDialogOnUpgradeProblem } 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,7 @@ } 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.' - ); - //await confirmUpgradePlan(message, tack.organization_id); - //await promoteToExternal(); + showBillingDialogOnUpgradeProblem(response.problem, stack.organization_id, () => 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..6e9d03a956 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -8,7 +8,10 @@ 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 { upgradeRequiredDialog } from '$features/billing/upgrade-required.svelte'; import { invalidatePersistentEventQueries } from '$features/events/api.svelte'; + import { filterUsesPremiumFeatures } from '$features/events/premium-filter'; import { buildIntercomBootOptions, IntercomShell } from '$features/intercom'; import { shouldLoadIntercomOrganization } from '$features/intercom/config'; import { getOrganizationQuery, getOrganizationsQuery, invalidateOrganizationQueries } from '$features/organizations/api.svelte'; @@ -40,6 +43,7 @@ let { children }: Props = $props(); let isAuthenticated = $derived(!!accessToken.current); + let requiresPremium = $derived(filterUsesPremiumFeatures(page.url.searchParams.get('filter'))); const sidebar = useSidebar(); let isCommandOpen = $state(false); @@ -278,7 +282,7 @@ {#if showOrganizationNotifications.current} - + {/if}
@@ -302,4 +306,8 @@ {@render appShell(openChat)} {/snippet} + + {#if upgradeRequiredDialog.open} + + {/if} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index b4351ba2d2..3af3095533 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -11,6 +11,7 @@ import { H3 } from '$comp/typography'; import { Button } from '$comp/ui/button'; import * as Sheet from '$comp/ui/sheet'; + import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; import { getOrganizationCountQuery } from '$features/events/api.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; import EventsOverview from '$features/events/components/events-overview.svelte'; @@ -38,7 +39,7 @@ import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, useFetchClientStatus } from '$shared/api/api.svelte'; - import { type FetchClientResponse, useFetchClient } from '@exceptionless/fetchclient'; + import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import ExternalLink from '@lucide/svelte/icons/external-link'; import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; @@ -46,6 +47,12 @@ import { throttle } from 'throttle-debounce'; let selectedEventId: null | string = $state(null); + + function handleEventError(problem: ProblemDetails) { + showBillingDialogOnUpgradeProblem(problem, organization.current); + selectedEventId = null; + } + function rowclick(row: EventSummaryModel) { selectedEventId = row.id; } @@ -189,6 +196,10 @@ clientResponse = await client.getJSON[]>(`organizations/${organization.current}/events`, { params: eventsQueryParameters as Record }); + + if (clientResponse.problem) { + showBillingDialogOnUpgradeProblem(clientResponse.problem, organization.current, () => loadData()); + } } const throttledLoadData = throttle(10000, loadData); @@ -312,7 +323,7 @@ >
- (selectedEventId = null)} /> +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte index 7955e2068f..4e4376ee6c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte @@ -7,6 +7,7 @@ import { Separator } from '$comp/ui/separator'; import { Skeleton } from '$comp/ui/skeleton'; import { Switch } from '$comp/ui/switch'; + import { showUpgradeDialog } from '$features/billing/upgrade-required.svelte'; import { getProjectsQuery, getProjectUserNotificationSettings, postProjectUserNotificationSettings } from '$features/projects/api.svelte'; import UserNotificationSettingsForm from '$features/projects/components/user-notification-settings-form.svelte'; import AlertDescription from '$features/shared/components/ui/alert/alert-description.svelte'; @@ -118,8 +119,10 @@ } } - async function handleUpgrade() { - console.log('TODO: Upgrade to premium features'); + function handleUpgrade() { + if (selectedProject?.organization_id) { + showUpgradeDialog(selectedProject.organization_id, 'Please upgrade your plan to enable occurrence level notifications.'); + } } 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..200e7bc0c8 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 { showBillingDialogOnUpgradeProblem } 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,8 @@ } async function handleError(problem: ProblemDetails) { - if (problem.status === 426) { - // TODO: Show a message to the user that they need to upgrade their subscription. + if (showBillingDialogOnUpgradeProblem(problem, organization.current)) { + return; } toast.error(`The event "${page.params.eventId}" could not be found.`); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte index 2f66c284ee..52c210cdcf 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -10,6 +10,7 @@ import { H3 } from '$comp/typography'; import { Button } from '$comp/ui/button'; import * as Sheet from '$comp/ui/sheet'; + import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; import { type GetEventsParams, getOrganizationCountQuery, getStackEventsQuery } from '$features/events/api.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; import EventsOverview from '$features/events/components/events-overview.svelte'; @@ -36,7 +37,7 @@ import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, useFetchClientStatus } from '$shared/api/api.svelte'; - import { type FetchClientResponse, useFetchClient } from '@exceptionless/fetchclient'; + import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import ExternalLink from '@lucide/svelte/icons/external-link'; import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; @@ -47,6 +48,12 @@ // TODO: Update this page to use StackSummaryModel instead of EventSummaryModel. let selectedStackId = $state(); + + function handleStackError(problem: ProblemDetails) { + showBillingDialogOnUpgradeProblem(problem, organization.current); + selectedStackId = undefined; + } + function rowClick(row: EventSummaryModel) { selectedStackId = row.id; } @@ -215,6 +222,8 @@ clientResponse = await client.getJSON[]>(`organizations/${organization.current}/events`, { params: eventsQueryParameters as Record }); + + showBillingDialogOnUpgradeProblem(clientResponse.problem, organization.current, () => loadData()); } const throttledLoadData = throttle(5000, loadData); @@ -339,7 +348,7 @@ >
- (selectedStackId = undefined)} /> +
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..7cc588a361 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,16 +42,24 @@ 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'); } function handleViewStripeInvoice(invoiceId: string) { - window.open(`https://manage.stripe.com/invoices/in_${invoiceId}`, '_blank'); + window.open(`https://manage.stripe.com/invoices/in_${encodeURIComponent(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..63ea8c99cb 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 @@ -8,6 +8,7 @@ import { Separator } from '$comp/ui/separator'; import { Skeleton } from '$comp/ui/skeleton'; import { env } from '$env/dynamic/public'; + import { ChangePlanDialog } from '$features/billing'; import { getOrganizationQuery } from '$features/organizations/api.svelte'; import { getNextBillingDateUtc, getRemainingEventLimit } from '$features/organizations/utils'; import { formatDateLabel, formatLongDate } from '$shared/dates'; @@ -28,11 +29,7 @@ const remainingEventLimit = $derived(getRemainingEventLimit(organizationQuery.data)); const nextBillingDate = $derived(getNextBillingDateUtc(organizationQuery.data)); - function handleChangePlan() { - // Navigate to plan change page or open modal - // This is a placeholder for future implementation - console.log('Change plan clicked'); - } + let changePlanDialogOpen = $state(false); const chartConfig = { blocked: { color: 'var(--chart-2)', label: 'Blocked' }, @@ -97,7 +94,7 @@

You are currently on the {#if canChangePlan} - + (changePlanDialogOpen = true)}> {organizationQuery.data?.plan_name} plan {:else} @@ -112,7 +109,9 @@ (). {#if canChangePlan} - Click here to change your plan or billing information. + (changePlanDialogOpen = true)} + >Click here to change your plan or billing information. {/if}

@@ -142,3 +141,7 @@ {/if} + +{#if changePlanDialogOpen && organizationQuery.data} + (changePlanDialogOpen = false)} /> +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte index 6298fd8dd5..d0d86ec26f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte @@ -4,6 +4,7 @@ import { H3, Muted } from '$comp/typography'; import { Button } from '$comp/ui/button'; import { Separator } from '$comp/ui/separator'; + import { showBillingDialogOnUpgradeProblem } from '$features/billing'; import { addOrganizationUser } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; import { DEFAULT_LIMIT } from '$features/shared/api/api.svelte'; @@ -79,6 +80,10 @@ await addUserMutation.mutateAsync(email); toastId = toast.success('User invited successfully'); } catch (error: unknown) { + if (showBillingDialogOnUpgradeProblem(error, organizationId, () => inviteUser(email))) { + return; + } + const message = error instanceof ProblemDetails ? error.title : 'Please try again.'; toastId = toast.error(`An error occurred while trying to invite the user: ${message}`); throw error; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte index 55b4c54c1a..5bebd88af8 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte @@ -7,6 +7,7 @@ import * as Field from '$comp/ui/field'; import { Input } from '$comp/ui/input'; import { Spinner } from '$comp/ui/spinner'; + import { showBillingDialogOnUpgradeProblem } from '$features/billing'; import { postOrganization } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; import { useHideOrganizationNotifications } from '$features/organizations/hooks/use-hide-organization-notifications.svelte'; @@ -18,6 +19,7 @@ let toastId = $state(); const createOrganization = postOrganization(); + const CREATE_ERROR_MESSAGE = 'Error creating organization. Please try again.'; useHideOrganizationNotifications(); @@ -37,11 +39,16 @@ await goto(resolve('/(app)/organization/[organizationId]/manage', { organizationId: id })); return null; } catch (error: unknown) { - toastId = toast.error('Error creating organization. Please try again.'); + if (showBillingDialogOnUpgradeProblem(error, organization.current, () => form.handleSubmit())) { + return null; + } + if (error instanceof ProblemDetails) { + toastId = toast.error(error.title || CREATE_ERROR_MESSAGE); return problemDetailsToFormErrors(error); } + toastId = toast.error(CREATE_ERROR_MESSAGE); return { form: 'An unexpected error occurred, please try again.' }; } } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte index 816b9892e8..7a52dbe68d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte @@ -9,6 +9,7 @@ import { Separator } from '$comp/ui/separator'; import { env } from '$env/dynamic/public'; import { slackOAuthLogin } from '$features/auth/index.svelte'; + import { showBillingDialogOnUpgradeProblem } from '$features/billing'; import { organization } from '$features/organizations/context.svelte'; import { deleteSlack, @@ -88,6 +89,10 @@ await newWebhook.mutateAsync(webhook); toastId = toast.success('Webhook added successfully'); } catch (error) { + if (showBillingDialogOnUpgradeProblem(error, organization.current, () => addWebhook(webhook))) { + return; + } + toastId = toast.error('Error adding webhook. Please try again.'); throw error; } @@ -100,7 +105,11 @@ const code = await slackOAuthLogin(); await addSlackMutation.mutateAsync(code); toastId = toast.success('Successfully connected Slack integration.'); - } catch { + } catch (error) { + if (showBillingDialogOnUpgradeProblem(error, organization.current, () => addSlack())) { + return; + } + toastId = toast.error('Error connecting Slack integration. Please try again.'); } } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte index 49f21d3ea3..7854990229 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte @@ -8,6 +8,7 @@ import { Separator } from '$comp/ui/separator'; import { Skeleton } from '$comp/ui/skeleton'; import { env } from '$env/dynamic/public'; + import { ChangePlanDialog } from '$features/billing'; import { getOrganizationQuery } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; import { getNextBillingDateUtc, getRemainingEventLimit } from '$features/organizations/utils'; @@ -41,11 +42,7 @@ } }); - function handleChangePlan() { - // Navigate to plan change page or open modal - // This is a placeholder for future implementation - console.log('Change plan clicked'); - } + let changePlanDialogOpen = $state(false); const chartConfig = { blocked: { color: 'var(--chart-2)', label: 'Blocked' }, @@ -125,7 +122,7 @@

You are currently on the {#if canChangePlan} - + (changePlanDialogOpen = true)}> {organizationQuery.data?.plan_name} plan {:else} @@ -140,7 +137,9 @@ (). {#if canChangePlan} - Click here to change your plan or billing information. + (changePlanDialogOpen = true)} + >Click here to change your plan or billing information. {/if}

@@ -169,3 +168,7 @@ {/if} + +{#if changePlanDialogOpen && organizationQuery.data} + (changePlanDialogOpen = false)} /> +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte index ea6d941063..ed96ed8a6c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte @@ -9,6 +9,7 @@ import * as Field from '$comp/ui/field'; import { Input } from '$comp/ui/input'; import { Spinner } from '$comp/ui/spinner'; + import { showBillingDialogOnUpgradeProblem } from '$features/billing'; import { organization } from '$features/organizations/context.svelte'; import { postProject } from '$features/projects/api.svelte'; import { type NewProjectFormData, NewProjectSchema } from '$features/projects/schemas'; @@ -36,11 +37,16 @@ await goto(resolve('/(app)/project/[projectId]/configure', { projectId: id }) + '?redirect=true'); return null; } catch (error: unknown) { - toastId = toast.error('Error creating project. Please try again.'); + if (showBillingDialogOnUpgradeProblem(error, organization.current, () => form.handleSubmit())) { + return null; + } + if (error instanceof ProblemDetails) { + toastId = toast.error(error.title || 'Error creating project. Please try again.'); return problemDetailsToFormErrors(error); } + toastId = toast.error('Error creating project. Please try again.'); return { form: 'An unexpected error occurred, please try again.' }; } } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index 5b81d822c7..d92d700004 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -13,6 +13,7 @@ import { H3 } from '$comp/typography'; import { Button } from '$comp/ui/button'; import * as Sheet from '$comp/ui/sheet'; + import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; import EventsOverview from '$features/events/components/events-overview.svelte'; import { ProjectFilter, StatusFilter } from '$features/events/components/filters'; import { @@ -32,7 +33,7 @@ import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, useFetchClientStatus } from '$shared/api/api.svelte'; - import { type FetchClientResponse, useFetchClient } from '@exceptionless/fetchclient'; + import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import ExternalLink from '@lucide/svelte/icons/external-link'; import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; @@ -42,6 +43,12 @@ import { redirectToEventsWithFilter } from '../redirect-to-events.svelte'; let selectedEventId: null | string = $state(null); + + function handleEventError(problem: ProblemDetails) { + showBillingDialogOnUpgradeProblem(problem, organization.current); + selectedEventId = null; + } + function rowclick(row: EventSummaryModel) { selectedEventId = row.id; } @@ -193,6 +200,10 @@ } }); + if (clientResponse.problem && showBillingDialogOnUpgradeProblem(clientResponse.problem, organization.current, () => loadData(true))) { + return; + } + if (clientResponse.ok) { if (clientResponse.meta.links.previous?.before) { before = clientResponse.meta.links.previous?.before; @@ -289,7 +300,7 @@ >
- (selectedEventId = null)} /> +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte index e3a46e1fdd..889400b434 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte @@ -82,7 +82,7 @@ return true; } - if ([400, 401, 403, 404, 410, 422].includes(status)) { + if ([400, 401, 403, 404, 410, 422, 426].includes(status)) { return false; } diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 607cdb2381..91bafbc139 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -20,6 +20,7 @@ using Foundatio.Repositories.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Stripe; using DataDictionary = Exceptionless.Core.Models.DataDictionary; using Invoice = Exceptionless.Web.Models.Invoice; @@ -221,15 +222,20 @@ public async Task> GetInvoiceAsync(string id) id = "in_" + id; Stripe.Invoice? stripeInvoice = null; + var client = new StripeClient(_options.StripeOptions.StripeApiKey); + try { - var client = new StripeClient(_options.StripeOptions.StripeApiKey); var invoiceService = new InvoiceService(client); stripeInvoice = await invoiceService.GetAsync(id); } + catch (StripeException ex) + { + _logger.LogCritical(ex, "Error getting invoice ({InvoiceId}): {Message}", id, ex.Message); + } catch (Exception ex) { - _logger.LogError(ex, "An error occurred while getting the invoice: {InvoiceId}", id); + _logger.LogCritical(ex, "Unexpected error getting invoice ({InvoiceId}): {Message}", id, ex.Message); } if (String.IsNullOrEmpty(stripeInvoice?.CustomerId)) @@ -245,17 +251,21 @@ public async Task> GetInvoiceAsync(string id) OrganizationId = organization.Id, OrganizationName = organization.Name, Date = stripeInvoice.Created, - Paid = stripeInvoice.Paid, + Paid = String.Equals(stripeInvoice.Status, "paid", StringComparison.OrdinalIgnoreCase), Total = stripeInvoice.Total / 100.0m }; foreach (var line in stripeInvoice.Lines.Data) { var item = new InvoiceLineItem { Amount = line.Amount / 100.0m, Description = line.Description }; - if (line.Plan is not null) + + var priceId = line.Pricing?.PriceDetails?.PriceId; + if (!String.IsNullOrEmpty(priceId)) { - string planName = line.Plan.Nickname ?? _billingManager.GetBillingPlan(line.Plan.Id)?.Name ?? line.Plan.Id; - item.Description = $"Exceptionless - {planName} Plan ({(line.Plan.Amount / 100.0):c}/{line.Plan.Interval})"; + var billingPlan = _billingManager.GetBillingPlan(priceId); + string planName = billingPlan?.Name ?? priceId; + string interval = priceId.EndsWith("_YEARLY", StringComparison.OrdinalIgnoreCase) ? "year" : "month"; + item.Description = $"Exceptionless - {planName} Plan ({line.Amount / 100.0m:c}/{interval})"; } var periodStart = line.Period.Start >= DateTime.MinValue ? line.Period.Start : stripeInvoice.PeriodStart; @@ -264,7 +274,7 @@ public async Task> GetInvoiceAsync(string id) invoice.Items.Add(item); } - var coupon = stripeInvoice.Discount?.Coupon; + var coupon = stripeInvoice.Discounts?.FirstOrDefault(d => d.Deleted is not true)?.Source?.Coupon; if (coupon is not null) { if (coupon.AmountOff.HasValue) @@ -335,9 +345,9 @@ public async Task>> GetPlansAsync( if (organization is null) return NotFound(); - var plans = _plans.Plans; - if (!Request.IsGlobalAdmin()) - plans = plans.Where(p => !p.IsHidden || p.Id == organization.PlanId).ToList(); + var plans = Request.IsGlobalAdmin() + ? _plans.Plans.ToList() + : _plans.Plans.Where(p => !p.IsHidden || String.Equals(p.Id, organization.PlanId, StringComparison.OrdinalIgnoreCase)).ToList(); var currentPlan = new BillingPlan { @@ -353,10 +363,11 @@ public async Task>> GetPlansAsync( HasPremiumFeatures = organization.HasPremiumFeatures }; - if (plans.All(p => p.Id != organization.PlanId)) - plans.Add(currentPlan); + int idx = plans.FindIndex(p => String.Equals(p.Id, organization.PlanId, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + plans[idx] = currentPlan; else - plans[plans.FindIndex(p => p.Id == organization.PlanId)] = currentPlan; + plans.Add(currentPlan); return Ok(plans); } @@ -365,19 +376,37 @@ public async Task>> GetPlansAsync( /// Change plan /// /// - /// Upgrades or downgrades the organizations plan. + /// Upgrades or downgrades the organization's plan. + /// Accepts parameters via JSON body (preferred) or query string (legacy). /// /// The identifier of the organization. - /// The identifier of the plan. - /// The token returned from the stripe service. - /// The last four numbers of the card. - /// The coupon id. + /// The plan change request (JSON body). + /// Legacy query parameter: the plan identifier. + /// Legacy query parameter: the Stripe token. + /// Legacy query parameter: last four digits of the card. + /// Legacy query parameter: the coupon identifier. /// The organization was not found. [HttpPost] - [Consumes("application/json")] [Route("{id:objectid}/change-plan")] - public async Task> ChangePlanAsync(string id, string planId, string? stripeToken = null, string? last4 = null, string? couponId = null) + public async Task> ChangePlanAsync( + string id, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ChangePlanRequest? model = null, + [FromQuery] string? planId = null, + [FromQuery] string? stripeToken = null, + [FromQuery] string? last4 = null, + [FromQuery] string? couponId = null) { + // Support legacy clients that send query parameters instead of a JSON body + model ??= new ChangePlanRequest { PlanId = planId ?? String.Empty }; + if (String.IsNullOrEmpty(model.PlanId) && !String.IsNullOrEmpty(planId)) + model.PlanId = planId; + if (String.IsNullOrEmpty(model.StripeToken) && !String.IsNullOrEmpty(stripeToken)) + model.StripeToken = stripeToken; + if (String.IsNullOrEmpty(model.Last4) && !String.IsNullOrEmpty(last4)) + model.Last4 = last4; + if (String.IsNullOrEmpty(model.CouponId) && !String.IsNullOrEmpty(couponId)) + model.CouponId = couponId; + if (String.IsNullOrEmpty(id) || !CanAccessOrganization(id)) return NotFound(); @@ -385,15 +414,19 @@ public async Task> ChangePlanAsync(string id, str .Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext)); if (!_options.StripeOptions.EnableBilling) - return Ok(ChangePlanResult.FailWithMessage("Plans cannot be changed while billing is disabled.")); + return NotFound(); var organization = await GetModelAsync(id, false); if (organization is null) - return Ok(ChangePlanResult.FailWithMessage("Invalid OrganizationId.")); + return NotFound(); - var plan = _billingManager.GetBillingPlan(planId); + var plan = _billingManager.GetBillingPlan(model.PlanId); if (plan is null) - return Ok(ChangePlanResult.FailWithMessage("Invalid PlanId.")); + { + _logger.LogWarning("Plan {PlanId} not found for organization {OrganizationId}", model.PlanId, id); + ModelState.AddModelError("general", "Invalid plan. Please select a valid plan."); + return ValidationProblem(ModelState); + } if (String.Equals(organization.PlanId, plan.Id) && String.Equals(_plans.FreePlan.Id, plan.Id)) return Ok(ChangePlanResult.SuccessWithMessage("Your plan was not changed as you were already on the free plan.")); @@ -409,6 +442,9 @@ public async Task> ChangePlanAsync(string id, str var client = new StripeClient(_options.StripeOptions.StripeApiKey); var customerService = new CustomerService(client); var subscriptionService = new SubscriptionService(client); + var paymentMethodService = new PaymentMethodService(client); + + bool isPaymentMethod = model.StripeToken?.StartsWith("pm_", StringComparison.Ordinal) is true; try { @@ -425,31 +461,58 @@ public async Task> ChangePlanAsync(string id, str organization.BillingStatus = BillingStatus.Trialing; organization.RemoveSuspension(); } + // New customer: create a Stripe customer and subscription from the provided payment token. else if (String.IsNullOrEmpty(organization.StripeCustomerId)) { - if (String.IsNullOrEmpty(stripeToken)) + if (String.IsNullOrEmpty(model.StripeToken)) return Ok(ChangePlanResult.FailWithMessage("Billing information was not set.")); organization.SubscribeDate = _timeProvider.GetUtcNow().UtcDateTime; var createCustomer = new CustomerCreateOptions { - Source = stripeToken, - Plan = planId, Description = organization.Name, Email = CurrentUser.EmailAddress }; - if (!String.IsNullOrWhiteSpace(couponId)) - createCustomer.Coupon = couponId; + if (isPaymentMethod) + { + createCustomer.PaymentMethod = model.StripeToken; + createCustomer.InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = model.StripeToken + }; + } + else + { + createCustomer.Source = model.StripeToken; + } var customer = await customerService.CreateAsync(createCustomer); + // Persist the Stripe customer ID immediately so a retry won't create a duplicate customer + organization.StripeCustomerId = customer.Id; + organization.CardLast4 = model.Last4; + await _repository.SaveAsync(organization, o => o.Cache()); + + var subscriptionOptions = new SubscriptionCreateOptions + { + Customer = customer.Id, + Items = [new SubscriptionItemOptions { Price = model.PlanId }] + }; + + if (isPaymentMethod) + subscriptionOptions.DefaultPaymentMethod = model.StripeToken; + + if (!String.IsNullOrWhiteSpace(model.CouponId)) + subscriptionOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; + + await subscriptionService.CreateAsync(subscriptionOptions); + organization.BillingStatus = BillingStatus.Active; organization.RemoveSuspension(); - organization.StripeCustomerId = customer.Id; - organization.CardLast4 = last4; } + // Existing customer: update (or create) their Stripe subscription and optionally swap payment method. else { var update = new SubscriptionUpdateOptions { Items = [] }; @@ -460,29 +523,60 @@ public async Task> ChangePlanAsync(string id, str if (!Request.IsGlobalAdmin()) customerUpdateOptions.Email = CurrentUser.EmailAddress; - if (!String.IsNullOrEmpty(stripeToken)) + var listSubscriptionsTask = subscriptionService.ListAsync(new SubscriptionListOptions { Customer = organization.StripeCustomerId }); + + if (!String.IsNullOrEmpty(model.StripeToken)) { - customerUpdateOptions.Source = stripeToken; + if (isPaymentMethod) + { + await paymentMethodService.AttachAsync(model.StripeToken, new PaymentMethodAttachOptions + { + Customer = organization.StripeCustomerId + }); + customerUpdateOptions.InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = model.StripeToken + }; + } + else + { + customerUpdateOptions.Source = model.StripeToken; + } cardUpdated = true; } - await customerService.UpdateAsync(organization.StripeCustomerId, customerUpdateOptions); + await Task.WhenAll( + customerService.UpdateAsync(organization.StripeCustomerId, customerUpdateOptions), + listSubscriptionsTask + ); - var subscriptionList = await subscriptionService.ListAsync(new SubscriptionListOptions { Customer = organization.StripeCustomerId }); + var subscriptionList = await listSubscriptionsTask; var subscription = subscriptionList.FirstOrDefault(s => !s.CanceledAt.HasValue); - if (subscription is not null) + if (subscription is not null && subscription.Items.Data.Count > 0) + { + update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Price = model.PlanId }); + if (!String.IsNullOrWhiteSpace(model.CouponId)) + update.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; + await subscriptionService.UpdateAsync(subscription.Id, update); + } + else if (subscription is not null) { - update.Items.Add(new SubscriptionItemOptions { Id = subscription.Items.Data[0].Id, Plan = planId }); + _logger.LogWarning("Subscription {SubscriptionId} has no items for organization {OrganizationId}, adding new item", subscription.Id, id); + update.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); + if (!String.IsNullOrWhiteSpace(model.CouponId)) + update.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; await subscriptionService.UpdateAsync(subscription.Id, update); } else { - create.Items.Add(new SubscriptionItemOptions { Plan = planId }); + create.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); + if (!String.IsNullOrWhiteSpace(model.CouponId)) + create.Discounts = [new SubscriptionDiscountOptions { Coupon = model.CouponId }]; await subscriptionService.CreateAsync(create); } if (cardUpdated) - organization.CardLast4 = last4; + organization.CardLast4 = model.Last4; organization.BillingStatus = BillingStatus.Active; organization.RemoveSuspension(); @@ -492,10 +586,15 @@ public async Task> ChangePlanAsync(string id, str await _repository.SaveAsync(organization, o => o.Cache().Originals()); await _messagePublisher.PublishAsync(new PlanChanged { OrganizationId = organization.Id }); } + catch (StripeException ex) + { + _logger.LogCritical(ex, "Error occurred update billing plan: {Message}", ex.Message); + return Ok(ChangePlanResult.FailWithMessage("An error occurred while changing plans. Please try again or contact support.")); + } catch (Exception ex) { - _logger.LogCritical(ex, "An error occurred while trying to update your billing plan: {Message}", ex.Message); - return Ok(ChangePlanResult.FailWithMessage(ex.Message)); + _logger.LogCritical(ex, "An unexpected error occurred while trying to update your billing plan: {Message}", ex.Message); + return Ok(ChangePlanResult.FailWithMessage("An error occurred while changing plans. Please try again.")); } return Ok(new ChangePlanResult { Success = true }); diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index bca79090a7..85754b01b7 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -84,4 +84,4 @@ - + \ No newline at end of file diff --git a/src/Exceptionless.Web/Mapping/InvoiceMapper.cs b/src/Exceptionless.Web/Mapping/InvoiceMapper.cs index 82d278779f..c3c653d64d 100644 --- a/src/Exceptionless.Web/Mapping/InvoiceMapper.cs +++ b/src/Exceptionless.Web/Mapping/InvoiceMapper.cs @@ -13,7 +13,7 @@ public InvoiceGridModel MapToInvoiceGridModel(Stripe.Invoice source) { Id = source.Id[3..], // Strip "in_" prefix Date = source.Created, - Paid = source.Paid + Paid = String.Equals(source.Status, "paid", StringComparison.OrdinalIgnoreCase) }; public List MapToInvoiceGridModels(IEnumerable source) diff --git a/src/Exceptionless.Web/Models/ChangePlanRequest.cs b/src/Exceptionless.Web/Models/ChangePlanRequest.cs new file mode 100644 index 0000000000..d623029709 --- /dev/null +++ b/src/Exceptionless.Web/Models/ChangePlanRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Exceptionless.Web.Models; + +public record ChangePlanRequest +{ + [Required] + public string PlanId { get; set; } = null!; + + public string? StripeToken { get; set; } + + public string? Last4 { get; set; } + + public string? CouponId { get; set; } +} diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index 88bb1f0dad..db9dd79749 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -44,6 +44,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) .SetBasePath(Directory.GetCurrentDirectory()) .AddYamlFile("appsettings.yml", optional: true, reloadOnChange: true) .AddYamlFile($"appsettings.{environment}.yml", optional: true, reloadOnChange: true) + .AddYamlFile($"appsettings.Local.yml", optional: true, reloadOnChange: true) .AddCustomEnvironmentVariables() .AddCommandLine(args) .Build(); diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 086c0a5990..2c0ef105a3 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -4317,7 +4317,7 @@ "Organization" ], "summary": "Change plan", - "description": "Upgrades or downgrades the organizations plan.", + "description": "Upgrades or downgrades the organization's plan.\nAccepts parameters via JSON body (preferred) or query string (legacy).", "parameters": [ { "name": "id", @@ -4332,7 +4332,7 @@ { "name": "planId", "in": "query", - "description": "The identifier of the plan.", + "description": "Legacy query parameter: the plan identifier.", "schema": { "type": "string" } @@ -4340,7 +4340,7 @@ { "name": "stripeToken", "in": "query", - "description": "The token returned from the stripe service.", + "description": "Legacy query parameter: the Stripe token.", "schema": { "type": "string" } @@ -4348,7 +4348,7 @@ { "name": "last4", "in": "query", - "description": "The last four numbers of the card.", + "description": "Legacy query parameter: last four digits of the card.", "schema": { "type": "string" } @@ -4356,12 +4356,77 @@ { "name": "couponId", "in": "query", - "description": "The coupon id.", + "description": "Legacy query parameter: the coupon identifier.", "schema": { "type": "string" } } ], + "requestBody": { + "description": "The plan change request (JSON body).", + "content": { + "text/plain": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + }, + "application/octet-stream": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + }, + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChangePlanRequest" + } + ] + } + } + } + }, "responses": { "200": { "description": "OK", @@ -7060,6 +7125,35 @@ } } }, + "ChangePlanRequest": { + "required": [ + "plan_id" + ], + "type": "object", + "properties": { + "plan_id": { + "type": "string" + }, + "stripe_token": { + "type": [ + "null", + "string" + ] + }, + "last4": { + "type": [ + "null", + "string" + ] + }, + "coupon_id": { + "type": [ + "null", + "string" + ] + } + } + }, "ChangePlanResult": { "required": [ "success" diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 9514a29281..d12525d168 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -1,4 +1,6 @@ +using Exceptionless.Core.Billing; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; @@ -9,21 +11,21 @@ namespace Exceptionless.Tests.Controllers; -/// -/// Tests for OrganizationController including mapping coverage. -/// Validates NewOrganization -> Organization and Organization -> ViewOrganization mappings. -/// public sealed class OrganizationControllerTests : IntegrationTestsBase { private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; private readonly IUserRepository _userRepository; + private readonly BillingManager _billingManager; + private readonly BillingPlans _plans; public OrganizationControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _organizationRepository = GetService(); _projectRepository = GetService(); _userRepository = GetService(); + _billingManager = GetService(); + _plans = GetService(); } protected override async Task ResetDataAsync() @@ -51,13 +53,12 @@ public async Task PostAsync_NewOrganization_MapsToOrganizationAndCreates() .StatusCodeShouldBeCreated() ); - // Assert - Verify Organization mapping -> Organization correctly + // Assert Assert.NotNull(viewOrg); Assert.NotNull(viewOrg.Id); Assert.Equal("Test Organization", viewOrg.Name); Assert.True(viewOrg.CreatedUtc > DateTime.MinValue); - // Verify persisted entity var organization = await _organizationRepository.GetByIdAsync(viewOrg.Id); Assert.NotNull(organization); Assert.Equal("Test Organization", organization.Name); @@ -73,7 +74,7 @@ public async Task GetAsync_ExistingOrganization_MapsToViewOrganization() .StatusCodeShouldBeOk() ); - // Assert - Verify mapped Organization -> ViewOrganization correctly + // Assert Assert.NotNull(viewOrg); Assert.Equal(SampleDataService.TEST_ORG_ID, viewOrg.Id); Assert.False(String.IsNullOrEmpty(viewOrg.Name)); @@ -92,7 +93,7 @@ public async Task GetAsync_WithStatsMode_ReturnsPopulatedViewOrganization() .StatusCodeShouldBeOk() ); - // Assert - ViewOrganization should include computed properties + // Assert Assert.NotNull(viewOrg); Assert.Equal(SampleDataService.TEST_ORG_ID, viewOrg.Id); Assert.NotNull(viewOrg.Usage); @@ -109,7 +110,7 @@ public async Task GetAllAsync_ReturnsViewOrganizationCollection() .StatusCodeShouldBeOk() ); - // Assert - All organizations should be mapped to ViewOrganization + // Assert Assert.NotNull(viewOrgs); Assert.True(viewOrgs.Count > 0); Assert.All(viewOrgs, vo => @@ -138,7 +139,7 @@ public async Task PostAsync_NewOrganization_AssignsDefaultPlan() .StatusCodeShouldBeCreated() ); - // Assert - Newly created org should have a default plan + // Assert Assert.NotNull(viewOrg); Assert.NotNull(viewOrg.PlanId); Assert.NotNull(viewOrg.PlanName); @@ -154,9 +155,8 @@ public async Task GetAsync_ViewOrganization_IncludesIsOverMonthlyLimit() .StatusCodeShouldBeOk() ); - // Assert - IsOverMonthlyLimit is computed by OrganizationMapper + // Assert Assert.NotNull(viewOrg); - // The value can be true or false depending on usage, but the property should be set Assert.IsType(viewOrg.IsOverMonthlyLimit); } @@ -217,7 +217,7 @@ public Task GetAsync_NonExistentOrganization_ReturnsNotFound() [Fact] public async Task DeleteAsync_ExistingOrganization_RemovesOrganization() { - // Arrange - Create an organization to delete + // Arrange var newOrg = new NewOrganization { Name = "Organization To Delete" @@ -364,4 +364,272 @@ await SendRequestAsync(r => r Assert.Contains(globalAdmin.Id, project.NotificationSettings.Keys); Assert.Contains(Project.NotificationIntegrations.Slack, project.NotificationSettings.Keys); } + + [Fact] + public async Task GetPlansAsync_UnlimitedPlanOrg_ReturnsPlansWithCurrentPlanOverlay() + { + // Act + var plans = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "plans") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(plans); + Assert.True(plans.Count > 0); + var unlimitedPlan = plans.SingleOrDefault(p => String.Equals(p.Id, _plans.UnlimitedPlan.Id, StringComparison.Ordinal)); + Assert.NotNull(unlimitedPlan); + Assert.False(unlimitedPlan.IsHidden); + } + + [Fact] + public async Task GetPlansAsync_FreePlanOrg_ExcludesHiddenPlans() + { + // Act + var plans = await SendRequestAsAsync>(r => r + .AsFreeOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "plans") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(plans); + Assert.True(plans.Count > 0); + + Assert.DoesNotContain(plans, p => p.IsHidden); + var freePlan = plans.SingleOrDefault(p => String.Equals(p.Id, _plans.FreePlan.Id, StringComparison.Ordinal)); + Assert.NotNull(freePlan); + Assert.Equal(_plans.FreePlan.Name, freePlan.Name); + } + + [Fact] + public async Task GetPlansAsync_AdminUser_ReturnsAllPlansIncludingHidden() + { + // Act + var plans = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "plans") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(plans); + Assert.Equal(_plans.Plans.Count, plans.Count); + } + + [Fact] + public async Task GetPlansAsync_CurrentPlanOverlay_ReflectsOrgValues() + { + // Arrange + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(org); + + // Act + var plans = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "plans") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(plans); + var currentPlan = plans.SingleOrDefault(p => String.Equals(p.Id, org.PlanId, StringComparison.Ordinal)); + Assert.NotNull(currentPlan); + Assert.Equal(org.PlanName, currentPlan.Name); + Assert.Equal(org.BillingPrice, currentPlan.Price); + Assert.Equal(org.MaxProjects, currentPlan.MaxProjects); + Assert.Equal(org.MaxUsers, currentPlan.MaxUsers); + Assert.Equal(org.RetentionDays, currentPlan.RetentionDays); + Assert.Equal(org.MaxEventsPerMonth, currentPlan.MaxEventsPerMonth); + Assert.Equal(org.HasPremiumFeatures, currentPlan.HasPremiumFeatures); + } + + [Fact] + public Task GetPlansAsync_NonExistentOrg_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", "000000000000000000000000", "plans") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task ChangePlanAsync_BillingDisabled_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "change-plan") + .Content(new ChangePlanRequest { PlanId = _plans.FreePlan.Id }) + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task ChangePlanAsync_LegacyQueryParams_BillingDisabled_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "change-plan") + .QueryString("planId", _plans.FreePlan.Id) + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task ChangePlanAsync_NonExistentOrg_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("organizations", "000000000000000000000000", "change-plan") + .Content(new ChangePlanRequest { PlanId = _plans.FreePlan.Id }) + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task ChangePlanAsync_UnauthorizedOrg_ReturnsNotFound() + { + // Act & Assert — free user should not be able to change plan for the test org they don't belong to + return SendRequestAsync(r => r + .AsFreeOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "change-plan") + .Content(new ChangePlanRequest { PlanId = _plans.FreePlan.Id }) + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task ChangePlanAsync_EmptyBody_BillingDisabled_ReturnsNotFound() + { + // Act & Assert — empty body falls back to query params; billing disabled returns 404 + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "change-plan") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task CanDownGradeAsync_TooManyUsers_ReturnsFailure() + { + // Arrange — test org has 2 users (global admin + org user); free plan allows max 1 + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(org); + + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + + // Act + var result = await _billingManager.CanDownGradeAsync(org, _plans.FreePlan, user); + + // Assert + Assert.False(result.Success); + Assert.Contains("remove", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("user", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanDownGradeAsync_TooManyProjects_ReturnsFailure() + { + // Arrange — free org has 1 user and 1 project; add a second project so project check fails + var org = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); + Assert.NotNull(org); + + var extraProject = new Project + { + Name = "Extra Project", + OrganizationId = org.Id, + NextSummaryEndOfDayTicks = DateTime.UtcNow.Date.AddDays(1).AddHours(1).Ticks + }; + await _projectRepository.AddAsync(extraProject, o => o.ImmediateConsistency()); + + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); + Assert.NotNull(user); + + // Act + var result = await _billingManager.CanDownGradeAsync(org, _plans.FreePlan, user); + + // Assert + Assert.False(result.Success); + Assert.Contains("remove", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("project", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanDownGradeAsync_AlreadyHasFreePlan_ReturnsFailure() + { + // Arrange — create a second org for the free user, so they already have 1 free org + var freeUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); + Assert.NotNull(freeUser); + + var secondOrg = new Organization { Name = "Second Org" }; + _billingManager.ApplyBillingPlan(secondOrg, _plans.Plans.First(p => p.Id == "EX_SMALL"), freeUser); + secondOrg.StripeCustomerId = "cus_test"; + secondOrg.CardLast4 = "4242"; + secondOrg.SubscribeDate = DateTime.UtcNow; + secondOrg = await _organizationRepository.AddAsync(secondOrg, o => o.ImmediateConsistency()); + + freeUser.OrganizationIds.Add(secondOrg.Id); + await _userRepository.SaveAsync(freeUser, o => o.ImmediateConsistency()); + + // Act — try to downgrade second org to free plan (user already has FREE_ORG on free plan) + var result = await _billingManager.CanDownGradeAsync(secondOrg, _plans.FreePlan, freeUser); + + // Assert + Assert.False(result.Success); + Assert.Contains("free account", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanDownGradeAsync_ValidDowngrade_ReturnsSuccess() + { + // Arrange — the free org (1 user, 1 project) should be able to "downgrade" to small plan + var org = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); + Assert.NotNull(org); + + var smallPlan = _plans.Plans.FirstOrDefault(p => p.Id == "EX_SMALL"); + Assert.NotNull(smallPlan); + + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); + Assert.NotNull(user); + + // Act — "upgrading" from free to small, downgrade check should succeed + var result = await _billingManager.CanDownGradeAsync(org, smallPlan, user); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public Task GetInvoiceAsync_BillingDisabled_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", "invoice", "in_test_invoice_id") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task GetInvoicesAsync_BillingDisabled_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "invoices") + .StatusCodeShouldBeNotFound() + ); + } } diff --git a/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs b/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs index b5822934b6..fb1b1dd46d 100644 --- a/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs @@ -20,7 +20,7 @@ public void MapToInvoiceGridModel_WithValidInvoice_StripsIdPrefix() { Id = "in_abc123", Created = new DateTime(2025, 1, 15, 12, 0, 0, DateTimeKind.Utc), - Paid = true + Status = "paid" }; // Act @@ -39,7 +39,7 @@ public void MapToInvoiceGridModel_WithValidInvoice_MapsDateAndPaid() { Id = "in_5f8a3b2c1d4e", Created = expectedDate, - Paid = true + Status = "paid" }; // Act @@ -58,7 +58,7 @@ public void MapToInvoiceGridModel_WithUnpaidInvoice_PaidIsFalse() { Id = "in_unpaid", Created = DateTime.UtcNow, - Paid = false + Status = "open" }; // Act @@ -74,9 +74,9 @@ public void MapToInvoiceGridModels_WithMultipleInvoices_MapsAll() // Arrange var invoices = new List { - new() { Id = "in_invoice1", Created = DateTime.UtcNow, Paid = true }, - new() { Id = "in_invoice2", Created = DateTime.UtcNow, Paid = false }, - new() { Id = "in_invoice3", Created = DateTime.UtcNow, Paid = true } + new() { Id = "in_invoice1", Created = DateTime.UtcNow, Status = "paid" }, + new() { Id = "in_invoice2", Created = DateTime.UtcNow, Status = "open" }, + new() { Id = "in_invoice3", Created = DateTime.UtcNow, Status = "paid" } }; // Act diff --git a/tests/http/organizations.http b/tests/http/organizations.http index ff060f944f..46697f3c5f 100644 --- a/tests/http/organizations.http +++ b/tests/http/organizations.http @@ -44,11 +44,17 @@ Content-Type: application/json ### @organizationId = {{newOrganization.response.body.$.id}} -### Change Plan -POST {{apiUrl}}/organizations/{{organizationId}}/change-plan?planId=EX_FREE +### Change Plan (JSON body - preferred) +POST {{apiUrl}}/organizations/{{organizationId}}/change-plan Authorization: Bearer {{token}} Content-Type: application/json +{ "plan_id": "EX_FREE" } + +### Change Plan (query params - legacy) +POST {{apiUrl}}/organizations/{{organizationId}}/change-plan?planId=EX_FREE +Authorization: Bearer {{token}} + ### Add User POST {{apiUrl}}/organizations/{{organizationId}}/users/test2@localhost Authorization: Bearer {{token}} @@ -87,4 +93,4 @@ Content-Type: application/json ### Unsuspend DELETE {{apiUrl}}/organizations/{{organizationId}}/suspend Authorization: Bearer {{token}} -Content-Type: application/json \ No newline at end of file +Content-Type: application/json