diff --git a/ENTERPRISE_FEATURE_ANALYSIS.md b/ENTERPRISE_FEATURE_ANALYSIS.md new file mode 100644 index 0000000000..e356712bd9 --- /dev/null +++ b/ENTERPRISE_FEATURE_ANALYSIS.md @@ -0,0 +1,417 @@ +# Enterprise Feature Access: Status Quo Analysis + +## Executive Summary + +Formbricks currently uses **two completely different mechanisms** to gate enterprise features depending on deployment type: + +| Deployment | Gating Mechanism | Activation | Feature Control | +|------------|------------------|------------|-----------------| +| **Cloud** (`IS_FORMBRICKS_CLOUD=1`) | Billing Plan (`organization.billing.plan`) | Stripe subscription | Plan-based (FREE/STARTUP/CUSTOM) | +| **On-Premise** | License Key (`ENTERPRISE_LICENSE_KEY`) | License API validation | License feature flags | + +This dual approach creates **significant complexity**, **code duplication**, and **inconsistent behavior** across the codebase. + +--- + +## 1. Core Architecture + +### 1.1 Cloud (Formbricks Cloud) + +**Source of Truth:** `organization.billing.plan` + +```typescript +// packages/database/zod/organizations.ts +plan: z.enum(["free", "startup", "scale", "enterprise"]).default("free") +``` + +**Plans and Limits:** +- `FREE`: 3 projects, 1,500 responses/month, 2,000 MIU +- `STARTUP`: 3 projects, 5,000 responses/month, 7,500 MIU +- `CUSTOM`: Unlimited (negotiated limits) + +**Activation:** Stripe webhook updates `organization.billing` on checkout/subscription events. + +### 1.2 On-Premise (Self-Hosted) + +**Source of Truth:** `ENTERPRISE_LICENSE_KEY` environment variable + +**License Features Schema:** +```typescript +// apps/web/modules/ee/license-check/types/enterprise-license.ts +{ + isMultiOrgEnabled: boolean, + contacts: boolean, + projects: number | null, + whitelabel: boolean, + removeBranding: boolean, + twoFactorAuth: boolean, + sso: boolean, + saml: boolean, + spamProtection: boolean, + ai: boolean, + auditLogs: boolean, + multiLanguageSurveys: boolean, + accessControl: boolean, + quotas: boolean, +} +``` + +**Activation:** License key validated against `https://ee.formbricks.com/api/licenses/check` (cached for 24h, grace period of 3 days). + +--- + +## 2. Feature Gating Patterns + +### 2.1 Pattern A: Dual-Path Check (Most Common) + +Features that need **both** Cloud billing **and** on-premise license checks: + +```typescript +// apps/web/modules/ee/license-check/lib/utils.ts +const getFeaturePermission = async (billingPlan, featureKey) => { + const license = await getEnterpriseLicense(); + + if (IS_FORMBRICKS_CLOUD) { + return license.active && billingPlan !== PROJECT_FEATURE_KEYS.FREE; + } else { + return license.active && !!license.features?.[featureKey]; + } +}; +``` + +**Used by:** +- `getRemoveBrandingPermission()` - Remove branding +- `getWhiteLabelPermission()` - Whitelabel features +- `getBiggerUploadFileSizePermission()` - Large file uploads +- `getIsSpamProtectionEnabled()` - reCAPTCHA spam protection +- `getMultiLanguagePermission()` - Multi-language surveys +- `getAccessControlPermission()` - Teams & roles +- `getIsQuotasEnabled()` - Quota management +- `getOrganizationProjectsLimit()` - Project limits + +### 2.2 Pattern B: License-Only Check + +Features checked **only** against license (works same for cloud and on-premise): + +```typescript +// apps/web/modules/ee/license-check/lib/utils.ts +const getSpecificFeatureFlag = async (featureKey) => { + const licenseFeatures = await getLicenseFeatures(); + if (!licenseFeatures) return false; + return licenseFeatures[featureKey] ?? false; +}; +``` + +**Used by:** +- `getIsMultiOrgEnabled()` - Multiple organizations +- `getIsContactsEnabled()` - Contacts & segments +- `getIsTwoFactorAuthEnabled()` - 2FA +- `getIsSsoEnabled()` - SSO +- `getIsAuditLogsEnabled()` - Audit logs + +### 2.3 Pattern C: Cloud-Only (No License Check) + +Features available only on Cloud, gated purely by billing plan: + +```typescript +// apps/web/modules/survey/lib/permission.ts +export const getExternalUrlsPermission = async (billingPlan) => { + if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE; + return true; // Always allowed on self-hosted +}; +``` + +**Used by:** +- External URLs permission +- Survey follow-ups (Custom plan only) + +### 2.4 Pattern D: On-Premise Only (Disabled on Cloud) + +Features explicitly disabled on Cloud: + +```typescript +// apps/web/modules/ee/license-check/lib/utils.ts +export const getIsSamlSsoEnabled = async () => { + if (IS_FORMBRICKS_CLOUD) return false; // Never on Cloud + const licenseFeatures = await getLicenseFeatures(); + return licenseFeatures.sso && licenseFeatures.saml; +}; +``` + +**Used by:** +- SAML SSO +- Pretty URLs (slug feature) +- Domain/Organization settings page + +--- + +## 3. Files Using Enterprise Features + +### 3.1 Core License/Feature Check Files + +| File | Purpose | +|------|---------| +| `apps/web/modules/ee/license-check/lib/license.ts` | License fetching & caching | +| `apps/web/modules/ee/license-check/lib/utils.ts` | Permission check functions | +| `apps/web/modules/ee/license-check/types/enterprise-license.ts` | Type definitions | +| `apps/web/lib/constants.ts` | `IS_FORMBRICKS_CLOUD`, `ENTERPRISE_LICENSE_KEY` | + +### 3.2 Feature-Specific Implementation Files + +#### Remove Branding +- `apps/web/modules/ee/whitelabel/remove-branding/actions.ts` +- `apps/web/modules/ee/whitelabel/remove-branding/components/branding-settings-card.tsx` +- `apps/web/modules/projects/settings/look/page.tsx` +- `apps/web/modules/projects/settings/actions.ts` + +#### Whitelabel / Email Customization +- `apps/web/modules/ee/whitelabel/email-customization/actions.ts` +- `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx` +- `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/domain/page.tsx` + +#### Multi-Language Surveys +- `apps/web/modules/ee/multi-language-surveys/lib/actions.ts` +- `apps/web/modules/ee/multi-language-surveys/components/*.tsx` +- `apps/web/modules/ee/languages/page.tsx` + +#### Contacts & Segments +- `apps/web/modules/ee/contacts/segments/actions.ts` +- `apps/web/modules/ee/contacts/page.tsx` +- `apps/web/modules/ee/contacts/api/v1/**/*.ts` +- `apps/web/modules/ee/contacts/api/v2/**/*.ts` + +#### Teams & Access Control +- `apps/web/modules/ee/teams/team-list/components/teams-view.tsx` +- `apps/web/modules/ee/role-management/actions.ts` +- `apps/web/modules/organization/settings/teams/page.tsx` +- `apps/web/modules/organization/settings/teams/actions.ts` + +#### SSO / SAML +- `apps/web/modules/ee/sso/lib/sso-handlers.ts` +- `apps/web/modules/ee/auth/saml/api/**/*.ts` +- `apps/web/modules/ee/auth/saml/lib/*.ts` +- `apps/web/modules/auth/lib/authOptions.ts` + +#### Two-Factor Authentication +- `apps/web/modules/ee/two-factor-auth/actions.ts` +- `apps/web/modules/ee/two-factor-auth/components/*.tsx` + +#### Quotas +- `apps/web/modules/ee/quotas/actions.ts` +- `apps/web/modules/ee/quotas/components/*.tsx` +- `apps/web/modules/ee/quotas/lib/*.ts` + +#### Audit Logs +- `apps/web/modules/ee/audit-logs/lib/handler.ts` +- `apps/web/modules/ee/audit-logs/lib/service.ts` + +#### Billing (Cloud Only) +- `apps/web/modules/ee/billing/page.tsx` +- `apps/web/modules/ee/billing/api/lib/*.ts` +- `apps/web/modules/ee/billing/components/*.tsx` + +### 3.3 API Routes Using Feature Checks + +| Route | Feature Check | +|-------|---------------| +| `apps/web/app/api/v1/client/[environmentId]/responses/route.ts` | Spam protection | +| `apps/web/app/api/v2/client/[environmentId]/responses/route.ts` | Spam protection | +| `apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts` | Cloud limits | +| `apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/route.ts` | Contacts | +| `apps/web/modules/api/v2/management/responses/lib/response.ts` | Cloud limits | + +### 3.4 UI Pages with Conditional Rendering + +| Page | Condition | +|------|-----------| +| `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/` | Cloud only | +| `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx` | On-premise only | +| `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/domain/page.tsx` | On-premise only | +| `apps/web/app/p/[slug]/page.tsx` (Pretty URLs) | On-premise only | + +--- + +## 4. Configuration & Environment Variables + +### 4.1 Key Environment Variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `IS_FORMBRICKS_CLOUD` | Enables cloud mode | `"0"` | +| `ENTERPRISE_LICENSE_KEY` | License key for on-premise | (empty) | +| `STRIPE_SECRET_KEY` | Stripe API key (Cloud) | (empty) | +| `AUDIT_LOG_ENABLED` | Enable audit logs | `"0"` | +| `SAML_DATABASE_URL` | SAML configuration DB | (empty) | + +### 4.2 Database Schema + +```prisma +// Organization billing stored in JSON column +billing: { + stripeCustomerId: string | null, + plan: "free" | "startup" | "scale" | "enterprise", + period: "monthly" | "yearly", + limits: { + projects: number | null, + monthly: { + responses: number | null, + miu: number | null, + } + }, + periodStart: Date | null +} +``` + +--- + +## 5. Problems with Current Approach + +### 5.1 Code Duplication + +Almost every feature check function has this pattern: +```typescript +if (IS_FORMBRICKS_CLOUD) { + // Check billing plan +} else { + // Check license feature +} +``` + +This is repeated in: +- 8+ permission check functions in `utils.ts` +- 30+ files that consume these functions +- Multiple API routes and pages + +### 5.2 Inconsistent Feature Gating + +| Feature | Cloud Gating | On-Premise Gating | +|---------|--------------|-------------------| +| Remove Branding | `plan !== FREE` | `license.features.removeBranding` | +| Multi-Language | `plan === CUSTOM` OR `license.multiLanguageSurveys` | `license.multiLanguageSurveys` | +| Follow-ups | `plan === CUSTOM` | Always allowed | +| SAML SSO | Never allowed | `license.sso && license.saml` | +| Teams | `plan === CUSTOM` OR `license.accessControl` | `license.accessControl` | + +### 5.3 Confusing License Requirement on Cloud + +Cloud deployments still require `ENTERPRISE_LICENSE_KEY` to be set for enterprise features to work: +```typescript +// utils.ts - getFeaturePermission +if (IS_FORMBRICKS_CLOUD) { + return license.active && billingPlan !== PROJECT_FEATURE_KEYS.FREE; + // ^^^^^^^^^^^^^^ Still checks license! +} +``` + +This means Cloud needs **both**: +1. Active billing plan (Stripe subscription) +2. Active enterprise license + +### 5.4 Fallback Logic Complexity + +```typescript +const featureFlagFallback = async (billingPlan) => { + const license = await getEnterpriseLicense(); + if (IS_FORMBRICKS_CLOUD) return license.active && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM; + else if (!IS_FORMBRICKS_CLOUD) return license.active; + return false; +}; +``` + +Features have "fallback" behavior for backwards compatibility, adding another layer of complexity. + +### 5.5 Testing Complexity + +Tests must mock both: +- `IS_FORMBRICKS_CLOUD` constant +- `getEnterpriseLicense()` function +- `organization.billing.plan` in some cases + +See: `apps/web/modules/ee/license-check/lib/utils.test.ts` (400+ lines of test mocking) + +--- + +## 6. Feature Availability Matrix + +| Feature | Free (Cloud) | Startup (Cloud) | Custom (Cloud) | No License (On-Prem) | License (On-Prem) | +|---------|--------------|-----------------|----------------|---------------------|-------------------| +| Remove Branding | ❌ | ✅ | ✅ | ❌ | ✅* | +| Whitelabel | ❌ | ✅ | ✅ | ❌ | ✅* | +| Multi-Language | ❌ | ❌ | ✅ | ❌ | ✅* | +| Teams & Roles | ❌ | ❌ | ✅ | ❌ | ✅* | +| Contacts | ❌ | ❌ | ❌ | ❌ | ✅* | +| SSO (OIDC) | ❌ | ❌ | ❌ | ❌ | ✅* | +| SAML SSO | ❌ | ❌ | ❌ | ❌ | ✅* | +| 2FA | ❌ | ❌ | ❌ | ❌ | ✅* | +| Audit Logs | ❌ | ❌ | ❌ | ❌ | ✅* | +| Quotas | ❌ | ❌ | ✅ | ❌ | ✅* | +| Spam Protection | ❌ | ❌ | ✅ | ❌ | ✅* | +| Follow-ups | ❌ | ❌ | ✅ | ✅ | ✅ | +| Pretty URLs | ❌ | ❌ | ❌ | ✅ | ✅ | +| Projects Limit | 3 | 3 | Custom | 3 | Custom* | + +*Depends on specific license feature flags + +--- + +## 7. Recommendations for Refactoring + +### 7.1 Unified Feature Access Layer + +Create a single `FeatureAccess` service that abstracts the deployment type: + +```typescript +interface FeatureAccessService { + canAccessFeature(feature: FeatureKey, context: AccessContext): Promise; + getLimit(limit: LimitKey, context: LimitContext): Promise; +} +``` + +### 7.2 Normalize Feature Flags + +Both Cloud and On-Premise should use the same feature flag schema. Cloud billing plans should map to predefined feature sets. + +### 7.3 Remove License Requirement from Cloud + +Cloud should not need `ENTERPRISE_LICENSE_KEY`. The license server should be bypassed entirely, with features controlled by billing plan. + +### 7.4 Consider Feature Entitlements + +Move to an "entitlements" model where: +- Cloud: Stripe subscription metadata defines entitlements +- On-Premise: License API returns entitlements + +Both resolve to the same `TFeatureEntitlements` type. + +--- + +## 8. Files That Would Need Changes + +### High Priority (Core Logic) +1. `apps/web/modules/ee/license-check/lib/license.ts` +2. `apps/web/modules/ee/license-check/lib/utils.ts` +3. `apps/web/lib/constants.ts` + +### Medium Priority (Feature Implementations) +4. All files in `apps/web/modules/ee/*/actions.ts` +5. `apps/web/modules/environments/lib/utils.ts` +6. `apps/web/modules/survey/lib/permission.ts` +7. `apps/web/modules/survey/follow-ups/lib/utils.ts` + +### Lower Priority (UI Conditional Rendering) +8. Settings pages with `IS_FORMBRICKS_CLOUD` checks +9. `UpgradePrompt` component usages +10. Navigation components + +--- + +## 9. Summary + +The current implementation has **organic complexity** from evolving independently for Cloud and On-Premise deployments. A refactor should: + +1. **Unify** the feature access mechanism behind a single interface +2. **Simplify** by removing the dual-check pattern +3. **Normalize** feature definitions across deployment types +4. **Test** with a cleaner mocking strategy + +This would reduce the 100+ files touching enterprise features to a single source of truth, making the codebase more maintainable and reducing bugs from inconsistent feature gating. diff --git a/ENTERPRISE_PRD.md b/ENTERPRISE_PRD.md new file mode 100644 index 0000000000..e39d1fbaa4 --- /dev/null +++ b/ENTERPRISE_PRD.md @@ -0,0 +1,428 @@ +I've spent ~2 days iterating over this, setting up Stripe, building our update pricing table, etc. So even though the formatting suggests this to be AI Slob, it's hand-crafted and I've read every line to make sure there is no misleading information 😇 + +------ + +### Unified Billing & Feature Access + +**Document Version:** 2.1 +**Last Updated:** January 17, 2026 +**Status:** Ready for development + +--- + +## 1. Executive Summary + +Formbricks Cloud needs a unified, Stripe-native approach to billing, feature entitlements, and usage metering. The current implementation has billing logic scattered throughout the codebase, making it difficult to maintain pricing consistency and add new features. + +This PRD outlines the requirements for: +1. Using Stripe as the single source of truth for features and billing +2. Implementing usage-based billing with graduated pricing +3. Giving customers control through spending caps + +**Scope**: This initiative focuses on Formbricks Cloud. On-Premise licensing will be addressed separately. + +--- + +## 2. Problem Statement + +### Current Pain Points + +1. **Scattered Billing Logic**: Feature availability is determined by code checks against `organization.billing.plan`, requiring code changes for any pricing adjustment. + +2. **Inconsistent Feature Gating**: Different features use different patterns to check access, making it unclear what's available on each plan. + +3. **No Usage-Based Billing**: Current plans have hard limits. Customers hitting limits must upgrade to a higher tier even if they only need slightly more capacity. + +4. **No Spending Controls**: Customers on usage-based plans have no way to cap their spending. + +5. **Manual Usage Tracking**: Response and user counts are tracked locally without integration to billing. + +--- + +## 3. Goals + +1. **Stripe as Source of Truth**: All feature entitlements and pricing come from Stripe, not hardcoded in the application. + +2. **Usage-Based Billing**: Implement graduated pricing where customers pay for what they use beyond included amounts. + +3. **Customer Control**: Allow customers to set spending caps to avoid unexpected charges. + +4. **Proactive Communication**: Notify customers as they approach usage limits. + +--- + +## 4. Feature Requirements + +### 4.1 Stripe as Single Source of Truth + +**Requirement**: The Formbricks instance should not contain billing or pricing logic. All feature availability must be determined by querying Stripe. + +**What This Means**: +- No hardcoded plan names or feature mappings in the codebase +- No `if (plan === 'pro')` style checks +- Feature checks query Stripe Entitlements API +- Pricing displayed in UI is fetched from Stripe Products/Prices +- Plan changes take effect immediately via Stripe webhooks + +**Benefits**: +- Change pricing without code deployment +- Add new plans without code changes +- A/B test pricing externally +- Single source of truth for sales, support, and product + +--- + +### 4.2 Stripe Entitlements for Feature Access + +**Requirement**: Use Stripe's Entitlements API to determine which features each customer can access. + +**How It Works**: +1. Define Features in Stripe (see inventory below) +2. Attach Features to Products via ProductFeature +3. When customer subscribes, Stripe creates Active Entitlements +4. Application checks entitlements before enabling features +5. Stripe is already setup correctly with all Products & Features ✅ + +**Multi-Item Subscriptions Simplify Entitlements**: +- Each plan subscription includes multiple prices (flat fee + metered usage) on the **same Product** +- Since all prices belong to one Product, calling `stripe.entitlements.activeEntitlements.list()` returns all features for that plan automatically +- No need to check multiple products or stitch together entitlements from separate subscriptions + +**Feature Inventory (not up-to-date but you get the idea)**: + +| Feature Name | Lookup Key | Description | +|--------------|------------|-------------| +| Hide Branding | `hide-branding` | Hide "Powered by Formbricks" | +| API Access | `api-access` | Gates API key generation & API page access | +| Integrations | `integrations` | Gates integrations page & configuration | +| Custom Webhooks | `webhooks` | Webhook integrations | +| Email Follow-ups | `follow-ups` | Automated email follow-ups | +| Custom Links in Surveys | `custom-links-in-surveys` | Custom links within surveys | +| Custom Redirect URL | `custom-redirect-url` | Custom thank-you redirects | +| Two Factor Auth | `two-fa` | 2FA for user accounts | +| Contacts & Segments | `contacts` | Contact management & segmentation | +| Teams & Access Roles | `rbac` | Team-based permissions | +| Quota Management | `quota-management` | Response quota controls | +| Spam Protection | `spam-protection` | reCAPTCHA integration | +| Workspace Limit 1 | `workspace-limit-1` | Up to 1 workspaces | +| Workspace Limit 3 | `workspace-limit-3` | Up to 3 workspaces | +| Workspace Limit 5 | `workspace-limit-5` | Up to 5 workspaces | + +Image + +--- + +### 4.3 Plan Structure + +**Plans & Pricing**: + +| Plan | Monthly Price | Annual Price | Savings | +|------|---------------|--------------|---------| +| **Hobby** | Free | Free | — | +| **Pro** | $89/month | $890/year | 2 months free | +| **Scale** | $390/month | $3,900/year | 2 months free | + +**Usage Limits**: + +| Plan | Workspaces | Responses/mo | Contacts/mo | Overage Billing | +|------|------------|--------------|-------------|-----------------| +| **Hobby** | 1 | 250 | — | No | +| **Pro** | 3 | 2,000 | 5,000 | Yes | +| **Scale** | 5 | 5,000 | 10,000 | Yes | + +**Note**: Hobby plan does not include Respondent Identification or Contact Management. Overage billing is only available on Pro and Scale plans. + +Image + +--- + +### 4.4 Restricted Features (Hobby & Trial Exclusions) + +**Requirement**: Certain high-risk features must be excluded from Free (Hobby) plan AND Trial users to prevent fraud and abuse. Other features are included in Trial to maximize conversion. + +**Restricted Features (blocked from Hobby + Trial)**: + +| Feature | Lookup Key | Abuse Risk | Why Restricted | +|---------|------------|------------|----------------| +| Custom Redirect URL | `custom-redirect-url` | High | Phishing redirects after survey | +| Custom Links in Surveys | `custom-links-in-surveys` | High | Malicious link distribution in survey content | + +**Trial-Included Features (to drive conversion)**: + +| Feature | Lookup Key | Why Included in Trial | +|---------|------------|----------------------| +| Webhooks | `webhooks` | Low abuse risk, high setup effort = conversion driver | +| API Access | `api-access` | Low abuse risk, high integration value | +| Integrations | `integrations` | Low abuse risk, high integration value | +| Email Follow-ups | `follow-ups` | Requires email verification, monitored | +| Hide Branding | `hide-branding` | No abuse risk, strong conversion driver | +| RBAC | `rbac` | No abuse risk, team adoption driver | +| Spam Protection | `spam-protection` | Actually prevents abuse | +| Quota Management | `quota-management` | Administrative feature | + +**Implementation**: +- Restricted features are NOT attached to Hobby or Trial products in Stripe +- Trial includes most Pro/Scale features to maximize value demonstration +- Application checks entitlements via Stripe API - if feature not present, show existing upgrade UI + +--- + +### 4.5 Usage-Based Billing with Graduated Pricing + +Image + +**Requirement**: Implement usage-based billing where customers pay a base fee that includes a usage allowance, with flat overage pricing. + +**Metrics to Meter**: + +| Metric | Event Name | Description | +|--------|------------|-------------| +| **Responses** | `response_created` | Survey responses submitted | +| **Identified Contacts** | `unique_contact_identified` | Unique contacts identified per month | + +**Identified Contacts Definition (ON HOLD)**: +An identified contact is one that has been identified in the current billing period via: +- SDK call: `formbricks.setUserId(userId)` +- Personal Survey Link access +- This OUT OF SCOPE for the first iteration to not become a blocker. We can add it if all works end-to-end + +**Counting Rules**: +- Each contact identification counts (even if same contact identified multiple times via different methods) +- Same contact re-accessing their personal link = 1 count (same contact) +- Billing period is monthly (even for annual subscribers) +- Meter events sent immediately (real-time) + +**Hard Limits via Stripe Metering**: +- Usage is metered through Stripe for billing AND enforcement +- When included usage is exhausted, overage rates apply +- No separate local limit enforcement needed + +--- + +### 4.6 Spending Caps + +**Requirement**: Customers must be able to set a maximum monthly spend for usage-based charges. + +**Behavior**: + +| Cap Setting | Effect | +|-------------|--------| +| No cap (default) | Usage billed without limit | +| Cap with "Warn" | Notifications sent, billing continues | +| Cap with "Pause" | Surveys paused when cap reached | + +**Configuration**: +- Minimum spending cap: **$10** +- No grace period when cap is hit +- Immediate pause if "Pause" mode selected +- Stripe does not provide spending caps out of the box, this is something we need to custom develop + +**When Cap is Reached (Pause mode)**: +- All surveys for the organization stop collecting responses (needs to be implemented) +- Existing responses are preserved +- In-app banner explains the pause +- Email notification sent to billing contacts +- Owner can lift pause or increase cap + +Image + +_The Pause vs. Alert mode is missing so far._ + +--- + +### 4.7 Usage Alerts via Stripe Meter Alerts + +**Requirement**: Proactively notify customers as they approach their included usage limits. + +**Alert Thresholds**: + +| Threshold | Notification | +|-----------|--------------| +| 80% of included | Email notification | +| 90% of included | Email + in-app banner | +| 100% of included | Email + in-app + (if cap) action | + +**Notification Content**: +- Current usage vs included amount +- What happens next (overage pricing applies) +- Link to upgrade or adjust spending cap + +Image + +--- + +### 4.8 Annual Billing with Monthly Limits + +**Requirement**: Support annual payment option while keeping all usage limits monthly. + +**Behavior**: +- Annual subscribers pay upfront for 12 months +- **2 months free** discount (annual = 10x monthly price) +- Usage limits reset monthly (same as monthly subscribers) +- Overage is billed monthly +- Example: Annual Pro pays $890/year, gets 2,000 responses/month every month + +Image + +--- + +### 4.9 Reverse Trial Experience + +**Requirement**: New users should experience premium features immediately through a Reverse Trial model. + +**Trigger**: We have UI to present to them to opt into the free trial + +**Trial Terms**: +- Duration: 14 days +- Features: Enroll to Trial Product (free) +- Limits: We have to see how to enforce those, gotta check what Stripe API offers us. Probably a dedicated Trial Meter +- No payment required to start +- Stripe customer created immediately (for metering) + +**Post-Trial (No Conversion)**: +- Downgrade to Hobby (Free) tier immediately +- Pro features disabled immediately +- Data preserved but locked behind upgrade + +--- + +### 4.10 Stripe Customer Creation on Signup + +**Requirement**: Create a Stripe customer immediately when a new organization is created. + +**Rationale**: +- Enables usage metering from day one +- Stripe handles hard limits via metering +- Simplifies upgrade flow (customer already exists) + +**What Gets Created**: +- Stripe Customer with organization ID in metadata +- No subscription (Hobby tier has no subscription) +- No payment method (added on first upgrade) + +--- + +### 5.1 Subscription Architecture: Multi-Item Subscriptions + +**Key Insight**: Each plan uses a **Subscription with Multiple Items** — one flat-fee price and metered usage prices, all belonging to the same Product. This allows us to charge for the base plan, meter and charge per used item. + +**How It Works**: + +```javascript +// Creating a Pro subscription with flat fee + usage metering +const subscription = await stripe.subscriptions.create({ + customer: 'cus_12345', + items: [ + { price: 'price_pro_monthly' }, // $89/mo flat fee + { price: 'price_pro_responses_usage' }, // Metered responses + { price: 'price_pro_contacts_usage' }, // Metered contacts + ], +}); +``` + +**Why This Matters for Entitlements**: +- All prices belong to the **same Product** (e.g., `prod_ProPlan`) +- Stripe Entitlements API automatically returns all features attached to that Product +- No need to check multiple products or subscriptions +- Single source of truth for feature access + +**What Customers See** (Single Invoice): + +| Description | Qty | Amount | +|-------------|-----|--------| +| Pro Plan (Jan 1 - Feb 1) | 1 | $89.00 | +| Pro Plan - Responses (Jan 1 - Feb 1) | 1,500 (First 1,000 included) | $40.00 | +| Pro Plan - Contacts (Jan 1 - Feb 1) | 2,500 (All included) | $0.00 | +| **Total** | | **$129.00** | + +### 5.2 Products & Prices + +Each plan Product contains multiple Prices: + +| Product | Stripe ID | Prices | +|---------|-----------|--------| +| **Hobby Tier** | `prod_ToYKB5ESOMZZk5` | Free (no subscription required) | +| **Pro Tier** | `prod_ToYKQ8WxS3ecgf` | `price_pro_monthly` ($89), `price_pro_yearly` ($890), `price_pro_usage_responses`, `price_pro_usage_contacts` | +| **Scale Tier** | `prod_ToYLW5uCQTMa6v` | `price_scale_monthly` ($390), `price_scale_yearly` ($3,900), `price_scale_usage_responses`, `price_scale_usage_contacts` | +| **Trial Tier** | `prod_TodVcJiEnK5ABK` | `price_trial_free` ($0), metered prices for tracking | + +**Note**: Response and Contact metered prices use **graduated tiers** where the first N units are included (priced at $0), then overage rates apply. + + +--- + +## 6. Non-Functional Requirements + +### 6.1 Performance + +- Entitlement checks: <100ms (p50), <200ms (p99) with caching +- Usage metering: Non-blocking, immediate send +- Spending cap checks: <50ms + +### 6.2 Reliability + +- Stripe unavailable: Use cached entitlements (max 5 min stale) +- Meter event fails: Queue for retry (at-least-once delivery) +- Webhook missed: Entitlements auto-refresh on access + +### 6.3 Data Consistency + +- Stripe is source of truth +- Local `organization.billing` is a cache only +- Cache invalidated via webhooks + +--- + +## 7. Migration Considerations + +### Existing Customers with Custom Limits + +**Problem**: Some existing customers have negotiated custom limits that don't fit the new plan structure. + +**Approach**: Grandfather indefinitely on legacy pricing until they choose to migrate. + +- Existing customers keep their current plans and limits +- No forced migration +- New billing system only applies to new signups and customers who voluntarily upgrade/change plans +- Legacy customers get a simplified view with the new usage meters UI and a the "Manage subscription" button. We hide all of the other UI and prompt them to reach out to Support to change their pricing (Alert component) + +--- + +## 8. Key Decisions + +| Topic | Decision | +|-------|----------| +| Free tier metering | Use Stripe for hard limits (no local enforcement) | +| Annual discount | 2 months free | +| Minimum spending cap | $10 | +| Cap grace period | None (immediate) | +| Contact identification counting | Each identification counts | +| Personal link re-access | Same contact = 1 count | +| Downgrade behavior for restricted features | Immediate disable | +| Meter event timing | Immediate (real-time) | +| Currency | USD only | +| Default spending cap | No cap | +| Overage visibility | Billing page | +| Migration for custom limits | Grandfather indefinitely | + +--- + +## 9. Out of Scope + +1. **On-Premise licensing**: Will be addressed separately +2. **Self-serve downgrade**: Handled via Stripe Customer Portal +3. **Refunds**: Handled via Stripe Dashboard +4. **Tax calculation**: Handled by Stripe Tax +5. **Invoice customization**: Handled via Stripe settings + +--- + +## 10. Setting up + +**Stripe** Sandbox Cloud: Dev + +Image + +In this branch you find; +- All of the dummy UI screenshotted here. Make sure to clean up after it was successfully implemented (has dummy UI code) +- A comprehensive analysis of our current, inconsistent feature flagging called ENTERPRISE_FEATURE_ANALYSIS \ No newline at end of file diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/plan/components/select-plan-onboarding.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/plan/components/select-plan-onboarding.tsx new file mode 100644 index 0000000000..07e23920d1 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/plan/components/select-plan-onboarding.tsx @@ -0,0 +1,20 @@ +import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card"; +import { Header } from "@/modules/ui/components/header"; + +interface SelectPlanOnboardingProps { + organizationId: string; +} + +export const SelectPlanOnboarding = ({ organizationId }: SelectPlanOnboardingProps) => { + const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`; + + return ( +
+
+ +
+ ); +}; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/plan/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/plan/page.tsx new file mode 100644 index 0000000000..d658317618 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/plan/page.tsx @@ -0,0 +1,29 @@ +import { redirect } from "next/navigation"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getOrganizationAuth } from "@/modules/organization/lib/utils"; +import { SelectPlanOnboarding } from "./components/select-plan-onboarding"; + +interface PlanPageProps { + params: Promise<{ + organizationId: string; + }>; +} + +const Page = async (props: PlanPageProps) => { + const params = await props.params; + + // Only show on Cloud + if (!IS_FORMBRICKS_CLOUD) { + return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`); + } + + const { session } = await getOrganizationAuth(params.organizationId); + + if (!session?.user) { + return redirect(`/auth/login`); + } + + return ; +}; + +export default Page; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 7b507034a1..8d7ac09ca3 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -2,6 +2,7 @@ import type { Session } from "next-auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getIsFreshInstance } from "@/lib/instance/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getAccessFlags } from "@/lib/membership/utils"; @@ -66,6 +67,10 @@ const Page = async () => { if (!firstProductionEnvironmentId) { if (isOwner || isManager) { + // On Cloud, show plan selection first for new users without any projects + if (IS_FORMBRICKS_CLOUD) { + return redirect(`/organizations/${userOrganizations[0].id}/workspaces/new/plan`); + } return redirect(`/organizations/${userOrganizations[0].id}/workspaces/new/mode`); } else { return redirect(`/organizations/${userOrganizations[0].id}/landing`); diff --git a/apps/web/images/customer-logos/cal-logo-light.svg b/apps/web/images/customer-logos/cal-logo-light.svg new file mode 100644 index 0000000000..bccfe813ef --- /dev/null +++ b/apps/web/images/customer-logos/cal-logo-light.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/images/customer-logos/ethereum-logo.png b/apps/web/images/customer-logos/ethereum-logo.png new file mode 100644 index 0000000000..3b4575d984 Binary files /dev/null and b/apps/web/images/customer-logos/ethereum-logo.png differ diff --git a/apps/web/images/customer-logos/flixbus-white.svg b/apps/web/images/customer-logos/flixbus-white.svg new file mode 100644 index 0000000000..4a5335bcca --- /dev/null +++ b/apps/web/images/customer-logos/flixbus-white.svg @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/apps/web/images/customer-logos/github-logo.png b/apps/web/images/customer-logos/github-logo.png new file mode 100644 index 0000000000..adc1552ba8 Binary files /dev/null and b/apps/web/images/customer-logos/github-logo.png differ diff --git a/apps/web/images/customer-logos/pigment-logo.webp b/apps/web/images/customer-logos/pigment-logo.webp new file mode 100644 index 0000000000..0ab6efdd36 Binary files /dev/null and b/apps/web/images/customer-logos/pigment-logo.webp differ diff --git a/apps/web/images/customer-logos/siemens.png b/apps/web/images/customer-logos/siemens.png new file mode 100644 index 0000000000..2cbb825948 Binary files /dev/null and b/apps/web/images/customer-logos/siemens.png differ diff --git a/apps/web/images/customer-logos/university-of-copenhegen.png b/apps/web/images/customer-logos/university-of-copenhegen.png new file mode 100644 index 0000000000..cda523057c Binary files /dev/null and b/apps/web/images/customer-logos/university-of-copenhegen.png differ diff --git a/apps/web/modules/auth/signup/page.tsx b/apps/web/modules/auth/signup/page.tsx index 4a33751d11..119fd518ce 100644 --- a/apps/web/modules/auth/signup/page.tsx +++ b/apps/web/modules/auth/signup/page.tsx @@ -56,7 +56,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => { const emailFromSearchParams = searchParams["email"]; return ( -
+
{ - const freePlan: TPricingPlan = { + const hobbyPlan: TPricingPlan = { id: "free", - name: t("environments.settings.billing.free"), + name: "Hobby", featured: false, - description: t("environments.settings.billing.free_description"), - price: { monthly: "$0", yearly: "$0" }, - mainFeatures: [ - t("environments.settings.billing.unlimited_surveys"), - t("environments.settings.billing.1000_monthly_responses"), - t("environments.settings.billing.2000_contacts"), - t("environments.settings.billing.1_workspace"), - t("environments.settings.billing.unlimited_team_members"), - t("environments.settings.billing.link_surveys"), - t("environments.settings.billing.website_surveys"), - t("environments.settings.billing.app_surveys"), - t("environments.settings.billing.ios_android_sdks"), - t("environments.settings.billing.email_embedded_surveys"), - t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"), - t("environments.settings.billing.api_webhooks"), - t("environments.settings.billing.all_integrations"), - t("environments.settings.billing.hosted_in_frankfurt") + " 🇪🇺", + description: "Unlimited Surveys, Team Members, and more.", + price: { monthly: "Start free", yearly: "Start free" }, + usageLimits: [ + { label: "Workspaces", value: "1" }, + { label: "Responses per month", value: "250" }, + ], + features: [ + "Link-based Surveys", + "In-product Surveys", + "All Question Types", + "Multi-language Surveys incl. RTL", + "Conditional Logic", + "Hidden Fields", + "Partial Responses", + "Recall Information", + "Multi-media Backgrounds", + "File Uploads", + "Single-use Links", + "Hosted in Frankfurt 🇪🇺", ], }; - const startupPlan: TPricingPlan = { - id: "startup", - name: t("environments.settings.billing.startup"), + const proPlan: TPricingPlan = { + id: "pro", + name: "Pro", featured: true, CTA: t("common.start_free_trial"), - description: t("environments.settings.billing.startup_description"), - price: { monthly: "$49", yearly: "$490" }, - mainFeatures: [ - t("environments.settings.billing.everything_in_free"), - t("environments.settings.billing.5000_monthly_responses"), - t("environments.settings.billing.7500_contacts"), - t("environments.settings.billing.3_workspaces"), - t("environments.settings.billing.remove_branding"), - t("environments.settings.billing.attribute_based_targeting"), + description: "Everything in Free with additional features.", + price: { monthly: "$89", yearly: "$74" }, + usageLimits: [ + { label: "Workspaces", value: "3" }, + { label: "Responses per month", value: "2,000", overage: true }, + { label: "Identified Contacts per month", value: "5,000", overage: true }, + ], + features: [ + "Everything in Free", + "Unlimited Seats", + "Hide Formbricks Branding", + "Respondent Identification", + "Contact & Segment Management", + "Attribute-based Segmentation", + "iOS & Android SDKs", + "Email Follow-ups", + "Custom Webhooks", + "All Integrations", ], }; - const customPlan: TPricingPlan = { - id: "custom", - name: t("environments.settings.billing.custom"), + const scalePlan: TPricingPlan = { + id: "scale", + name: "Scale", featured: false, - CTA: t("common.request_pricing"), - description: t("environments.settings.billing.enterprise_description"), - price: { - monthly: t("environments.settings.billing.custom"), - yearly: t("environments.settings.billing.custom"), - }, - mainFeatures: [ - t("environments.settings.billing.everything_in_startup"), - t("environments.settings.billing.email_follow_ups"), - t("environments.settings.billing.custom_response_limit"), - t("environments.settings.billing.custom_contacts_limit"), - t("environments.settings.billing.custom_workspace_limit"), - t("environments.settings.billing.team_access_roles"), - t("environments.workspace.languages.multi_language_surveys"), - t("environments.settings.billing.uptime_sla_99"), - t("environments.settings.billing.premium_support_with_slas"), + CTA: t("common.start_free_trial"), + description: "Advanced features for scaling your business.", + price: { monthly: "$390", yearly: "$325" }, + usageLimits: [ + { label: "Workspaces", value: "5" }, + { label: "Responses per month", value: "5,000", overage: true }, + { label: "Identified Contacts per month", value: "10,000", overage: true }, ], - href: "https://formbricks.com/custom-plan?source=billingView", + features: [ + "Everything in Pro", + "Teams & Access Roles", + "Full API Access", + "Quota Management", + "Two-Factor Auth", + "Spam Protection (ReCaptcha)", + ], + addons: ["SSO Enforcement", "Custom SSO", "Hosting in USA 🇺🇸", "SOC-2 Verification"], }; return { - plans: [freePlan, startupPlan, customPlan], + plans: [hobbyPlan, proPlan, scalePlan], }; }; diff --git a/apps/web/modules/ee/billing/components/overage-card.tsx b/apps/web/modules/ee/billing/components/overage-card.tsx new file mode 100644 index 0000000000..479b16de54 --- /dev/null +++ b/apps/web/modules/ee/billing/components/overage-card.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { PencilIcon, TrashIcon } from "lucide-react"; +import { useState } from "react"; +import { cn } from "@/lib/cn"; +import { Button } from "@/modules/ui/components/button"; +import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; +import { Input } from "@/modules/ui/components/input"; +import { Label } from "@/modules/ui/components/label"; +import { TabToggle } from "@/modules/ui/components/tab-toggle"; +import { SettingsId } from "./settings-id"; + +type OverageMode = "allow" | "blocked"; + +interface OverageUsage { + responses: number; + responseCost: number; + contacts: number; + contactsCost: number; +} + +interface OverageCardProps { + currentMode: OverageMode; + spendingLimit: number | null; + overageUsage: OverageUsage; + onModeChange: (mode: OverageMode) => void | Promise; + onSpendingLimitChange: (limit: number | null) => void | Promise; +} + +const OVERAGE_MODE_CONFIG: Record< + OverageMode, + { + label: string; + tooltip: string; + } +> = { + allow: { + label: "Allow", + tooltip: + "You're currently allowing additional responses over your included response volumes. This is good to keep your surveys running and contacts identified.", + }, + blocked: { + label: "Blocked", + tooltip: + "Overage is blocked. When you reach your included limits, surveys will stop collecting responses and contacts won't be identified until the next billing cycle.", + }, +}; + +const MIN_SPENDING_LIMIT = 10; + +export const OverageCard = ({ + currentMode, + spendingLimit, + overageUsage, + onModeChange, + onSpendingLimitChange, +}: OverageCardProps) => { + const [isChanging, setIsChanging] = useState(false); + const [isEditingLimit, setIsEditingLimit] = useState(false); + const [limitInput, setLimitInput] = useState(spendingLimit?.toString() ?? ""); + const [limitError, setLimitError] = useState(null); + const [showBlockConfirmation, setShowBlockConfirmation] = useState(false); + + const totalOverageCost = overageUsage.responseCost + overageUsage.contactsCost; + + const handleModeChange = (newMode: OverageMode) => { + if (newMode === currentMode) return; + + // Show confirmation when switching to blocked with outstanding overage + if (newMode === "blocked" && currentMode === "allow" && totalOverageCost > 0) { + setShowBlockConfirmation(true); + return; + } + + executeModeChange(newMode); + }; + + const executeModeChange = (newMode: OverageMode) => { + setIsChanging(true); + Promise.resolve(onModeChange(newMode)).finally(() => { + setIsChanging(false); + setShowBlockConfirmation(false); + }); + }; + + const handleSaveLimit = () => { + const value = Number.parseFloat(limitInput); + + if (limitInput === "" || Number.isNaN(value)) { + setLimitError("Please enter a valid amount"); + return; + } + + if (value < MIN_SPENDING_LIMIT) { + setLimitError(`Minimum spending limit is $${MIN_SPENDING_LIMIT}`); + return; + } + + setLimitError(null); + setIsChanging(true); + Promise.resolve(onSpendingLimitChange(value)).finally(() => { + setIsChanging(false); + setIsEditingLimit(false); + }); + }; + + const handleRemoveLimit = () => { + setIsChanging(true); + Promise.resolve(onSpendingLimitChange(null)).finally(() => { + setIsChanging(false); + setLimitInput(""); + }); + }; + + const handleCancelEdit = () => { + setIsEditingLimit(false); + setLimitInput(spendingLimit?.toString() ?? ""); + setLimitError(null); + }; + + const handleStartEdit = () => { + setLimitInput(spendingLimit?.toString() ?? ""); + setIsEditingLimit(true); + }; + + return ( +
+
+ + +
+ handleModeChange(value as OverageMode)} + disabled={isChanging} + /> +
+
+ + {/* Overage Usage Meters (only when Allow mode) */} + {currentMode === "allow" && ( +
+ {/* Responses Overage */} +
+
+

Responses

+ + Overage + +
+

+ {overageUsage.responses.toLocaleString()} +

+

${overageUsage.responseCost.toFixed(2)} this month

+
+ + {/* Contacts Overage */} +
+
+

Identified Contacts

+ + Overage + +
+

+ {overageUsage.contacts.toLocaleString()} +

+

${overageUsage.contactsCost.toFixed(2)} this month

+
+
+ )} + + {/* Total Overage Cost & Spending Limit Section (only when Allow mode) */} + {currentMode === "allow" && ( +
+ {/* Spending Stats Header */} +
+ + {spendingLimit && ( + + )} +
+ + {/* Spending Limit Progress Bar (if limit is set) */} + {spendingLimit && ( +
+
+
0.9 ? "bg-red-500" : "bg-teal-500" + )} + style={{ width: `${Math.min((totalOverageCost / spendingLimit) * 100, 100)}%` }} + /> +
+

+ {((totalOverageCost / spendingLimit) * 100).toFixed(0)}% of spending limit used +

+
+ )} + + {/* Action Buttons */} +
+ {isEditingLimit && ( +
+ +
+
+ $ + { + setLimitInput(e.target.value); + setLimitError(null); + }} + placeholder={`${MIN_SPENDING_LIMIT}`} + className="pl-7" + isInvalid={!!limitError} + /> +
+ + +
+ {limitError &&

{limitError}

} +
+ )} + + {!isEditingLimit && spendingLimit && ( +
+ + +
+ )} + + {!isEditingLimit && !spendingLimit && ( + + )} +
+
+ )} + + {/* Confirmation Modal for blocking with outstanding overage */} + executeModeChange("blocked")} + /> +
+ ); +}; diff --git a/apps/web/modules/ee/billing/components/pricing-card.tsx b/apps/web/modules/ee/billing/components/pricing-card.tsx index 43624a8a4e..00d05f1625 100644 --- a/apps/web/modules/ee/billing/components/pricing-card.tsx +++ b/apps/web/modules/ee/billing/components/pricing-card.tsx @@ -1,11 +1,9 @@ "use client"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, PlusIcon } from "lucide-react"; import { useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations"; import { cn } from "@/lib/cn"; -import { Badge } from "@/modules/ui/components/badge"; import { Button } from "@/modules/ui/components/button"; import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; import { TPricingPlan } from "../api/lib/constants"; @@ -16,11 +14,8 @@ interface PricingCardProps { organization: TOrganization; onUpgrade: () => Promise; onManageSubscription: () => Promise; - projectFeatureKeys: { - FREE: string; - STARTUP: string; - CUSTOM: string; - }; + isTrialActive?: boolean; + currentPlan: string; } export const PricingCard = ({ @@ -29,178 +24,246 @@ export const PricingCard = ({ onUpgrade, onManageSubscription, organization, - projectFeatureKeys, + isTrialActive = false, + currentPlan, }: PricingCardProps) => { - const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [contactModalOpen, setContactModalOpen] = useState(false); - const displayPrice = (() => { - if (plan.id === projectFeatureKeys.CUSTOM) { - return plan.price.monthly; - } - return planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly; - })(); + const displayPrice = planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly; + const isMonetaryPrice = displayPrice.startsWith("$"); const isCurrentPlan = useMemo(() => { - if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) { - return true; - } + if (currentPlan === "free" && plan.id === "free") return true; + if (currentPlan === "pro" && plan.id === "pro") return true; + if (currentPlan === "scale" && plan.id === "scale") return true; + return false; + }, [currentPlan, plan.id]); - if (organization.billing.plan === projectFeatureKeys.CUSTOM && plan.id === projectFeatureKeys.CUSTOM) { - return true; - } + const hasActiveSubscription = + !!organization.billing.stripeCustomerId && isCurrentPlan && plan.id !== "free"; - return organization.billing.plan === plan.id && organization.billing.period === planPeriod; - }, [ - organization.billing.period, - organization.billing.plan, - plan.id, - planPeriod, - projectFeatureKeys.CUSTOM, - projectFeatureKeys.FREE, - ]); + // Check if this is the "other" paid plan (for Change plan button) + const isOtherPaidPlan = + (currentPlan === "pro" && plan.id === "scale") || (currentPlan === "scale" && plan.id === "pro"); const CTAButton = useMemo(() => { - if (isCurrentPlan) { - return null; - } - - if (plan.id === projectFeatureKeys.CUSTOM) { + // Trial state: show "Subscribe now" to convert + if (isTrialActive && plan.id === "scale") { return ( ); } - if (plan.id === projectFeatureKeys.STARTUP) { - if (organization.billing.plan === projectFeatureKeys.FREE) { - return ( - - ); - } - + // Current paid plan with subscription + if (hasActiveSubscription) { return ( ); } + // Current plan (free/hobby) + if (isCurrentPlan && plan.id === "free") { + return ( + + ); + } + + // Free plan for non-free users - cannot downgrade + if (plan.id === "free" && currentPlan !== "free") { + return ( + + ); + } + + // If user is on a paid plan and this is the other paid plan, show "Change plan" + if (isOtherPaidPlan) { + return ( + + ); + } + + // User is on Hobby, show "Upgrade" for paid plans + if (currentPlan === "free" && (plan.id === "pro" || plan.id === "scale")) { + return ( + + ); + } + + // Default fallback + return ( + + ); + }, [ + currentPlan, + hasActiveSubscription, + isCurrentPlan, + isOtherPaidPlan, + isTrialActive, + loading, + onManageSubscription, + onUpgrade, + plan.featured, + plan.id, + ]); + + // Determine badge to show + const getBadge = () => { + // Active trial badge + if (isTrialActive && plan.id === "scale") { + return ( + + Active trial + + ); + } + + // Current plan badge (for paid plans) + if (isCurrentPlan && plan.id !== "free") { + return ( + + Current plan + + ); + } + + // Only show "Most popular" if user is on Hobby plan + if (plan.featured && currentPlan === "free" && !isCurrentPlan) { + return ( + + Most popular + + ); + } + return null; - }, [ - isCurrentPlan, - loading, - onUpgrade, - organization.billing.plan, - plan.CTA, - plan.featured, - plan.href, - plan.id, - projectFeatureKeys.CUSTOM, - projectFeatureKeys.FREE, - projectFeatureKeys.STARTUP, - t, - ]); + }; + + // Highlight the current plan card or the featured card for Hobby users + const shouldHighlight = isCurrentPlan || (plan.featured && currentPlan === "free"); return (
-
-
-

- {plan.name} -

- {isCurrentPlan && ( - - )} + {/* Header */} +
+
+

{plan.name}

+ {getBadge()}
-
-
-

- {displayPrice} -

- {plan.id !== projectFeatureKeys.CUSTOM && ( -
-

- / {planPeriod === "monthly" ? "Month" : "Year"} -

+ + {/* Price */} +
+ {displayPrice} + {isMonetaryPrice && / Month} +
+ + {/* CTA Button */} +
{CTAButton}
+
+ + {/* Usage Limits */} +
+

Usage

+
+ {plan.usageLimits.map((limit) => ( +
+
+ {limit.label} + {limit.overage && Overage billing available}
- )} -
- - {CTAButton} - - {plan.id !== projectFeatureKeys.FREE && isCurrentPlan && ( - - )} + {limit.value} +
+ ))}
-
-
    - {plan.mainFeatures.map((mainFeature) => ( -
  • -
+ + {/* Features */} +
+

Features

+
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ + {/* Addons (if any) */} + {plan.addons && plan.addons.length > 0 && ( +
+

Available Add-ons

+
    + {plan.addons.map((addon) => ( +
  • + + {addon}
  • ))}
-
+ )} = { + trial: { + plan: "scale", + displayName: "Scale (Trial)", + limits: { responses: 5000, miu: 10000, projects: 5 }, + hasStripeCustomer: false, + trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days from now + billingPeriod: "monthly", + }, + hobby: { + plan: "free", + displayName: "Hobby", + limits: { responses: 500, miu: 1250, projects: 1 }, + hasStripeCustomer: false, + trialEndsAt: null, + billingPeriod: "monthly", + }, + pro: { + plan: "pro", + displayName: "Pro", + limits: { responses: 1000, miu: 2500, projects: 3 }, + hasStripeCustomer: true, + trialEndsAt: null, + billingPeriod: "monthly", + }, + scale: { + plan: "scale", + displayName: "Scale", + limits: { responses: 5000, miu: 10000, projects: 5 }, + hasStripeCustomer: true, + trialEndsAt: null, + billingPeriod: "monthly", + }, +}; interface PricingTableProps { organization: TOrganization; @@ -23,11 +85,6 @@ interface PricingTableProps { STARTUP_MAY25_MONTHLY: string; STARTUP_MAY25_YEARLY: string; }; - projectFeatureKeys: { - FREE: string; - STARTUP: string; - CUSTOM: string; - }; hasBillingRights: boolean; } @@ -35,7 +92,6 @@ export const PricingTable = ({ environmentId, organization, peopleCount, - projectFeatureKeys, responseCount, projectCount, stripePriceLookupKeys, @@ -46,8 +102,17 @@ export const PricingTable = ({ organization.billing.period ?? "monthly" ); - const handleMonthlyToggle = (period: TOrganizationBillingPeriod) => { - setPlanPeriod(period); + // Demo mode state + const [demoPlan, setDemoPlan] = useState(null); + const [demoOverageMode, setDemoOverageMode] = useState<"allow" | "blocked">("allow"); + const [demoSpendingLimit, setDemoSpendingLimit] = useState(null); + + // Demo overage usage (simulated values for demo) + const demoOverageUsage = { + responses: 150, + responseCost: 12, // $0.08/response for Pro + contacts: 320, + contactsCost: 12.8, // $0.04/contact for Pro }; const router = useRouter(); @@ -65,7 +130,48 @@ export const PricingTable = ({ checkSubscriptionStatus(); }, [organization.id]); + // Get effective values based on demo mode + const demoConfig = demoPlan ? DEMO_PLAN_CONFIGS[demoPlan] : null; + const effectivePlan = demoConfig?.plan ?? organization.billing.plan; + const effectiveDisplayName = demoConfig?.displayName ?? getPlanDisplayName(organization.billing.plan); + const effectiveResponsesLimit = + demoConfig?.limits.responses ?? organization.billing.limits.monthly.responses; + const effectiveMiuLimit = demoConfig?.limits.miu ?? organization.billing.limits.monthly.miu; + const effectiveProjectsLimit = demoConfig?.limits.projects ?? organization.billing.limits.projects; + const effectiveHasStripeCustomer = demoConfig?.hasStripeCustomer ?? !!organization.billing.stripeCustomerId; + const effectiveTrialEndsAt = demoConfig?.trialEndsAt ?? null; + const effectiveBillingPeriod = demoConfig?.billingPeriod ?? organization.billing.period ?? "monthly"; + + // Determine if user is on a paid plan + const isOnPaidPlan = effectivePlan === "pro" || effectivePlan === "scale"; + const isOnMonthlyBilling = effectiveBillingPeriod === "monthly" && isOnPaidPlan; + + // Create a mock organization for the pricing cards when in demo mode + const effectiveOrganization: TOrganization = demoPlan + ? { + ...organization, + billing: { + ...organization.billing, + plan: effectivePlan, + period: effectiveBillingPeriod, + stripeCustomerId: effectiveHasStripeCustomer ? "demo_stripe_id" : null, + limits: { + ...organization.billing.limits, + monthly: { + responses: effectiveResponsesLimit, + miu: effectiveMiuLimit, + }, + projects: effectiveProjectsLimit, + }, + }, + } + : organization; + const openCustomerPortal = async () => { + if (demoPlan) { + toast.success("Demo: Would open Stripe Customer Portal"); + return; + } const manageSubscriptionResponse = await manageSubscriptionAction({ environmentId, }); @@ -74,7 +180,11 @@ export const PricingTable = ({ } }; - const upgradePlan = async (priceLookupKey) => { + const upgradePlan = async (priceLookupKey: string) => { + if (demoPlan) { + toast.success(`Demo: Would upgrade to ${priceLookupKey}`); + return; + } try { const upgradePlanResponse = await upgradePlanAction({ environmentId, @@ -107,7 +217,8 @@ export const PricingTable = ({ }; const onUpgrade = async (planId: string) => { - if (planId === "startup") { + // Map new plan IDs to existing Stripe keys + if (planId === "pro") { await upgradePlan( planPeriod === "monthly" ? stripePriceLookupKeys.STARTUP_MAY25_MONTHLY @@ -116,8 +227,13 @@ export const PricingTable = ({ return; } - if (planId === "custom") { - window.location.href = "https://formbricks.com/custom-plan?source=billingView"; + if (planId === "scale") { + if (demoPlan) { + toast.success("Demo: Would redirect to Scale plan signup"); + return; + } + // Scale plan redirects to custom plan page for now + globalThis.location.href = "https://formbricks.com/custom-plan?source=billingView"; return; } @@ -126,177 +242,274 @@ export const PricingTable = ({ } }; - const responsesUnlimitedCheck = - organization.billing.plan === "custom" && organization.billing.limits.monthly.responses === null; - const peopleUnlimitedCheck = - organization.billing.plan === "custom" && organization.billing.limits.monthly.miu === null; - const projectsUnlimitedCheck = - organization.billing.plan === "custom" && organization.billing.limits.projects === null; + const handleUpgradeToAnnual = async () => { + if (demoPlan) { + toast.success("Demo: Would upgrade to annual billing"); + return; + } + await openCustomerPortal(); + }; return ( -
-
-
-
-

- {t("environments.settings.billing.current_plan")}:{" "} - {organization.billing.plan} - {cancellingOn && ( - - )} -

+
+ {/* Demo Mode Toggle */} +
+
+ Demo Mode + — Preview different plan states +
+
+ {(["trial", "hobby", "pro", "scale"] as DemoPlanState[]).map((plan) => ( + + ))} + {demoPlan && ( + + )} +
+
- {organization.billing.stripeCustomerId && organization.billing.plan === "free" && ( -
- + {/* Your Plan Status */} + { + void openCustomerPortal(); + }, + variant: "secondary", + } + : undefined + }> +
+ + {effectiveTrialEndsAt && ( + + )} + {cancellingOn && !demoPlan && ( + + )} +
+ + {/* Usage Stats */} +
+
+

Responses this month

+

+ {responseCount.toLocaleString()} + {effectiveResponsesLimit && ( + + {" "} + / {effectiveResponsesLimit.toLocaleString()} + + )} +

+ {effectiveResponsesLimit && ( +
+
0.9 ? "bg-red-500" : "bg-teal-500" + )} + style={{ width: `${Math.min((responseCount / effectiveResponsesLimit) * 100, 100)}%` }} + />
)}
-
-
-

{t("common.responses")}

- {organization.billing.limits.monthly.responses && ( - +

Identified Contacts

+

+ {peopleCount.toLocaleString()} + {effectiveMiuLimit && ( + + {" "} + / {effectiveMiuLimit.toLocaleString()} + + )} +

+ {effectiveMiuLimit && ( +
+
0.9 ? "bg-red-500" : "bg-teal-500" + )} + style={{ width: `${Math.min((peopleCount / effectiveMiuLimit) * 100, 100)}%` }} /> - )} +
+ )} +
- {responsesUnlimitedCheck && ( - +

Workspaces

+

+ {projectCount} + {effectiveProjectsLimit && ( + / {effectiveProjectsLimit} + )} +

+ {effectiveProjectsLimit && ( +
+
0.9 ? "bg-red-500" : "bg-teal-500" + )} + style={{ width: `${Math.min((projectCount / effectiveProjectsLimit) * 100, 100)}%` }} /> - )} -
- -
-

- {t("environments.settings.billing.monthly_identified_users")} -

- {organization.billing.limits.monthly.miu && ( - - )} - - {peopleUnlimitedCheck && ( - - )} -
- -
-

{t("common.workspaces")}

- {organization.billing.limits.projects && ( - - )} - - {projectsUnlimitedCheck && ( - - )} -
+
+ )}
- {hasBillingRights && ( -
-
-
- - -
-
- +

Your volumes renew on Feb 1, 2025

+ + + {/* Overage Card (only for paid plans or trial) */} + {(isOnPaidPlan || effectiveTrialEndsAt) && ( + + { + if (demoPlan) { + toast.success(`Demo: Would change overage mode to ${mode}`); + setDemoOverageMode(mode); + return; + } + // TODO: Implement actual overage mode change via API + toast.success(`Overage mode changed to ${mode}`); + setDemoOverageMode(mode); + }} + onSpendingLimitChange={async (limit) => { + if (demoPlan) { + toast.success( + limit ? `Demo: Would set spending limit to $${limit}` : "Demo: Would remove spending limit" + ); + setDemoSpendingLimit(limit); + return; + } + // TODO: Implement actual spending limit change via API + toast.success(limit ? `Spending limit set to $${limit}` : "Spending limit removed"); + setDemoSpendingLimit(limit); + }} + /> + + )} + + {/* Alert: Annual Billing Upgrade (only for monthly paid plans) */} + {isOnMonthlyBilling && ( + + Save 20% on Annual Billing + Simplify your billing cycle and get 2 months free. + Switch to Annual + + )} + + {/* Alert: Special Pricing Programs (only for non-paid plans) */} + {!isOnPaidPlan && ( + + Special Pricing Programs + + Exclusive discounts for Startups, Non-profits, and Open Source projects. + + { + if (demoPlan) { + toast.success("Demo: Would open discount application form"); + return; + } + globalThis.open("https://formbricks.com/discount", "_blank"); + }}> + Apply for Discount + + + )} + + {/* Pricing Plans */} + {hasBillingRights && ( +
+ {/* Period Toggle */} +
+
+ +
+ {planPeriod === "yearly" && ( + + Get 2 months free 🔥 + + )}
- )} -
-
+ + {/* Plan Cards */} +
+ {getCloudPricingData(t).plans.map((plan) => ( + { + await onUpgrade(plan.id); + }} + organization={effectiveOrganization} + onManageSubscription={openCustomerPortal} + isTrialActive={demoPlan === "trial" && plan.id === "scale"} + currentPlan={effectivePlan} + /> + ))} +
+
+ )} +
); }; diff --git a/apps/web/modules/ee/billing/components/select-plan-card.tsx b/apps/web/modules/ee/billing/components/select-plan-card.tsx new file mode 100644 index 0000000000..07698f83cf --- /dev/null +++ b/apps/web/modules/ee/billing/components/select-plan-card.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { CheckIcon, GiftIcon } from "lucide-react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import calLogo from "@/images/customer-logos/cal-logo-light.svg"; +import ethereumLogo from "@/images/customer-logos/ethereum-logo.png"; +import flixbusLogo from "@/images/customer-logos/flixbus-white.svg"; +import githubLogo from "@/images/customer-logos/github-logo.png"; +import siemensLogo from "@/images/customer-logos/siemens.png"; +import { Button } from "@/modules/ui/components/button"; + +interface SelectPlanCardProps { + /** URL to redirect after starting trial or continuing with free */ + nextUrl: string; +} + +const TRIAL_FEATURES = [ + "Fully white-labeled surveys", + "All team & collaboration features", + "Setup custom webhooks", + "Get full API access", + "Setup email follow-ups", + "Manage quotas", +]; + +const CUSTOMER_LOGOS = [ + { src: siemensLogo, alt: "Siemens" }, + { src: calLogo, alt: "Cal.com" }, + { src: flixbusLogo, alt: "FlixBus" }, + { src: githubLogo, alt: "GitHub" }, + { src: ethereumLogo, alt: "Ethereum" }, +]; + +export const SelectPlanCard = ({ nextUrl }: SelectPlanCardProps) => { + const router = useRouter(); + const [isStartingTrial, setIsStartingTrial] = useState(false); + + const handleStartTrial = async () => { + setIsStartingTrial(true); + // TODO: Implement trial activation via Stripe + router.push(nextUrl); + }; + + const handleContinueFree = () => { + router.push(nextUrl); + }; + + return ( +
+ {/* Trial Card */} +
+
+ {/* Gift Icon */} +
+ +
+ + {/* Title & Subtitle */} +
+

Try Pro features for free!

+

14 days trial, no credit card required

+
+ + {/* Features List */} +
    + {TRIAL_FEATURES.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + {/* CTA Button */} + +
+ + {/* Logo Carousel */} +
+
+ {/* Duplicate logos for seamless infinite scroll */} + {[...CUSTOMER_LOGOS, ...CUSTOMER_LOGOS].map((logo, index) => ( +
+ {logo.alt} +
+ ))} +
+
+
+ + {/* Skip Option */} + +
+ ); +}; diff --git a/apps/web/modules/ee/billing/components/settings-id.tsx b/apps/web/modules/ee/billing/components/settings-id.tsx new file mode 100644 index 0000000000..a65c113f41 --- /dev/null +++ b/apps/web/modules/ee/billing/components/settings-id.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { HelpCircleIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; + +interface SettingsIdProps { + label: string; + value: string; + tooltip?: string; + className?: string; + align?: "left" | "right"; +} + +export const SettingsId = ({ label, value, tooltip, className, align = "left" }: SettingsIdProps) => { + return ( +
+
+ {label} + {tooltip && ( + + + + + + +

{tooltip}

+
+
+
+ )} +
+ {value} +
+ ); +}; diff --git a/apps/web/modules/ee/billing/page.tsx b/apps/web/modules/ee/billing/page.tsx index b8ba82b4ce..1726483e26 100644 --- a/apps/web/modules/ee/billing/page.tsx +++ b/apps/web/modules/ee/billing/page.tsx @@ -1,7 +1,6 @@ import { notFound } from "next/navigation"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; -import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; -import { PROJECT_FEATURE_KEYS, STRIPE_PRICE_LOOKUP_KEYS } from "@/lib/constants"; +import { IS_FORMBRICKS_CLOUD, STRIPE_PRICE_LOOKUP_KEYS } from "@/lib/constants"; import { getMonthlyActiveOrganizationPeopleCount, getMonthlyOrganizationResponseCount, @@ -49,7 +48,6 @@ export const PricingPage = async (props) => { responseCount={responseCount} projectCount={projectCount} stripePriceLookupKeys={STRIPE_PRICE_LOOKUP_KEYS} - projectFeatureKeys={PROJECT_FEATURE_KEYS} hasBillingRights={hasBillingRights} /> diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 120265f69d..4a632d8d46 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -20,6 +20,7 @@ module.exports = { fadeOut: "fadeOut 0.2s ease-out", surveyLoading: "surveyLoadingAnimation 0.5s ease-out forwards", surveyExit: "surveyExitAnimation 0.5s ease-out forwards", + "logo-scroll": "logo-scroll 20s linear infinite", }, blur: { xxs: "0.33px", @@ -130,6 +131,10 @@ module.exports = { "0%": { transform: "translateY(0)", opacity: "1" }, "100%": { transform: "translateY(-50px)", opacity: "0" }, }, + "logo-scroll": { + "0%": { transform: "translateX(0)" }, + "100%": { transform: "translateX(-50%)" }, + }, }, width: { "sidebar-expanded": "4rem",