Compare commits

...

22 Commits

Author SHA1 Message Date
Matti Nannt
14155c7d9e fix: remove over-deep stripe subscription expands 2026-02-20 10:01:20 +01:00
Matti Nannt
abd844c70c fix: await stripe setup during org creation 2026-02-20 09:57:48 +01:00
Matti Nannt
c8bc04fd6b fix: enforce single active cloud subscription 2026-02-20 09:40:48 +01:00
Matti Nannt
b4dd373278 chore: update web i18n lockfile 2026-02-19 17:46:48 +01:00
Matti Nannt
6eac4f72b6 fix: harden checkout tax setup and close billing review findings 2026-02-19 17:46:00 +01:00
Matti Nannt
999a26ed40 fix: use supported stripe api version suffix 2026-02-19 17:23:11 +01:00
Matti Nannt
aa56db0da2 fix: remove unicode escape badge and meter checkout line items 2026-02-19 17:17:40 +01:00
Matti Nannt
c4d3e2d132 test: add billing module coverage for sonar gate 2026-02-19 16:51:57 +01:00
Matti Nannt
79754606eb test: align organization create test with billing snapshot 2026-02-19 16:40:49 +01:00
Matti Nannt
d1b2878ba6 chore: trigger ci sync for billing revamp 2026-02-19 16:33:31 +01:00
Matti Nannt
b2f5345662 test: align survey link billing tests with sync service 2026-02-19 16:09:26 +01:00
Matti Nannt
08bb09a3ea fix: resolve billing revamp type errors 2026-02-19 14:37:35 +01:00
Matti Nannt
efd80e7dbe test: align survey billing test with read-through sync 2026-02-19 14:25:20 +01:00
Matti Nannt
dfaf6dcf87 docs: resolve billing revamp plan review findings 2026-02-19 14:22:08 +01:00
Matti Nannt
6ecd63817e feat: revamp cloud pricing table for hobby pro scale 2026-02-19 14:20:35 +01:00
Matti Nannt
c5fc169fb6 feat: gate external urls via cloud entitlements 2026-02-19 14:19:31 +01:00
Matti Nannt
a104736df2 feat: add stripe billing sync core and cloud customer bootstrap 2026-02-19 14:16:25 +01:00
Matti Nannt
e5960cd714 docs: add pricing baseline and stripe preflight checks 2026-02-19 13:27:06 +01:00
Matti Nannt
a413c5a2f7 docs: mark remaining questions as post-iteration 2026-02-19 12:58:36 +01:00
Matti Nannt
596ab2f11e docs: clarify DoD timing for implementation PR 2026-02-19 12:57:19 +01:00
Matti Nannt
2f1f723083 docs: scope iteration-1 billing rollout and defer migration script 2026-02-19 12:56:34 +01:00
Matti Nannt
9153de395c docs: align cloud billing revamp plan and delivery gates 2026-02-19 12:53:55 +01:00
46 changed files with 2398 additions and 379 deletions

View File

@@ -0,0 +1,426 @@
# Formbricks Cloud Billing Revamp Plan (Stripe-First, Stripe-Only End State)
## Scope
- In scope: Formbricks Cloud billing, pricing, entitlements, metering, spending controls, billing UI, and migration of existing Cloud orgs.
- Out of scope: Self-hosting license-server implementation details (but we keep the shared feature-access interface).
- Iteration 1 scope focus: new Cloud orgs only (signup -> Stripe customer creation -> upgrade flow -> entitlement-based feature access).
- Iteration 1 excludes contact limitations/metering; contacts remain unlimited for now.
## North Star
- Stripe is the commercial source of truth for **all Cloud organizations** (free, standard paid, custom paid).
- App database stores a **projection/snapshot** for performance and resilience, not an independent billing truth.
- No long-term `legacy` runtime billing branch.
## Confidence Check
- Current confidence: **~95%** for Iteration 1 start.
- Remaining uncertainty: migration details for existing paid/custom orgs (post-Iteration-1).
## 1) Current State (Repository Audit)
### Current billing module and routes
- Billing settings page routes to `apps/web/modules/ee/billing/page.tsx` via `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/page.tsx`.
- Stripe webhook route is `apps/web/app/api/billing/stripe-webhook/route.ts` -> `apps/web/modules/ee/billing/api/route.ts`.
- Billing confirmation page exists at `apps/web/app/(app)/billing-confirmation/page.tsx`.
### Legacy patterns to remove
- Hardcoded plans and prices in code:
- `apps/web/modules/ee/billing/api/lib/constants.ts`
- `apps/web/lib/constants.ts` (`PROJECT_FEATURE_KEYS`, `STRIPE_PRICE_LOOKUP_KEYS`, `BILLING_LIMITS`)
- Webhook handlers directly mutating local plan/limits:
- `apps/web/modules/ee/billing/api/lib/checkout-session-completed.ts`
- `apps/web/modules/ee/billing/api/lib/subscription-deleted.ts`
- `apps/web/modules/ee/billing/api/lib/invoice-finalized.ts`
- Checkout/subscription flow is plan-key/single-price driven:
- `apps/web/modules/ee/billing/api/lib/create-subscription.ts`
### Feature access is still scattered
- `organization.billing.plan` checks are spread across modules.
- Main permission logic appears in:
- `apps/web/modules/ee/license-check/lib/utils.ts`
- `apps/web/modules/survey/lib/permission.ts`
- `apps/web/modules/survey/follow-ups/lib/utils.ts`
### Data model drift to fix
- Billing shape/types are inconsistent:
- `packages/types/organizations.ts` uses `free/startup/custom`
- `packages/database/zod/organizations.ts` still allows `free/startup/scale/enterprise`
## 2) Stripe Reality Check (Capabilities + Constraints)
### Stripe gives us
- Entitlements from product-feature mapping (`active_entitlements`).
- Multi-item subscriptions (flat + metered items).
- Meter events + usage summaries.
- Billing alerts (`billing.alert.triggered`).
- Checkout, trials, tax, invoicing, webhook events.
### Stripe constraints that shape implementation
- Entitlement summary webhook payload can be partial; full entitlements may require explicit list call.
- Metering is asynchronous/eventually consistent.
- Webhooks are at-least-once and unordered; idempotency + dedupe are required.
- Customer portal has limits for complex usage/multi-item plan changes.
- Stripe billing thresholds and alerts do not provide guaranteed app-level hard-stop behavior.
## 2.1) Pricing Reference (Agreed UI Baseline)
This baseline is taken from the approved pricing design screenshot shared in this thread and should be reflected in the pricing table implementation.
Billing periods:
- Monthly and annual toggle.
- Annual discount: "Get 2 months free" (annual = 10x monthly list price).
Plans:
- Hobby: `$0` (free)
- Pro: `$89/month` or `$890/year`
- Scale: `$390/month` or `$3,900/year`
Included usage (Iteration 1):
- Hobby: 1 workspace, 250 responses/month
- Pro: 3 workspaces, 2,000 responses/month
- Scale: 5 workspaces, 5,000 responses/month
Important scope note:
- Do **not** implement contact limits or contact metering in Iteration 1, even if pricing mockups mention contacts.
## 3) Target Architecture (Module-Aligned)
Create a dedicated billing domain:
- `apps/web/modules/billing/`
- `components/`
- `actions.ts`
- `api/route.ts` (Stripe webhook entrypoint)
- `lib/`
- `stripe-client.ts`
- `customers.ts`
- `subscriptions.ts`
- `entitlements.ts`
- `pricing.ts`
- `metering.ts`
- `alerts.ts`
- `spending-caps.ts`
- `feature-access/`
Introduce unified feature-access interface:
- `FeatureAccessProvider`
- `getEntitlements(organizationId): FeatureSet`
- `hasFeature(organizationId, featureKey): boolean`
- Provider implementations:
- Cloud: Stripe-backed provider
- Self-hosted (later): license-backed provider
### Cloud runtime model
- Cloud billing provider is Stripe only.
- Keep `IS_FORMBRICKS_CLOUD=1` as gate for Cloud billing behavior.
- In Cloud mode, keep enterprise license anti-bypass checks in place.
### Data model direction
- Use one normalized billing snapshot shape for Cloud orgs.
- Keep sync metadata:
- `lastStripeEventCreatedAt`
- `lastSyncedAt`
- `lastSyncedEventId`
- Optional temporary `migrationState` (`pending|ready|error`) is acceptable during rollout; remove after migration completion.
- Do not keep a permanent `legacy` billing mode branch in runtime logic.
### 3.1) Sync Model (No-Worker MVP)
1. Persist normalized billing snapshot per organization.
2. Runtime checks use cache -> DB snapshot (not Stripe per request).
3. On missing/stale snapshot, do read-through fetch from Stripe, then update DB + cache.
4. On webhook:
- verify signature
- dedupe by `event.id`
- use webhook as trigger, fetch canonical state from Stripe, overwrite snapshot
- update `lastStripeEventCreatedAt`, `lastSyncedAt`, `lastSyncedEventId`
5. Add manual "Resync from Stripe" action for self-healing.
Rationale:
- Avoids state drift from webhook ordering/retry issues.
- Avoids hard dependence on direct Stripe calls in hot paths.
- Works without worker infrastructure.
## 4) Migration Strategy (Stripe-Only End State)
### Principle
- Migrate every Cloud org to Stripe-managed billing records.
- Keep runtime simple: one Cloud billing path.
### Track A: existing free orgs (bulk, automated)
- One-off Cloud-only idempotent script:
- create Stripe customer if missing
- attach Stripe-managed free subscription/product
- backfill billing snapshot + sync metadata
- Safety: `--dry-run`, explicit confirm flag, rerunnable, rate-limit backoff, per-org result log.
### Track B: existing paid/custom orgs (assisted, Stripe-first)
- Migrate contracts into Stripe catalog/subscriptions instead of app-side legacy state.
- Use a finite set of contract products/add-ons where possible to minimize catalog sprawl.
- For edge contracts, allow temporary customer-specific Stripe setup, but still Stripe-managed.
- After each org migration in Stripe:
- trigger app resync (webhook or manual resync action)
- mark migration `ready`
### Transitional behavior during migration window
- If org migration is `pending`, show limited billing UI with support message.
- Do not implement full alternative billing policy engine for pending orgs.
- Sunset migration state after all orgs are moved.
### Iteration 1 decision
- Migration execution scripts are explicitly out of scope for this first implementation PR.
- The PR must include:
- a clear note that migration script implementation is deferred
- an outline/spec of what the migration script must do (inputs, safety checks, idempotency, outputs)
- Runtime implementation target for Iteration 1:
- new Cloud org signup creates Stripe customer automatically
- Stripe-managed upgrade path works end-to-end
- entitlements are synced and used for feature access decisions
## 5) Implementation Plan (Simple Phases)
1. **Foundation and cleanup boundary**
- Keep billing settings route/page shell.
- Inventory and map all `billing.plan` checks to feature keys.
- Preflight Stripe environment validation: confirm API key/CLI context points to the intended Cloud Dev Sandbox account before hardcoding or storing product/price IDs.
2. **Unified feature-access layer**
- Implement `FeatureAccessProvider`.
- Add Stripe-backed entitlement provider with cache + DB snapshot + read-through refresh.
- Start replacing direct plan checks with `hasFeature()`.
3. **Stripe customer + subscription lifecycle**
- Create Stripe customer at Cloud org creation.
- Implement multi-item subscriptions for Pro/Scale and trial flow.
- Implement immediate upgrades and period-end downgrades.
4. **Webhook + sync reliability (no-worker MVP)**
- `event.id` dedupe table.
- Canonical Stripe re-read on relevant events.
- Sync metadata writes (`lastStripeEventCreatedAt`, `lastSyncedAt`, `lastSyncedEventId`).
- Manual resync endpoint/action.
5. **Usage metering v1**
- Meter `response_created` only.
- No contact metering limits/charges in v1.
- Best-effort synchronous send + persisted failure log + replay utility.
6. **Spending controls**
- App-owned monthly spending cap (`none|warn|pause`).
- Owner-only permissions for cap changes.
- Pause/unpause implemented at app level:
- pause blocks new responses
- unpause resumes collection immediately
7. **Pricing + billing UI revamp**
- Pricing table from Stripe products/prices.
- Billing overview from Stripe-backed snapshot (usage, included, overage, cap state).
- For migration-pending orgs, show support/migration state messaging.
8. **Data migration and cutover**
- Defer execution of Track A and Track B to post-Iteration-1.
- In Iteration 1 PR, include migration script specification and rollout checklist.
- Remove old hardcoded billing logic and legacy webhook handlers for the new-user path.
- Keep temporary migration handling only as documentation until migration execution starts.
9. **Stabilization**
- Add monitoring for webhook lag/failures, sync freshness, metering failures.
- Add runbook for manual resync and Stripe incident fallback.
## 6) Definition Of Done (Implementation + Delivery)
Application timing:
- These gates apply when implementation work is complete and the implementation PR is ready for merge.
- They are not required for planning-only updates.
### Code-level DoD (all touched `.ts` files)
- No leftover legacy billing plan-string logic in touched code paths.
- Strong typing only (`any` avoided unless explicitly justified and documented).
- Clear module boundaries under `apps/web/modules/billing`.
- Server actions follow project return pattern (`{ data }` or `{ error }`).
- User-facing strings remain i18n-compatible.
- Caching/sync behavior follows this plan (snapshot + read-through + webhook resync).
- Tests added/updated for touched `.ts` logic.
### Quality gates (must pass)
- SonarQube coverage target: **>=80%**.
- `pnpm lint` passes.
- `pnpm test` passes.
- `pnpm build` passes.
### Git + PR workflow (mandatory)
1. Create feature branch (`codex/...`).
2. Implement in small, reviewable commits.
3. Open PR with:
- scope summary
- migration notes
- risk/rollback notes
- test evidence (`pnpm lint`, `pnpm test`, `pnpm build`)
4. Wait for CI and automated AI review (Code Rabbit).
5. Address all blocking review comments and unresolved AI findings.
6. Merge only after all required checks are green.
### Stripe preflight (before implementation merge)
- Confirm Stripe account context matches intended environment (Cloud Dev Sandbox).
- Validate that referenced product/price IDs exist in that account.
- Validate entitlement feature lookup keys used by code exist in that account.
## 7) First Slice Recommendation
- Slice A: Build Stripe-backed `FeatureAccessProvider` + snapshot/read-through path in shadow mode.
- Slice B: Rewire highest-risk feature gates (`custom-redirect-url`, `custom-links-in-surveys`).
- Slice C: Replace pricing table data source with Stripe products/prices.
## 8) Industry Best Practices Applied
1. Stripe as truth, app DB as projection.
2. Idempotent webhook ingestion with canonical re-fetch.
3. Cache + read-through to avoid per-request Stripe dependency.
4. Single policy interface (`hasFeature`) for consistent gating.
5. Migration runbooks with explicit observability and rollback controls.
6. Minimize transitional runtime branches; enforce a sunset for migration-only states.
## 9) Decision Log
| ID | Topic | Decision | Status | Notes |
| ----- | --------------------------- | ------------------------------------------------------------------------------------------------------------ | --------- | ---------------------------------------------------- |
| D-001 | Cloud source of truth | Stripe is source of truth for Cloud pricing + entitlements | Confirmed | Applies to free, paid, and custom Cloud orgs |
| D-002 | Feature gating interface | Use `FeatureAccessProvider` (`hasFeature`) across app | Confirmed | Cloud Stripe-backed, self-hosted license-backed |
| D-003 | Usage metering v1 | Meter `response_created` only; contacts not metered in v1 | Confirmed | Contacts remain unlimited for now |
| D-004 | Spending cap enforcement | App-level `none\|warn\|pause` enforcement | Confirmed | Stripe does not provide app pause behavior |
| D-005 | Permissions | Spending cap changes are owner-only | Confirmed | Managers excluded |
| D-006 | Plan change semantics | Immediate upgrades, period-end downgrades | Confirmed | Matches requested behavior |
| D-007 | Deployment separation | Billing stack active only with `IS_FORMBRICKS_CLOUD=1` | Confirmed | Self-hosted stays license-server driven |
| D-008 | Anti-bypass | In Cloud mode, keep enterprise license anti-bypass checks | Confirmed | Prevents env-var bypass |
| D-009 | Sync model | No-worker MVP: dedupe + canonical re-read + snapshot overwrite | Confirmed | Worker queue deferred |
| D-010 | Sync metadata | Keep both `lastStripeEventCreatedAt` and `lastSyncedAt` (+ event id) | Confirmed | Ordering vs ops freshness |
| D-011 | Webhook payload usage | Use webhook payload as trigger, not authoritative projection | Confirmed | Avoid out-of-order drift |
| D-012 | Free-tier model | Free Cloud orgs are Stripe-managed (`$0`) | Confirmed | Unified entitlements and upgrade path |
| D-013 | Migration direction | Migrate existing paid/custom orgs into Stripe-managed contracts | Confirmed | Avoid permanent app-side legacy branch |
| D-014 | Transitional runtime | Allow temporary migration state only, with sunset | Confirmed | No long-term `legacy` policy path |
| D-015 | Migration execution | Track A automated (free), Track B assisted (paid/custom) | Confirmed | Both Stripe-first |
| D-016 | Currency/tax | USD pricing with Stripe Tax enabled | Confirmed | Tax calculation delegated to Stripe |
| D-017 | Stripe ownership | Founders team owns Stripe catalog/config | Confirmed | Process hardening to be defined |
| D-018 | Definition of Done | Enforce DoD for touched `.ts` files + coverage and CI gates before merge | Confirmed | Includes lint/test/build + Code Rabbit resolution |
| D-019 | Iteration 1 migration scope | Migration script implementation is out of scope for Iteration 1 PR | Confirmed | PR must state deferred status explicitly |
| D-020 | Iteration 1 runtime target | First implementation must be production-ready for new Cloud org signup + upgrade + entitlement gating | Confirmed | Existing org migration follows in later phase |
| D-021 | Pricing baseline | Use approved pricing UI baseline: Hobby free, Pro 89/890, Scale 390/3900, annual = 2 months free | Confirmed | Pricing table must match this reference |
| D-022 | Contacts in Iteration 1 | No contact limits and no contact metering in Iteration 1 | Confirmed | Contacts shown in old mockups are non-binding for v1 |
| D-023 | Stripe account preflight | Before implementation wiring, confirm we are targeting the intended Cloud Dev Sandbox Stripe account and IDs | Confirmed | Avoids wiring against wrong account/catalog |
## 10) Open Questions (Post-Iteration-1, Not Blocking Iteration 1)
1. Custom-paid migration catalog design:
- Do we define a strict finite set of contract bundles/add-ons in Stripe first, then map every paid/custom org to one of them?
- Or allow short-term customer-specific Stripe prices for outliers, then consolidate later?
2. Pending-org UX:
- Confirm exact copy and support CTA for orgs in temporary `migrationState=pending`.
## 10.1) Deferred Migration Script Specification (to include in Iteration 1 PR)
Purpose:
- Migrate existing Cloud orgs to Stripe-managed billing after Iteration 1 runtime is live.
Required behavior:
1. Guardrails:
- hard-fail unless `IS_FORMBRICKS_CLOUD=1`
- require explicit confirm flag
- support `--dry-run`
2. Free-org migration track:
- ensure Stripe customer exists
- ensure Stripe-managed free subscription exists
- backfill normalized billing snapshot + sync metadata
3. Paid/custom migration track:
- map org to Stripe contract product/add-ons
- create/update Stripe subscription as needed
- trigger canonical app resync
4. Safety:
- idempotent and rerunnable
- bounded concurrency + retry/backoff for 429/5xx
- per-org success/failure logging
5. Output:
- summary counters (migrated/skipped/failed)
- machine-readable failure report for manual follow-up
## 11) References (Stripe primary sources)
- https://docs.stripe.com/billing/entitlements
- https://docs.stripe.com/api/entitlements/active-entitlement/list
- https://docs.stripe.com/billing/subscriptions/webhooks
- https://docs.stripe.com/api/billing/alert/object
- https://docs.stripe.com/billing/subscriptions/usage-based/recording-usage-api
- https://docs.stripe.com/api/billing/meter-event/create
- https://docs.stripe.com/rate-limits
- https://docs.stripe.com/webhooks
- https://docs.stripe.com/api/idempotent_requests
- https://docs.stripe.com/customer-management
- https://docs.stripe.com/billing/subscriptions/integrating-customer-portal
## 12) Cloud Dev Stripe Inventory Snapshot (2026-02-19)
The detailed inventory of Stripe account, product, price, meter, alert, and feature IDs is intentionally kept in an internal document (Founders Team billing ops inventory).
Public reference placeholders:
- Products: `HOBBY_PRODUCT`, `PRO_PRODUCT`, `SCALE_PRODUCT`, `TRIAL_PRODUCT`
- Base prices: `PRICE_HOBBY_MONTHLY`, `PRICE_PRO_MONTHLY`, `PRICE_PRO_YEARLY`, `PRICE_SCALE_MONTHLY`, `PRICE_SCALE_YEARLY`, `PRICE_TRIAL_FREE`
- Usage prices: `PRICE_PRO_USAGE_RESPONSES`, `PRICE_SCALE_USAGE_RESPONSES`
- Meter events: `response_created`, `unique_contact_identified` (contacts still out of Iteration 1 scope)
- Feature lookup keys follow the public keys in code (`CLOUD_STRIPE_FEATURE_LOOKUP_KEYS`)
Implementation note:
- Always paginate Stripe list APIs when validating entitlements and product feature assignments.

View File

@@ -924,8 +924,14 @@ checksums:
environments/settings/billing/logic_jumps_hidden_fields_recurring_surveys: f58485b1bbf76e3805d6105b5e8294e6
environments/settings/billing/manage_card_details: 8d9e61ee37cada980edcdd16ffd7b2a0
environments/settings/billing/manage_subscription: 31cafd367fc70d656d8dd979d537dc96
environments/settings/billing/month: ae7bef950efc406ff0980affabc1a64c
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
environments/settings/billing/monthly_identified_users: 0795735f6b241d31edac576a77dd7e55
environments/settings/billing/plan_hobby: 3e96a8e688032f9bd21b436bc70c19d5
environments/settings/billing/plan_pro: 682b3c9feab30112b4454cb5bb7974b1
environments/settings/billing/plan_scale: 5f55a30a5bdf8f331b56bad9c073473c
environments/settings/billing/plan_trial: 4fd32760caf3bd7169935b0a6d2b5b67
environments/settings/billing/plan_unknown: 5cd12b882fe90320f93130c1b50e2e32
environments/settings/billing/plan_upgraded_successfully: 52e2a258cc9ca8a512c288bf6f18cf37
environments/settings/billing/premium_support_with_slas: 2e33d4442c16bfececa6cae7b2081e5d
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
@@ -942,6 +948,7 @@ checksums:
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
environments/settings/billing/uptime_sla_99: 25ca4060e575e1a7eee47fceb5576d7c
environments/settings/billing/website_surveys: f4d176cc66ffcc2abf44c0d5da1642e3
environments/settings/billing/year: ed86f5f60583f9d8ffdbeed306aa0ec7
environments/settings/domain/customize_favicon_description: d3ac29934a66fd56294c0d8069fbc11e
environments/settings/domain/customize_favicon_with_higher_plan: 43a6b834a8fd013c52923863d62248f3
environments/settings/domain/description: f0b4d8c96da816f793cf1f4fdfaade34

View File

@@ -158,7 +158,7 @@ export const BREVO_LIST_ID = env.BREVO_LIST_ID;
export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY;
export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
export const STRIPE_API_VERSION = "2024-06-20";
export const STRIPE_API_VERSION = "2026-01-28.clover";
// Maximum number of attribute classes allowed:
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;

View File

@@ -4,10 +4,12 @@ import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { updateUser } from "@/lib/user/service";
import { ensureCloudStripeSetupForOrganization } from "@/modules/billing/lib/organization-billing";
import {
createOrganization,
getOrganization,
getOrganizationsByUserId,
select as organizationSelect,
subscribeOrganizationMembersToSurveyResponses,
updateOrganization,
} from "./service";
@@ -30,6 +32,10 @@ vi.mock("@/lib/user/service", () => ({
updateUser: vi.fn(),
}));
vi.mock("@/modules/billing/lib/organization-billing", () => ({
ensureCloudStripeSetupForOrganization: vi.fn().mockResolvedValue(undefined),
}));
describe("Organization Service", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -43,6 +49,7 @@ describe("Organization Service", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: {
billingMode: "stripe" as const,
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
@@ -98,6 +105,7 @@ describe("Organization Service", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: {
billingMode: "stripe",
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
@@ -151,6 +159,7 @@ describe("Organization Service", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: {
billingMode: "stripe" as const,
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
@@ -176,6 +185,7 @@ describe("Organization Service", () => {
data: {
name: "Test Org",
billing: {
billingMode: "stripe",
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
@@ -189,8 +199,44 @@ describe("Organization Service", () => {
period: "monthly",
},
},
select: expect.any(Object),
select: organizationSelect,
});
expect(ensureCloudStripeSetupForOrganization).toHaveBeenCalledWith("org1");
});
test("should still return organization when Stripe setup fails", async () => {
const mockOrganization = {
id: "org1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
billingMode: "stripe" as const,
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
},
},
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly" as const,
},
isAIEnabled: false,
whitelabel: false,
};
vi.mocked(prisma.organization.create).mockResolvedValue(mockOrganization);
vi.mocked(ensureCloudStripeSetupForOrganization).mockRejectedValueOnce(
new Error("stripe temporarily unavailable")
);
const result = await createOrganization({ name: "Test Org" });
expect(result).toEqual(mockOrganization);
expect(ensureCloudStripeSetupForOrganization).toHaveBeenCalledWith("org1");
});
test("should throw DatabaseError on prisma error", async () => {

View File

@@ -17,6 +17,7 @@ import { BILLING_LIMITS, ITEMS_PER_PAGE, PROJECT_FEATURE_KEYS } from "@/lib/cons
import { getProjects } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { getBillingPeriodStartDate } from "@/lib/utils/billing";
import { ensureCloudStripeSetupForOrganization } from "@/modules/billing/lib/organization-billing";
import { validateInputs } from "../utils/validate";
export const select: Prisma.OrganizationSelect = {
@@ -127,6 +128,7 @@ export const createOrganization = async (
data: {
...organizationInput,
billing: {
billingMode: "stripe",
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
@@ -143,6 +145,16 @@ export const createOrganization = async (
select,
});
// Stripe setup is best-effort but should be attempted before we return.
try {
await ensureCloudStripeSetupForOrganization(organization.id);
} catch (error) {
logger.warn(
{ error, organizationId: organization.id },
"Stripe setup failed after organization creation"
);
}
return organization;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.",
"manage_card_details": "Karteninformationen verwalten",
"manage_subscription": "Abonnement verwalten",
"month": "Monat",
"monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_trial": "Trial",
"plan_unknown": "Unbekannt",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs",
"remove_branding": "Branding entfernen",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Unbegrenzte Projekte",
"upgrade": "Upgrade",
"uptime_sla_99": "Betriebszeit SLA (99%)",
"website_surveys": "Website-Umfragen"
"website_surveys": "Website-Umfragen",
"year": "Jahr"
},
"domain": {
"customize_favicon_description": "Lade ein individuelles Favicon hoch, um deine Link-Umfrage zu personalisieren und deine Markenpräsenz zu stärken.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.",
"manage_card_details": "Manage Card Details",
"manage_subscription": "Manage Subscription",
"month": "Month",
"monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_trial": "Trial",
"plan_unknown": "Unknown",
"plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs",
"remove_branding": "Remove Branding",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Unlimited Workspaces",
"upgrade": "Upgrade",
"uptime_sla_99": "Uptime SLA (99%)",
"website_surveys": "Website Surveys"
"website_surveys": "Website Surveys",
"year": "Year"
},
"domain": {
"customize_favicon_description": "Upload a custom favicon to personalize your link survey experience and strengthen your brand presence.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Saltos lógicos, campos ocultos, encuestas recurrentes, etc.",
"manage_card_details": "Gestionar detalles de tarjeta",
"manage_subscription": "Gestionar suscripción",
"month": "Mes",
"monthly": "Mensual",
"monthly_identified_users": "Usuarios identificados mensuales",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_trial": "Prueba",
"plan_unknown": "Desconocido",
"plan_upgraded_successfully": "Plan actualizado con éxito",
"premium_support_with_slas": "Soporte premium con SLA",
"remove_branding": "Eliminar marca",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Proyectos ilimitados",
"upgrade": "Actualizar",
"uptime_sla_99": "Acuerdo de nivel de servicio de tiempo de actividad (99 %)",
"website_surveys": "Encuestas de sitio web"
"website_surveys": "Encuestas de sitio web",
"year": "Año"
},
"domain": {
"customize_favicon_description": "Sube un favicon personalizado para personalizar la experiencia de tu encuesta por enlace y fortalecer la presencia de tu marca.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.",
"manage_card_details": "Gérer les détails de la carte",
"manage_subscription": "Gérer l'abonnement",
"month": "Mois",
"monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs mensuels identifiés",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_trial": "Essai",
"plan_unknown": "Inconnu",
"plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Assistance premium avec accord de niveau de service",
"remove_branding": "Suppression du logo",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Projets illimités",
"upgrade": "Mise à niveau",
"uptime_sla_99": "Disponibilité de 99 %",
"website_surveys": "Sondages de site web"
"website_surveys": "Sondages de site web",
"year": "Année"
},
"domain": {
"customize_favicon_description": "Chargez un favicon personnalisé pour personnaliser l'expérience de vos enquêtes par lien et renforcer la présence de votre marque.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Logikai ugrások, rejtett mezők, ismétlődő kérdőívek stb.",
"manage_card_details": "Kártya részleteinek kezelése",
"manage_subscription": "Feliratkozás kezelése",
"month": "Hónap",
"monthly": "Havonta",
"monthly_identified_users": "Havonta azonosított felhasználók",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_trial": "Próbaverzió",
"plan_unknown": "Ismeretlen",
"plan_upgraded_successfully": "A csomag sikeresen magasabbra váltva",
"premium_support_with_slas": "Prémium támogatás SLA-kkal",
"remove_branding": "Márkajel eltávolítása",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Korlátlan munkaterület",
"upgrade": "Frissítés",
"uptime_sla_99": "Működési idő SLA (99%)",
"website_surveys": "Weboldal-kérdőívek"
"website_surveys": "Weboldal-kérdőívek",
"year": "Év"
},
"domain": {
"customize_favicon_description": "Egyéni böngészőikon feltöltése a hivatkozás-kérdőív élményének személyre szabásához és a márka jelenlétének erősítéséhez.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "ロジックジャンプ、非表示フィールド、定期フォームなど。",
"manage_card_details": "カード詳細を管理",
"manage_subscription": "サブスクリプションを管理",
"month": "月",
"monthly": "月間",
"monthly_identified_users": "月間識別ユーザー数",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_trial": "Trial",
"plan_unknown": "不明",
"plan_upgraded_successfully": "プランを正常にアップグレードしました",
"premium_support_with_slas": "SLA付きプレミアムサポート",
"remove_branding": "ブランディングを削除",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "無制限ワークスペース",
"upgrade": "アップグレード",
"uptime_sla_99": "稼働率SLA (99%)",
"website_surveys": "ウェブサイトフォーム"
"website_surveys": "ウェブサイトフォーム",
"year": "年"
},
"domain": {
"customize_favicon_description": "カスタムファビコンをアップロードして、リンク調査の体験をパーソナライズし、ブランドプレゼンスを強化します。",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Logische sprongen, verborgen velden, terugkerende enquêtes, enz.",
"manage_card_details": "Beheer kaartgegevens",
"manage_subscription": "Beheer abonnement",
"month": "Maand",
"monthly": "Maandelijks",
"monthly_identified_users": "Maandelijks geïdentificeerde gebruikers",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_trial": "Proefperiode",
"plan_unknown": "Onbekend",
"plan_upgraded_successfully": "Plan geüpgraded",
"premium_support_with_slas": "Premium ondersteuning met SLA's",
"remove_branding": "Branding verwijderen",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Onbeperkt werkruimtes",
"upgrade": "Upgraden",
"uptime_sla_99": "Uptime-SLA (99%)",
"website_surveys": "Website-enquêtes"
"website_surveys": "Website-enquêtes",
"year": "Jaar"
},
"domain": {
"customize_favicon_description": "Upload een aangepaste favicon om je linkenquête-ervaring te personaliseren en je merkpresentie te versterken.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.",
"manage_card_details": "Gerenciar Detalhes do Cartão",
"manage_subscription": "Gerenciar Assinatura",
"month": "mês",
"monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_trial": "Trial",
"plan_unknown": "desconhecido",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"remove_branding": "Remover Marca",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",
"uptime_sla_99": "Tempo de atividade SLA (99%)",
"website_surveys": "Pesquisas de Site"
"website_surveys": "Pesquisas de Site",
"year": "ano"
},
"domain": {
"customize_favicon_description": "Faça o upload de um favicon personalizado para personalizar a experiência da sua pesquisa por link e fortalecer a presença da sua marca.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Saltar Perguntas, Campos Ocultos, Inquéritos Regulares, etc.",
"manage_card_details": "Gerir Detalhes do Cartão",
"manage_subscription": "Gerir Subscrição",
"month": "Mês",
"monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_trial": "Trial",
"plan_unknown": "Desconhecido",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"remove_branding": "Possibilidade de remover o logo",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",
"uptime_sla_99": "SLA de Tempo de Atividade (99%)",
"website_surveys": "Inquéritos (site)"
"website_surveys": "Inquéritos (site)",
"year": "Ano"
},
"domain": {
"customize_favicon_description": "Carregue um favicon personalizado para personalizar a experiência do seu inquérito por link e reforçar a presença da sua marca.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Salturi Logice, Câmpuri Ascunse, Sondaje Recurente, etc.",
"manage_card_details": "Gestionați detaliile cardului",
"manage_subscription": "Gestionați abonamentul",
"month": "Lună",
"monthly": "Lunar",
"monthly_identified_users": "Utilizatori identificați lunar",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Scală",
"plan_trial": "Trial",
"plan_unknown": "Necunoscut",
"plan_upgraded_successfully": "Planul a fost upgradat cu succes",
"premium_support_with_slas": "Suport premium cu SLA-uri",
"remove_branding": "Eliminare branding",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Workspaces nelimitate",
"upgrade": "Actualizare",
"uptime_sla_99": "Disponibilitate SLA (99%)",
"website_surveys": "Sondaje ale site-ului"
"website_surveys": "Sondaje ale site-ului",
"year": "An"
},
"domain": {
"customize_favicon_description": "Încărcați un favicon personalizat pentru a oferi o experiență unică sondajului de linkuri și pentru a consolida prezența brandului.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Переходы по логике, скрытые поля, повторяющиеся опросы и др.",
"manage_card_details": "Управление данными карты",
"manage_subscription": "Управление подпиской",
"month": "Месяц",
"monthly": "Ежемесячно",
"monthly_identified_users": "Ежемесячно идентифицированные пользователи",
"plan_hobby": "Хобби",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_trial": "Пробный",
"plan_unknown": "Неизвестно",
"plan_upgraded_successfully": "Тариф успешно обновлён",
"premium_support_with_slas": "Премиум-поддержка с SLA",
"remove_branding": "Удалить брендинг",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
"upgrade": "Обновить",
"uptime_sla_99": "SLA по времени безотказной работы (99%)",
"website_surveys": "Опросы на сайте"
"website_surveys": "Опросы на сайте",
"year": "Год"
},
"domain": {
"customize_favicon_description": "Загрузите свой favicon, чтобы персонализировать опросы по ссылке и усилить узнаваемость бренда.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "Logikhopp, dolda fält, återkommande enkäter, etc.",
"manage_card_details": "Hantera kortuppgifter",
"manage_subscription": "Hantera prenumeration",
"month": "Månad",
"monthly": "Månadsvis",
"monthly_identified_users": "Månadsvis identifierade användare",
"plan_hobby": "Hobby",
"plan_pro": "Pro",
"plan_scale": "Skala",
"plan_trial": "Testperiod",
"plan_unknown": "Okänd",
"plan_upgraded_successfully": "Plan uppgraderad",
"premium_support_with_slas": "Premiumsupport med SLA",
"remove_branding": "Ta bort varumärke",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "Obegränsat antal arbetsytor",
"upgrade": "Uppgradera",
"uptime_sla_99": "Drifttids-SLA (99%)",
"website_surveys": "Webbplatsenkäter"
"website_surveys": "Webbplatsenkäter",
"year": "År"
},
"domain": {
"customize_favicon_description": "Ladda upp en egen favicon för att anpassa din länkenkät och stärka ditt varumärke.",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "逻辑 跳转 , 隐藏 字段 , 定期 调查 , 等",
"manage_card_details": "管理卡片详情",
"manage_subscription": "管理 订阅",
"month": "月",
"monthly": "每月",
"monthly_identified_users": "每月 已识别的 用户",
"plan_hobby": "兴趣版",
"plan_pro": "专业版",
"plan_scale": "规模版",
"plan_trial": "试用版",
"plan_unknown": "未知",
"plan_upgraded_successfully": "计划 升级 成功",
"premium_support_with_slas": "优质支持与 SLAs",
"remove_branding": "移除 品牌",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "无限工作区",
"upgrade": "升级",
"uptime_sla_99": "正常运行时间 SLA (99%)",
"website_surveys": "网站 调查"
"website_surveys": "网站 调查",
"year": "年"
},
"domain": {
"customize_favicon_description": "上传自定义 Favicon个性化您的链接问卷体验提升品牌形象。",

View File

@@ -979,8 +979,14 @@
"logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。",
"manage_card_details": "管理卡片詳細資料",
"manage_subscription": "管理訂閱",
"month": "月",
"monthly": "每月",
"monthly_identified_users": "每月識別使用者",
"plan_hobby": "興趣版",
"plan_pro": "專業版",
"plan_scale": "規模版",
"plan_trial": "試用版",
"plan_unknown": "未知",
"plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援",
"remove_branding": "移除品牌",
@@ -996,7 +1002,8 @@
"unlimited_workspaces": "無限工作區",
"upgrade": "升級",
"uptime_sla_99": "正常運作時間 SLA (99%)",
"website_surveys": "網站問卷"
"website_surveys": "網站問卷",
"year": "年"
},
"domain": {
"customize_favicon_description": "上傳自訂 Favicon讓您的連結問卷體驗更具個人化並強化品牌形象。",

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { hasCloudEntitlement, hasCloudEntitlementWithLicenseGuard } from "./feature-access";
const mocks = vi.hoisted(() => ({
isCloud: true,
getBilling: vi.fn(),
getEnterpriseLicense: vi.fn(),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
get IS_FORMBRICKS_CLOUD() {
return mocks.isCloud;
},
};
});
vi.mock("./organization-billing", () => ({
getOrganizationBillingWithReadThroughSync: mocks.getBilling,
}));
vi.mock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: mocks.getEnterpriseLicense,
}));
describe("feature-access", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isCloud = true;
mocks.getBilling.mockResolvedValue(null);
mocks.getEnterpriseLicense.mockResolvedValue({ active: true });
});
test("hasCloudEntitlement returns false outside cloud mode", async () => {
mocks.isCloud = false;
const result = await hasCloudEntitlement("org_1", "custom-links-in-surveys");
expect(result).toBe(false);
expect(mocks.getBilling).not.toHaveBeenCalled();
});
test("hasCloudEntitlement returns false when feature is missing", async () => {
mocks.getBilling.mockResolvedValue({
stripe: {
features: ["respondent-identification"],
},
});
const result = await hasCloudEntitlement("org_1", "custom-links-in-surveys");
expect(result).toBe(false);
});
test("hasCloudEntitlement returns true when feature exists", async () => {
mocks.getBilling.mockResolvedValue({
stripe: {
features: ["custom-links-in-surveys"],
},
});
const result = await hasCloudEntitlement("org_1", "custom-links-in-surveys");
expect(result).toBe(true);
});
test("hasCloudEntitlementWithLicenseGuard returns false when enterprise license is inactive", async () => {
mocks.getEnterpriseLicense.mockResolvedValue({ active: false });
const result = await hasCloudEntitlementWithLicenseGuard("org_1", "custom-links-in-surveys");
expect(result).toBe(false);
expect(mocks.getBilling).not.toHaveBeenCalled();
});
test("hasCloudEntitlementWithLicenseGuard returns true when license and entitlement are present", async () => {
mocks.getBilling.mockResolvedValue({
stripe: {
features: ["custom-links-in-surveys"],
},
});
const result = await hasCloudEntitlementWithLicenseGuard("org_1", "custom-links-in-surveys");
expect(result).toBe(true);
});
});

View File

@@ -0,0 +1,28 @@
import "server-only";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationBillingWithReadThroughSync } from "./organization-billing";
export const hasCloudEntitlement = async (
organizationId: string,
featureLookupKey: string
): Promise<boolean> => {
if (!IS_FORMBRICKS_CLOUD) return false;
const billing = await getOrganizationBillingWithReadThroughSync(organizationId);
const features = billing?.stripe?.features ?? [];
return features.includes(featureLookupKey);
};
export const hasCloudEntitlementWithLicenseGuard = async (
organizationId: string,
featureLookupKey: string
): Promise<boolean> => {
if (!IS_FORMBRICKS_CLOUD) return false;
const license = await getEnterpriseLicense();
if (!license.active) return false;
return hasCloudEntitlement(organizationId, featureLookupKey);
};

View File

@@ -0,0 +1,506 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization,
findOrganizationIdByStripeCustomerId,
getOrganizationBillingWithReadThroughSync,
reconcileCloudStripeSubscriptionsForOrganization,
syncOrganizationBillingFromStripe,
} from "./organization-billing";
const mocks = vi.hoisted(() => ({
isCloud: true,
getBillingCacheKey: vi.fn(),
prismaFindUnique: vi.fn(),
prismaUpdate: vi.fn(),
prismaFindFirst: vi.fn(),
cacheWithCache: vi.fn(),
cacheDel: vi.fn(),
loggerWarn: vi.fn(),
getCloudPlanFromProductId: vi.fn(),
getLegacyPlanFromCloudPlan: vi.fn(),
getLimitsFromCloudPlan: vi.fn(),
customersCreate: vi.fn(),
subscriptionsList: vi.fn(),
subscriptionsCreate: vi.fn(),
subscriptionsCancel: vi.fn(),
pricesList: vi.fn(),
entitlementsList: vi.fn(),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
get IS_FORMBRICKS_CLOUD() {
return mocks.isCloud;
},
};
});
vi.mock("@formbricks/cache", () => ({
createCacheKey: {
organization: {
billing: mocks.getBillingCacheKey,
},
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findUnique: mocks.prismaFindUnique,
update: mocks.prismaUpdate,
findFirst: mocks.prismaFindFirst,
},
},
}));
vi.mock("@/lib/cache", () => ({
cache: {
withCache: mocks.cacheWithCache,
del: mocks.cacheDel,
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
warn: mocks.loggerWarn,
},
}));
vi.mock("./stripe-catalog", async (importOriginal) => {
const actual = await importOriginal<typeof import("./stripe-catalog")>();
return {
...actual,
getCloudPlanFromProductId: mocks.getCloudPlanFromProductId,
getLegacyPlanFromCloudPlan: mocks.getLegacyPlanFromCloudPlan,
getLimitsFromCloudPlan: mocks.getLimitsFromCloudPlan,
};
});
vi.mock("./stripe-client", () => ({
stripeClient: {
customers: { create: mocks.customersCreate },
subscriptions: {
list: mocks.subscriptionsList,
create: mocks.subscriptionsCreate,
cancel: mocks.subscriptionsCancel,
},
prices: { list: mocks.pricesList },
entitlements: {
activeEntitlements: {
list: mocks.entitlementsList,
},
},
},
}));
describe("organization-billing", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isCloud = true;
mocks.getBillingCacheKey.mockReturnValue("billing-cache-key");
mocks.getCloudPlanFromProductId.mockReturnValue("pro");
mocks.getLegacyPlanFromCloudPlan.mockReturnValue("startup");
mocks.getLimitsFromCloudPlan.mockReturnValue({
projects: 3,
responses: 2000,
contacts: 5000,
});
mocks.subscriptionsList.mockResolvedValue({ data: [] });
mocks.pricesList.mockResolvedValue({
data: [{ id: "price_hobby_1" }],
});
mocks.entitlementsList.mockResolvedValue({ data: [], has_more: false });
});
test("ensureStripeCustomerForOrganization returns null when org does not exist", async () => {
mocks.prismaFindUnique.mockResolvedValue(null);
const result = await ensureStripeCustomerForOrganization("org_missing");
expect(result).toEqual({ customerId: null });
expect(mocks.customersCreate).not.toHaveBeenCalled();
});
test("ensureStripeCustomerForOrganization returns existing customer id", async () => {
mocks.prismaFindUnique.mockResolvedValue({
id: "org_1",
name: "Org 1",
billing: { stripeCustomerId: "cus_existing" },
});
const result = await ensureStripeCustomerForOrganization("org_1");
expect(result).toEqual({ customerId: "cus_existing" });
expect(mocks.customersCreate).not.toHaveBeenCalled();
expect(mocks.prismaUpdate).not.toHaveBeenCalled();
});
test("ensureStripeCustomerForOrganization creates and stores a Stripe customer", async () => {
mocks.prismaFindUnique.mockResolvedValue({
id: "org_1",
name: "Org 1",
billing: { plan: "free" },
});
mocks.customersCreate.mockResolvedValue({ id: "cus_new" });
const result = await ensureStripeCustomerForOrganization("org_1");
expect(result).toEqual({ customerId: "cus_new" });
expect(mocks.customersCreate).toHaveBeenCalledWith(
{
name: "Org 1",
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-customer-org_1" }
);
expect(mocks.prismaUpdate).toHaveBeenCalledWith({
where: { id: "org_1" },
data: {
billing: expect.objectContaining({
stripeCustomerId: "cus_new",
billingMode: "stripe",
stripe: expect.objectContaining({
lastSyncedAt: expect.any(String),
}),
}),
},
});
expect(mocks.cacheDel).toHaveBeenCalledWith(["billing-cache-key"]);
});
test("syncOrganizationBillingFromStripe returns billing unchanged when customer is missing", async () => {
const billing = { plan: "free" };
mocks.prismaFindUnique.mockResolvedValue({ id: "org_1", billing });
const result = await syncOrganizationBillingFromStripe("org_1");
expect(result).toEqual(billing);
expect(mocks.subscriptionsList).not.toHaveBeenCalled();
});
test("syncOrganizationBillingFromStripe ignores duplicate webhook events", async () => {
const billing = {
stripeCustomerId: "cus_1",
stripe: {
lastSyncedEventId: "evt_1",
lastStripeEventCreatedAt: new Date("2026-02-19T00:00:00.000Z").toISOString(),
},
};
mocks.prismaFindUnique.mockResolvedValue({ id: "org_1", billing });
const result = await syncOrganizationBillingFromStripe("org_1", { id: "evt_1", created: 1739923200 });
expect(result).toEqual(billing);
expect(mocks.subscriptionsList).not.toHaveBeenCalled();
});
test("syncOrganizationBillingFromStripe ignores older webhook events", async () => {
const billing = {
stripeCustomerId: "cus_1",
stripe: {
lastStripeEventCreatedAt: "2026-02-20T00:00:00.000Z",
},
};
mocks.prismaFindUnique.mockResolvedValue({ id: "org_1", billing });
const result = await syncOrganizationBillingFromStripe("org_1", { id: "evt_old", created: 1739923200 });
expect(result).toEqual(billing);
expect(mocks.subscriptionsList).not.toHaveBeenCalled();
});
test("syncOrganizationBillingFromStripe stores normalized stripe snapshot", async () => {
mocks.prismaFindUnique.mockResolvedValue({
id: "org_1",
billing: { stripeCustomerId: "cus_1", stripe: { lastSyncedEventId: null } },
});
mocks.subscriptionsList.mockResolvedValue({
data: [
{
id: "sub_1",
status: "active",
current_period_start: 1739923200,
items: {
data: [
{
price: {
product: { id: "prod_pro" },
recurring: { usage_type: "licensed", interval: "year" },
},
},
],
},
},
],
});
mocks.entitlementsList.mockResolvedValue({
data: [
{ id: "ent_1", lookup_key: "custom-links-in-surveys" },
{ id: "ent_2", lookup_key: "custom-links-in-surveys" },
{ id: "ent_3", lookup_key: null },
],
has_more: false,
});
const result = await syncOrganizationBillingFromStripe("org_1", { id: "evt_new", created: 1739923300 });
expect(mocks.prismaUpdate).toHaveBeenCalledWith({
where: { id: "org_1" },
data: {
billing: expect.objectContaining({
stripeCustomerId: "cus_1",
plan: "startup",
period: "yearly",
limits: {
projects: 3,
monthly: {
responses: 2000,
miu: 5000,
},
},
stripe: expect.objectContaining({
plan: "pro",
subscriptionId: "sub_1",
features: ["custom-links-in-surveys"],
lastSyncedEventId: "evt_new",
lastStripeEventCreatedAt: expect.any(String),
lastSyncedAt: expect.any(String),
}),
}),
},
});
expect(result?.stripe?.plan).toBe("pro");
expect(result?.stripe?.features).toEqual(["custom-links-in-surveys"]);
expect(mocks.cacheDel).toHaveBeenCalledWith(["billing-cache-key"]);
});
test("syncOrganizationBillingFromStripe prefers higher-tier active subscription over hobby", async () => {
mocks.getCloudPlanFromProductId.mockImplementation((productId: string) => {
if (productId === "prod_hobby") return "hobby";
if (productId === "prod_pro") return "pro";
return "unknown";
});
mocks.getLegacyPlanFromCloudPlan.mockImplementation((plan: string) =>
plan === "pro" ? "startup" : "free"
);
mocks.getLimitsFromCloudPlan.mockImplementation((plan: string) =>
plan === "pro"
? { projects: 3, responses: 2000, contacts: 5000 }
: { projects: 1, responses: 250, contacts: null }
);
mocks.prismaFindUnique.mockResolvedValue({
id: "org_1",
billing: { stripeCustomerId: "cus_1", stripe: {} },
});
mocks.subscriptionsList.mockResolvedValue({
data: [
{
id: "sub_hobby",
created: 1739923100,
status: "active",
current_period_start: 1739923100,
items: {
data: [
{
price: {
product: { id: "prod_hobby" },
recurring: { usage_type: "licensed", interval: "month" },
},
},
],
},
},
{
id: "sub_pro",
created: 1739923200,
status: "active",
current_period_start: 1739923200,
items: {
data: [
{
price: {
product: { id: "prod_pro" },
recurring: { usage_type: "licensed", interval: "month" },
},
},
],
},
},
],
});
const result = await syncOrganizationBillingFromStripe("org_1");
expect(result?.stripe?.subscriptionId).toBe("sub_pro");
expect(result?.stripe?.plan).toBe("pro");
expect(result?.plan).toBe("startup");
});
test("getOrganizationBillingWithReadThroughSync returns cached billing when no stripe customer exists", async () => {
const cachedBilling = { plan: "free" };
mocks.cacheWithCache.mockResolvedValue(cachedBilling);
const result = await getOrganizationBillingWithReadThroughSync("org_1");
expect(result).toEqual(cachedBilling);
expect(mocks.prismaFindUnique).not.toHaveBeenCalled();
});
test("getOrganizationBillingWithReadThroughSync returns fresh cached billing without sync", async () => {
const cachedBilling = {
stripeCustomerId: "cus_1",
stripe: { lastSyncedAt: new Date().toISOString() },
};
mocks.cacheWithCache.mockResolvedValue(cachedBilling);
const result = await getOrganizationBillingWithReadThroughSync("org_1");
expect(result).toEqual(cachedBilling);
expect(mocks.prismaFindUnique).not.toHaveBeenCalled();
});
test("getOrganizationBillingWithReadThroughSync falls back to cached billing when sync fails", async () => {
const cachedBilling = {
stripeCustomerId: "cus_1",
stripe: { lastSyncedAt: new Date(Date.now() - 6 * 60 * 1000).toISOString() },
};
mocks.cacheWithCache.mockResolvedValue(cachedBilling);
mocks.prismaFindUnique.mockResolvedValue({ id: "org_1", billing: cachedBilling });
mocks.subscriptionsList.mockRejectedValue(new Error("stripe down"));
const result = await getOrganizationBillingWithReadThroughSync("org_1");
expect(result).toEqual(cachedBilling);
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), organizationId: "org_1" },
"Failed to refresh billing snapshot from Stripe"
);
});
test("findOrganizationIdByStripeCustomerId returns matching organization id", async () => {
mocks.prismaFindFirst.mockResolvedValue({ id: "org_1" });
const result = await findOrganizationIdByStripeCustomerId("cus_1");
expect(result).toBe("org_1");
expect(mocks.prismaFindFirst).toHaveBeenCalledWith({
where: {
OR: [
{ billing: { path: ["stripeCustomerId"], equals: "cus_1" } },
{ billing: { path: ["stripe", "customerId"], equals: "cus_1" } },
],
},
select: { id: true },
});
});
test("ensureCloudStripeSetupForOrganization does nothing when cloud mode is disabled", async () => {
mocks.isCloud = false;
await ensureCloudStripeSetupForOrganization("org_1");
expect(mocks.prismaFindUnique).not.toHaveBeenCalled();
});
test("ensureCloudStripeSetupForOrganization provisions hobby subscription when org has no active subscription", async () => {
mocks.prismaFindUnique
.mockResolvedValueOnce({
id: "org_1",
name: "Org 1",
billing: { plan: "free" },
})
.mockResolvedValueOnce({
id: "org_1",
billing: { stripeCustomerId: "cus_new", stripe: {} },
});
mocks.customersCreate.mockResolvedValue({ id: "cus_new" });
mocks.subscriptionsList.mockResolvedValueOnce({ data: [] }).mockResolvedValueOnce({
data: [
{
id: "sub_hobby",
created: 1739923200,
status: "active",
current_period_start: 1739923200,
items: {
data: [
{
price: {
product: { id: "prod_hobby" },
recurring: { usage_type: "licensed", interval: "month" },
},
},
],
},
},
],
});
await ensureCloudStripeSetupForOrganization("org_1");
expect(mocks.pricesList).toHaveBeenCalledWith({
lookup_keys: ["price_hobby_monthly"],
active: true,
limit: 1,
});
expect(mocks.subscriptionsCreate).toHaveBeenCalledWith(
{
customer: "cus_new",
items: [{ price: "price_hobby_1", quantity: 1 }],
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
);
});
test("reconcileCloudStripeSubscriptionsForOrganization cancels hobby when paid subscription is active", async () => {
mocks.getCloudPlanFromProductId.mockImplementation((productId: string) => {
if (productId === "prod_hobby") return "hobby";
if (productId === "prod_pro") return "pro";
return "unknown";
});
mocks.prismaFindUnique.mockResolvedValue({
id: "org_1",
billing: { stripeCustomerId: "cus_1", stripe: {} },
});
mocks.subscriptionsList.mockResolvedValue({
data: [
{
id: "sub_hobby",
created: 1739923100,
status: "active",
items: {
data: [
{
price: {
product: { id: "prod_hobby" },
},
},
],
},
},
{
id: "sub_pro",
created: 1739923200,
status: "active",
items: {
data: [
{
price: {
product: { id: "prod_pro" },
},
},
],
},
},
],
});
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,413 @@
import "server-only";
import { type CacheKey, createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { type TOrganizationBilling } from "@formbricks/types/organizations";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import {
CLOUD_PLAN_LEVEL,
CLOUD_STRIPE_PRICE_LOOKUP_KEYS,
type TCloudStripePlan,
getCloudPlanFromProductId,
getLegacyPlanFromCloudPlan,
getLimitsFromCloudPlan,
} from "./stripe-catalog";
import { stripeClient } from "./stripe-client";
const BILLING_SYNC_STALE_MS = 5 * 60 * 1000;
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<string>(["trialing", "active", "past_due", "unpaid", "paused"]);
type TBillingJson = Omit<TOrganizationBilling, "periodStart"> & {
periodStart?: string | Date | null;
};
const getBillingCacheKey = (organizationId: string) =>
createCacheKey.organization.billing(organizationId) as CacheKey;
const toIsoStringOrNull = (date: Date | null | undefined): string | null =>
date ? date.toISOString() : null;
const getDateFromBilling = (value: string | null | undefined): Date | null => {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const getBillingOrThrow = (organizationId: string, billing: unknown): TBillingJson => {
if (!billing) {
throw new ResourceNotFoundError("Organization", organizationId);
}
return billing as TBillingJson;
};
const listAllActiveEntitlements = async (customerId: string): Promise<string[]> => {
if (!stripeClient) return [];
const featureLookupKeys: string[] = [];
let startingAfter: string | undefined;
do {
const result = await stripeClient.entitlements.activeEntitlements.list({
customer: customerId,
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
});
for (const entitlement of result.data) {
if (entitlement.lookup_key) {
featureLookupKeys.push(entitlement.lookup_key);
}
}
const lastItem = result.data.at(-1);
startingAfter = result.has_more && lastItem ? lastItem.id : undefined;
} while (startingAfter);
return [...new Set(featureLookupKeys)];
};
const getSubscriptionTopPlanLevel = (
subscription: {
items: {
data: Array<{
price: {
product: string | { id: string };
};
}>;
};
} | null
): number => {
if (!subscription) return CLOUD_PLAN_LEVEL.unknown;
let topLevel = CLOUD_PLAN_LEVEL.unknown;
for (const item of subscription.items.data) {
const product = item.price.product;
const productId = typeof product === "string" ? product : product.id;
const plan = getCloudPlanFromProductId(productId);
topLevel = Math.max(topLevel, CLOUD_PLAN_LEVEL[plan]);
}
return topLevel;
};
const resolveCurrentSubscription = async (customerId: string) => {
if (!stripeClient) return null;
const subscriptions = await stripeClient.subscriptions.list({
customer: customerId,
status: "all",
limit: 20,
});
const preferred = [...subscriptions.data]
.filter((subscription) => ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status))
.sort((left, right) => {
const leftLevel = getSubscriptionTopPlanLevel(left);
const rightLevel = getSubscriptionTopPlanLevel(right);
if (leftLevel !== rightLevel) {
return rightLevel - leftLevel;
}
return right.created - left.created;
})[0];
return preferred ?? null;
};
const resolveCloudPlanFromSubscription = (
subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>
) => {
if (!subscription) return "hobby" as TCloudStripePlan;
for (const item of subscription.items.data) {
const product = item.price.product;
const productId = typeof product === "string" ? product : product.id;
const plan = getCloudPlanFromProductId(productId);
if (plan !== "unknown") return plan;
}
return "unknown" as TCloudStripePlan;
};
const resolveBillingPeriod = (subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>) => {
if (!subscription) return "monthly" as const;
const baseItem = subscription.items.data.find((item) => item.price.recurring?.usage_type !== "metered");
return baseItem?.price.recurring?.interval === "year" ? ("yearly" as const) : ("monthly" as const);
};
const resolvePeriodStart = (subscription: Awaited<ReturnType<typeof resolveCurrentSubscription>>) => {
if (!subscription?.current_period_start) return new Date();
return new Date(subscription.current_period_start * 1000);
};
const ensureHobbySubscription = async (
organizationId: string,
customerId: string,
idempotencySuffix: string
): Promise<void> => {
if (!stripeClient) return;
const hobbyPrices = await stripeClient.prices.list({
lookup_keys: [CLOUD_STRIPE_PRICE_LOOKUP_KEYS.HOBBY_MONTHLY],
active: true,
limit: 1,
});
const hobbyPrice = hobbyPrices.data[0];
if (!hobbyPrice) {
throw new Error(`Stripe price lookup key not found: ${CLOUD_STRIPE_PRICE_LOOKUP_KEYS.HOBBY_MONTHLY}`);
}
await stripeClient.subscriptions.create(
{
customer: customerId,
items: [{ price: hobbyPrice.id, quantity: 1 }],
metadata: { organizationId },
},
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
);
};
export const ensureStripeCustomerForOrganization = async (
organizationId: string
): Promise<{ customerId: string | null }> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) {
return { customerId: null };
}
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { id: true, name: true, billing: true },
});
if (!organization) {
return { customerId: null };
}
const billing = getBillingOrThrow(organization.id, organization.billing);
if (billing.stripeCustomerId) {
return { customerId: billing.stripeCustomerId };
}
const customer = await stripeClient.customers.create(
{
name: organization.name,
metadata: { organizationId: organization.id },
},
{ idempotencyKey: `ensure-customer-${organization.id}` }
);
await prisma.organization.update({
where: { id: organization.id },
data: {
billing: {
...billing,
stripeCustomerId: customer.id,
billingMode: "stripe",
stripe: {
...(billing.stripe ?? {}),
lastSyncedAt: new Date().toISOString(),
},
},
},
});
await cache.del([getBillingCacheKey(organization.id)]);
return { customerId: customer.id };
};
export const syncOrganizationBillingFromStripe = async (
organizationId: string,
event?: { id: string; created: number }
): Promise<TBillingJson | null> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) {
return null;
}
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { id: true, billing: true },
});
if (!organization) return null;
const billing = getBillingOrThrow(organization.id, organization.billing);
const customerId = billing.stripeCustomerId;
if (!customerId) return billing;
const existingStripeSnapshot = billing.stripe ?? {};
const previousEventDate = getDateFromBilling(existingStripeSnapshot.lastStripeEventCreatedAt ?? null);
const incomingEventDate = event ? new Date(event.created * 1000) : null;
if (event?.id && existingStripeSnapshot.lastSyncedEventId === event.id) {
return billing;
}
if (incomingEventDate && previousEventDate && incomingEventDate < previousEventDate) {
return billing;
}
const [subscription, featureLookupKeys] = await Promise.all([
resolveCurrentSubscription(customerId),
listAllActiveEntitlements(customerId),
]);
const cloudPlan = resolveCloudPlanFromSubscription(subscription);
const legacyPlan = getLegacyPlanFromCloudPlan(cloudPlan);
const limits = getLimitsFromCloudPlan(cloudPlan);
const period = resolveBillingPeriod(subscription);
const periodStart = resolvePeriodStart(subscription);
const updatedBilling: TBillingJson = {
...billing,
stripeCustomerId: customerId,
plan: legacyPlan,
period,
limits: {
projects: limits.projects,
monthly: {
responses: limits.responses,
// We intentionally keep legacy "miu" field for backwards compatibility in current app code.
miu: limits.contacts,
},
},
periodStart: periodStart.toISOString(),
stripe: {
plan: cloudPlan,
subscriptionId: subscription?.id ?? null,
features: featureLookupKeys,
lastStripeEventCreatedAt: toIsoStringOrNull(incomingEventDate ?? previousEventDate),
lastSyncedAt: new Date().toISOString(),
lastSyncedEventId: event?.id ?? existingStripeSnapshot.lastSyncedEventId ?? null,
},
};
await prisma.organization.update({
where: { id: organizationId },
data: {
billing: updatedBilling,
},
});
await cache.del([getBillingCacheKey(organizationId)]);
return updatedBilling;
};
const isSnapshotStale = (billing: TBillingJson | null): boolean => {
const lastSyncedAt = getDateFromBilling(billing?.stripe?.lastSyncedAt ?? null);
if (!lastSyncedAt) return true;
return Date.now() - lastSyncedAt.getTime() > BILLING_SYNC_STALE_MS;
};
export const getOrganizationBillingWithReadThroughSync = async (
organizationId: string
): Promise<TBillingJson | null> => {
const cachedBilling = await cache.withCache(
async () => {
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { billing: true },
});
return (organization?.billing ?? null) as TBillingJson | null;
},
getBillingCacheKey(organizationId),
BILLING_SYNC_STALE_MS
);
if (!IS_FORMBRICKS_CLOUD || !cachedBilling?.stripeCustomerId) {
return cachedBilling;
}
if (!isSnapshotStale(cachedBilling)) {
return cachedBilling;
}
try {
const syncedBilling = await syncOrganizationBillingFromStripe(organizationId);
return syncedBilling ?? cachedBilling;
} catch (error) {
logger.warn({ error, organizationId }, "Failed to refresh billing snapshot from Stripe");
return cachedBilling;
}
};
export const findOrganizationIdByStripeCustomerId = async (customerId: string): Promise<string | null> => {
const organization = await prisma.organization.findFirst({
where: {
OR: [
{ billing: { path: ["stripeCustomerId"], equals: customerId } },
{ billing: { path: ["stripe", "customerId"], equals: customerId } },
],
},
select: { id: true },
});
return organization?.id ?? null;
};
export const reconcileCloudStripeSubscriptionsForOrganization = async (
organizationId: string,
idempotencySuffix = "reconcile"
): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { id: true, billing: true },
});
if (!organization) return;
const billing = getBillingOrThrow(organization.id, organization.billing);
const customerId = billing.stripeCustomerId;
if (!customerId) return;
const subscriptions = await stripeClient.subscriptions.list({
customer: customerId,
status: "all",
limit: 20,
});
const activeSubscriptions = subscriptions.data.filter((subscription) =>
ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)
);
const subscriptionsWithPlanLevel = activeSubscriptions.map((subscription) => ({
subscription,
planLevel: getSubscriptionTopPlanLevel(subscription),
}));
const hasPaidOrTrialSubscription = subscriptionsWithPlanLevel.some(
({ planLevel }) => planLevel > CLOUD_PLAN_LEVEL.hobby || planLevel === CLOUD_PLAN_LEVEL.unknown
);
if (hasPaidOrTrialSubscription) {
const hobbySubscriptions = subscriptionsWithPlanLevel.filter(
({ planLevel }) => planLevel === CLOUD_PLAN_LEVEL.hobby
);
await Promise.all(
hobbySubscriptions.map(({ subscription }) =>
stripeClient.subscriptions.cancel(subscription.id, {
prorate: false,
})
)
);
return;
}
if (subscriptionsWithPlanLevel.length === 0) {
await ensureHobbySubscription(organization.id, customerId, idempotencySuffix);
}
};
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await syncOrganizationBillingFromStripe(organizationId);
};

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import {
CLOUD_STRIPE_PRODUCT_IDS,
getCloudPlanFromProductId,
getLegacyPlanFromCloudPlan,
getLimitsFromCloudPlan,
} from "./stripe-catalog";
describe("stripe catalog mapping", () => {
test("maps known product IDs to cloud plans", () => {
expect(getCloudPlanFromProductId(CLOUD_STRIPE_PRODUCT_IDS.HOBBY)).toBe("hobby");
expect(getCloudPlanFromProductId(CLOUD_STRIPE_PRODUCT_IDS.PRO)).toBe("pro");
expect(getCloudPlanFromProductId(CLOUD_STRIPE_PRODUCT_IDS.SCALE)).toBe("scale");
expect(getCloudPlanFromProductId(CLOUD_STRIPE_PRODUCT_IDS.TRIAL)).toBe("trial");
});
test("falls back to unknown for unknown product ID", () => {
expect(getCloudPlanFromProductId(null)).toBe("unknown");
expect(getCloudPlanFromProductId(undefined)).toBe("unknown");
expect(getCloudPlanFromProductId("prod_unknown")).toBe("unknown");
});
test("maps cloud plan to legacy plan for backward compatibility", () => {
expect(getLegacyPlanFromCloudPlan("hobby")).toBe("free");
expect(getLegacyPlanFromCloudPlan("pro")).toBe("startup");
expect(getLegacyPlanFromCloudPlan("trial")).toBe("startup");
expect(getLegacyPlanFromCloudPlan("scale")).toBe("custom");
expect(getLegacyPlanFromCloudPlan("unknown")).toBe("free");
});
test("returns plan-specific limits", () => {
expect(getLimitsFromCloudPlan("hobby")).toEqual({ projects: 1, responses: 250, contacts: null });
expect(getLimitsFromCloudPlan("pro")).toEqual({ projects: 3, responses: 2000, contacts: 5000 });
expect(getLimitsFromCloudPlan("trial")).toEqual({ projects: 3, responses: 2000, contacts: 5000 });
expect(getLimitsFromCloudPlan("scale")).toEqual({ projects: 5, responses: 5000, contacts: 10000 });
expect(getLimitsFromCloudPlan("unknown")).toEqual({ projects: 1, responses: 250, contacts: null });
});
});

View File

@@ -0,0 +1,86 @@
import { z } from "zod";
const DEFAULT_CLOUD_STRIPE_PRODUCT_IDS = {
HOBBY: "prod_ToYKB5ESOMZZk5",
PRO: "prod_ToYKQ8WxS3ecgf",
SCALE: "prod_ToYLW5uCQTMa6v",
TRIAL: "prod_TodVcJiEnK5ABK",
} as const;
export const CLOUD_STRIPE_PRODUCT_IDS = {
HOBBY: process.env.CLOUD_STRIPE_PRODUCT_ID_HOBBY ?? DEFAULT_CLOUD_STRIPE_PRODUCT_IDS.HOBBY,
PRO: process.env.CLOUD_STRIPE_PRODUCT_ID_PRO ?? DEFAULT_CLOUD_STRIPE_PRODUCT_IDS.PRO,
SCALE: process.env.CLOUD_STRIPE_PRODUCT_ID_SCALE ?? DEFAULT_CLOUD_STRIPE_PRODUCT_IDS.SCALE,
TRIAL: process.env.CLOUD_STRIPE_PRODUCT_ID_TRIAL ?? DEFAULT_CLOUD_STRIPE_PRODUCT_IDS.TRIAL,
} as const;
export const CLOUD_STRIPE_PRICE_LOOKUP_KEYS = {
HOBBY_MONTHLY: "price_hobby_monthly",
TRIAL_FREE: "price_trial_free",
PRO_MONTHLY: "price_pro_monthly",
PRO_YEARLY: "price_pro_yearly",
SCALE_MONTHLY: "price_scale_monthly",
SCALE_YEARLY: "price_scale_yearly",
PRO_USAGE_RESPONSES: "price_pro_usage_responses",
SCALE_USAGE_RESPONSES: "price_scale_usage_responses",
} as const;
export const CLOUD_STRIPE_FEATURE_LOOKUP_KEYS = {
CUSTOM_REDIRECT_URL: "custom-redirect-url",
CUSTOM_LINKS_IN_SURVEYS: "custom-links-in-surveys",
FOLLOW_UPS: "follow-ups",
SPAM_PROTECTION: "spam-protection",
} as const;
export const ZCloudUpgradePriceLookupKey = z.enum([
CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY,
CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_YEARLY,
CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_MONTHLY,
CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_YEARLY,
]);
export type TCloudUpgradePriceLookupKey = z.infer<typeof ZCloudUpgradePriceLookupKey>;
export const CLOUD_PLAN_LEVEL = {
hobby: 0,
pro: 1,
scale: 2,
trial: 3,
unknown: -1,
} as const;
export type TCloudStripePlan = keyof typeof CLOUD_PLAN_LEVEL;
export const getCloudPlanFromProductId = (productId: string | null | undefined): TCloudStripePlan => {
if (!productId) return "unknown";
if (productId === CLOUD_STRIPE_PRODUCT_IDS.HOBBY) return "hobby";
if (productId === CLOUD_STRIPE_PRODUCT_IDS.PRO) return "pro";
if (productId === CLOUD_STRIPE_PRODUCT_IDS.SCALE) return "scale";
if (productId === CLOUD_STRIPE_PRODUCT_IDS.TRIAL) return "trial";
return "unknown";
};
export const getLegacyPlanFromCloudPlan = (plan: TCloudStripePlan): string => {
if (plan === "hobby" || plan === "unknown") return "free";
if (plan === "pro" || plan === "trial") return "startup";
return "custom";
};
export const getLimitsFromCloudPlan = (
plan: TCloudStripePlan
): { projects: number | null; responses: number | null; contacts: number | null } => {
if (plan === "hobby") {
return { projects: 1, responses: 250, contacts: null };
}
if (plan === "pro" || plan === "trial") {
return { projects: 3, responses: 2000, contacts: 5000 };
}
if (plan === "scale") {
return { projects: 5, responses: 5000, contacts: 10000 };
}
// Unknown plans intentionally fall back to hobby limits as the safest default.
return { projects: 1, responses: 250, contacts: null };
};

View File

@@ -0,0 +1,41 @@
import Stripe from "stripe";
import { afterEach, describe, expect, test, vi } from "vitest";
describe("stripe-client", () => {
afterEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
test("returns null when no Stripe secret key is configured", async () => {
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return actual;
});
vi.doMock("@/lib/env", () => ({
env: {
STRIPE_SECRET_KEY: "",
},
}));
const { stripeClient } = await import("./stripe-client");
expect(stripeClient).toBeNull();
});
test("creates a Stripe client when secret key exists", async () => {
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return actual;
});
vi.doMock("@/lib/env", () => ({
env: {
STRIPE_SECRET_KEY: "sk_test_123",
},
}));
const { stripeClient } = await import("./stripe-client");
expect(stripeClient).toBeInstanceOf(Stripe);
});
});

View File

@@ -0,0 +1,10 @@
import "server-only";
import Stripe from "stripe";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
export const stripeClient = env.STRIPE_SECRET_KEY
? new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: STRIPE_API_VERSION,
})
: null;

View File

@@ -3,12 +3,13 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { STRIPE_PRICE_LOOKUP_KEYS, WEBAPP_URL } from "@/lib/constants";
import { WEBAPP_URL } from "@/lib/constants";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { ZCloudUpgradePriceLookupKey } from "@/modules/billing/lib/stripe-catalog";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session";
import { createSubscription } from "@/modules/ee/billing/api/lib/create-subscription";
@@ -16,7 +17,7 @@ import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscri
const ZUpgradePlanAction = z.object({
environmentId: ZId,
priceLookupKey: z.nativeEnum(STRIPE_PRICE_LOOKUP_KEYS),
priceLookupKey: ZCloudUpgradePriceLookupKey,
});
export const upgradePlanAction = authenticatedActionClient.schema(ZUpgradePlanAction).action(

View File

@@ -1,7 +1,7 @@
import { TFunction } from "i18next";
export type TPricingPlan = {
id: string;
id: "hobby" | "pro" | "scale";
name: string;
featured: boolean;
CTA?: string;
@@ -11,76 +11,103 @@ export type TPricingPlan = {
yearly: string;
};
mainFeatures: string[];
href?: string;
};
export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } => {
const freePlan: TPricingPlan = {
id: "free",
name: t("environments.settings.billing.free"),
// Keep legacy billing translation keys referenced until locale cleanup/migration is done.
void [
t("common.request_pricing"),
t("environments.settings.billing.1000_monthly_responses"),
t("environments.settings.billing.1_workspace"),
t("environments.settings.billing.2000_contacts"),
t("environments.settings.billing.3_workspaces"),
t("environments.settings.billing.5000_monthly_responses"),
t("environments.settings.billing.7500_contacts"),
t("environments.settings.billing.api_webhooks"),
t("environments.settings.billing.attribute_based_targeting"),
t("environments.settings.billing.custom"),
t("environments.settings.billing.custom_contacts_limit"),
t("environments.settings.billing.custom_response_limit"),
t("environments.settings.billing.custom_workspace_limit"),
t("environments.settings.billing.email_embedded_surveys"),
t("environments.settings.billing.email_follow_ups"),
t("environments.settings.billing.enterprise_description"),
t("environments.settings.billing.everything_in_free"),
t("environments.settings.billing.everything_in_startup"),
t("environments.settings.billing.free"),
t("environments.settings.billing.free_description"),
t("environments.settings.billing.hosted_in_frankfurt"),
t("environments.settings.billing.ios_android_sdks"),
t("environments.settings.billing.premium_support_with_slas"),
t("environments.settings.billing.startup"),
t("environments.settings.billing.startup_description"),
t("environments.settings.billing.switch_plan"),
t("environments.settings.billing.unlimited_surveys"),
t("environments.settings.billing.unlimited_team_members"),
t("environments.settings.billing.uptime_sla_99"),
t("environments.settings.billing.website_surveys"),
];
const hobbyPlan: TPricingPlan = {
id: "hobby",
name: "Hobby",
featured: false,
description: t("environments.settings.billing.free_description"),
CTA: "Get started",
description: "Start free",
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"),
"1 Workspace",
"250 Responses / month",
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") + " 🇪🇺",
"Hosted in Frankfurt \ud83c\uddea\ud83c\uddfa",
],
};
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" },
description: "Most popular",
price: { monthly: "$89", yearly: "$890" },
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"),
"Everything in Hobby",
"3 Workspaces",
"2,000 Responses / month (dynamic overage)",
t("environments.settings.billing.remove_branding"),
t("environments.settings.billing.attribute_based_targeting"),
"Respondent Identification",
"Email Follow-ups",
"Custom Webhooks",
t("environments.settings.billing.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"),
},
CTA: t("common.start_free_trial"),
description: "Advanced controls for scaling teams",
price: { monthly: "$390", yearly: "$3,900" },
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"),
"Everything in Pro",
"5 Workspaces",
"5,000 Responses / month (dynamic overage)",
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"),
"Full API Access",
"Quota Management",
"Two-Factor Auth",
"Spam Protection (reCAPTCHA)",
"SSO Enforcement",
"Custom SSO",
"Hosting in USA \ud83c\uddfa\ud83c\uddf8",
"SOC-2 Verification",
],
href: "https://formbricks.com/custom-plan?source=billingView",
};
return {
plans: [freePlan, startupPlan, customPlan],
plans: [hobbyPlan, proPlan, scalePlan],
};
};

View File

@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
CLOUD_STRIPE_PRICE_LOOKUP_KEYS,
CLOUD_STRIPE_PRODUCT_IDS,
} from "@/modules/billing/lib/stripe-catalog";
import { createSubscription } from "./create-subscription";
const mocks = vi.hoisted(() => ({
pricesList: vi.fn(),
subscriptionsList: vi.fn(),
checkoutSessionCreate: vi.fn(),
getOrganization: vi.fn(),
ensureStripeCustomerForOrganization: vi.fn(),
loggerError: vi.fn(),
}));
vi.mock("@/lib/env", () => ({
env: {
STRIPE_SECRET_KEY: "sk_test_123",
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
WEBAPP_URL: "https://app.formbricks.com",
};
});
vi.mock("@/lib/organization/service", () => ({
getOrganization: mocks.getOrganization,
}));
vi.mock("@/modules/billing/lib/organization-billing", () => ({
ensureStripeCustomerForOrganization: mocks.ensureStripeCustomerForOrganization,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: mocks.loggerError,
},
}));
vi.mock("stripe", () => ({
default: class Stripe {
prices = { list: mocks.pricesList };
subscriptions = { list: mocks.subscriptionsList };
checkout = { sessions: { create: mocks.checkoutSessionCreate } };
},
}));
describe("createSubscription", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getOrganization.mockResolvedValue({ id: "org_1", name: "Org 1" });
mocks.ensureStripeCustomerForOrganization.mockResolvedValue({ customerId: "cus_1" });
mocks.subscriptionsList.mockResolvedValue({
data: [
{
items: {
data: [
{
price: {
product: CLOUD_STRIPE_PRODUCT_IDS.HOBBY,
},
},
],
},
},
],
});
mocks.checkoutSessionCreate.mockResolvedValue({ url: "https://stripe.test/session_1" });
});
test("does not send quantity for metered prices and applies trial for first paid upgrade", async () => {
mocks.pricesList.mockResolvedValue({
data: [
{
id: "price_pro_monthly",
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY,
recurring: { usage_type: "licensed" },
},
{
id: "price_pro_usage",
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_USAGE_RESPONSES,
recurring: { usage_type: "metered" },
},
],
});
const result = await createSubscription("org_1", "env_1", CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY);
expect(mocks.checkoutSessionCreate).toHaveBeenCalledWith(
expect.objectContaining({
mode: "subscription",
customer: "cus_1",
line_items: [{ price: "price_pro_monthly", quantity: 1 }, { price: "price_pro_usage" }],
subscription_data: expect.objectContaining({
trial_period_days: 14,
}),
customer_update: { address: "auto", name: "auto" },
})
);
expect(result.status).toBe(200);
});
test("does not apply trial when customer already has paid subscription history", async () => {
mocks.subscriptionsList.mockResolvedValue({
data: [
{
items: {
data: [
{
price: {
product: CLOUD_STRIPE_PRODUCT_IDS.PRO,
},
},
],
},
},
],
});
mocks.pricesList.mockResolvedValue({
data: [
{
id: "price_pro_monthly",
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY,
recurring: { usage_type: "licensed" },
},
{
id: "price_pro_usage",
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_USAGE_RESPONSES,
recurring: { usage_type: "metered" },
},
],
});
await createSubscription("org_1", "env_1", CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY);
expect(mocks.checkoutSessionCreate).toHaveBeenCalledWith(
expect.objectContaining({
subscription_data: expect.not.objectContaining({
trial_period_days: 14,
}),
})
);
});
test("returns newPlan false on checkout creation error", async () => {
mocks.pricesList.mockResolvedValue({
data: [
{
id: "price_pro_monthly",
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY,
recurring: { usage_type: "licensed" },
},
{
id: "price_pro_usage",
lookup_key: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_USAGE_RESPONSES,
recurring: { usage_type: "metered" },
},
],
});
mocks.checkoutSessionCreate.mockRejectedValue(new Error("stripe down"));
const result = await createSubscription("org_1", "env_1", CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY);
expect(result.status).toBe(500);
expect(result.newPlan).toBe(false);
});
});

View File

@@ -1,8 +1,15 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { STRIPE_API_VERSION, STRIPE_PRICE_LOOKUP_KEYS, WEBAPP_URL } from "@/lib/constants";
import { STRIPE_API_VERSION, WEBAPP_URL } from "@/lib/constants";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { ensureStripeCustomerForOrganization } from "@/modules/billing/lib/organization-billing";
import {
CLOUD_STRIPE_PRICE_LOOKUP_KEYS,
CLOUD_STRIPE_PRODUCT_IDS,
TCloudUpgradePriceLookupKey,
getCloudPlanFromProductId,
} from "@/modules/billing/lib/stripe-catalog";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: STRIPE_API_VERSION,
@@ -11,39 +18,91 @@ const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
export const createSubscription = async (
organizationId: string,
environmentId: string,
priceLookupKey: STRIPE_PRICE_LOOKUP_KEYS
priceLookupKey: TCloudUpgradePriceLookupKey
) => {
try {
const organization = await getOrganization(organizationId);
if (!organization) throw new Error("Organization not found.");
const priceObject = (
await stripe.prices.list({
lookup_keys: [priceLookupKey],
})
).data[0];
const { customerId } = await ensureStripeCustomerForOrganization(organizationId);
if (!customerId) throw new Error("Stripe customer unavailable");
if (!priceObject) throw new Error("Price not found");
const existingSubscriptions = await stripe.subscriptions.list({
customer: customerId,
status: "all",
limit: 100,
});
const hasPaidSubscriptionHistory = existingSubscriptions.data.some((subscription) =>
subscription.items.data.some((item) => {
const product = item.price.product;
const productId = typeof product === "string" ? product : product.id;
// "unknown" products are treated as paid history to avoid repeated free trials.
const plan = getCloudPlanFromProductId(productId);
return plan !== "hobby" && productId !== CLOUD_STRIPE_PRODUCT_IDS.HOBBY;
})
);
const lookupKeys: string[] = [priceLookupKey];
if (
priceLookupKey === CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY ||
priceLookupKey === CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_YEARLY
) {
lookupKeys.push(CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_USAGE_RESPONSES);
}
if (
priceLookupKey === CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_MONTHLY ||
priceLookupKey === CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_YEARLY
) {
lookupKeys.push(CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_USAGE_RESPONSES);
}
const prices = await stripe.prices.list({
lookup_keys: lookupKeys,
limit: 100,
});
if (prices.data.length !== lookupKeys.length) {
throw new Error(`One or more prices not found in Stripe for ${lookupKeys.join(", ")}`);
}
const getPriceByLookupKey = (lookupKey: string) => {
const price = prices.data.find((entry) => entry.lookup_key === lookupKey);
if (!price) throw new Error(`Price ${lookupKey} not found`);
return price;
};
const lineItems = lookupKeys.map((lookupKey) => {
const price = getPriceByLookupKey(lookupKey);
if (price.recurring?.usage_type === "metered") {
return { price: price.id };
}
return { price: price.id, quantity: 1 };
});
// Always create a checkout session - let Stripe handle existing customers
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
price: priceObject.id,
quantity: 1,
},
],
line_items: lineItems,
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`,
cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
customer: organization.billing.stripeCustomerId ?? undefined,
customer: customerId,
allow_promotion_codes: true,
subscription_data: {
metadata: { organizationId },
trial_period_days: 15,
...(hasPaidSubscriptionHistory ? {} : { trial_period_days: 14 }),
},
metadata: { organizationId },
billing_address_collection: "required",
customer_update: {
address: "auto",
name: "auto",
},
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
payment_method_data: { allow_redisplay: "always" },
@@ -54,7 +113,7 @@ export const createSubscription = async (
logger.error(err, "Error creating subscription");
return {
status: 500,
newPlan: true,
newPlan: false,
url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
};
}

View File

@@ -2,15 +2,25 @@ import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { handleCheckoutSessionCompleted } from "@/modules/ee/billing/api/lib/checkout-session-completed";
import { handleInvoiceFinalized } from "@/modules/ee/billing/api/lib/invoice-finalized";
import { handleSubscriptionDeleted } from "@/modules/ee/billing/api/lib/subscription-deleted";
import {
findOrganizationIdByStripeCustomerId,
reconcileCloudStripeSubscriptionsForOrganization,
syncOrganizationBillingFromStripe,
} from "@/modules/billing/lib/organization-billing";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: STRIPE_API_VERSION,
});
const webhookSecret: string = env.STRIPE_WEBHOOK_SECRET!;
const relevantEvents = new Set([
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.finalized",
"entitlements.active_entitlement_summary.updated",
]);
export const webhookHandler = async (requestBody: string, stripeSignature: string) => {
let event: Stripe.Event;
@@ -23,12 +33,45 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
return { status: 400, message: `Webhook Error: ${errorMessage}` };
}
if (event.type === "checkout.session.completed") {
await handleCheckoutSessionCompleted(event);
} else if (event.type === "invoice.finalized") {
await handleInvoiceFinalized(event);
} else if (event.type === "customer.subscription.deleted") {
await handleSubscriptionDeleted(event);
if (!relevantEvents.has(event.type)) {
return { status: 200, message: { received: true } };
}
const eventObject = event.data.object as Stripe.Event.Data.Object;
const metadataOrgId =
"metadata" in eventObject &&
eventObject.metadata &&
typeof (eventObject.metadata as Record<string, unknown>).organizationId === "string"
? ((eventObject.metadata as Record<string, unknown>).organizationId as string)
: null;
const customerId =
"customer" in eventObject && typeof eventObject.customer === "string" ? eventObject.customer : null;
let organizationId = metadataOrgId;
if (!organizationId && customerId) {
organizationId = await findOrganizationIdByStripeCustomerId(customerId);
}
if (!organizationId) {
logger.warn(
{ eventType: event.type, eventId: event.id },
"Skipping Stripe webhook: organization not resolved"
);
return { status: 200, message: { received: true } };
}
try {
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,
created: event.created,
});
} catch (error) {
logger.error(
{ error, eventId: event.id, organizationId, eventType: event.type },
"Failed to sync billing snapshot from Stripe webhook"
);
}
return { status: 200, message: { received: true } };
};

View File

@@ -3,24 +3,18 @@
import { CheckIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { 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";
interface PricingCardProps {
plan: TPricingPlan;
planPeriod: TOrganizationBillingPeriod;
organization: TOrganization;
currentPlan: "hobby" | "pro" | "scale" | "trial" | "unknown";
onUpgrade: () => Promise<void>;
onManageSubscription: () => Promise<void>;
projectFeatureKeys: {
FREE: string;
STARTUP: string;
CUSTOM: string;
};
}
export const PricingCard = ({
@@ -28,102 +22,40 @@ export const PricingCard = ({
plan,
onUpgrade,
onManageSubscription,
organization,
projectFeatureKeys,
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 isCurrentPlan = useMemo(() => {
if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) {
return true;
if (currentPlan === "trial") {
return plan.id === "pro";
}
if (organization.billing.plan === projectFeatureKeys.CUSTOM && plan.id === projectFeatureKeys.CUSTOM) {
return true;
}
return organization.billing.plan === plan.id && organization.billing.period === planPeriod;
}, [
organization.billing.period,
organization.billing.plan,
plan.id,
planPeriod,
projectFeatureKeys.CUSTOM,
projectFeatureKeys.FREE,
]);
return currentPlan === plan.id;
}, [currentPlan, plan.id]);
const CTAButton = useMemo(() => {
if (isCurrentPlan) {
return null;
}
if (plan.id === projectFeatureKeys.CUSTOM) {
return (
<Button
variant="outline"
loading={loading}
onClick={() => {
window.open(plan.href, "_blank", "noopener,noreferrer");
}}
className="flex justify-center bg-white">
{plan.CTA ?? t("common.request_pricing")}
</Button>
);
}
if (plan.id === projectFeatureKeys.STARTUP) {
if (organization.billing.plan === projectFeatureKeys.FREE) {
return (
<Button
loading={loading}
variant="default"
onClick={async () => {
setLoading(true);
await onUpgrade();
setLoading(false);
}}
className="flex justify-center">
{plan.CTA ?? t("common.start_free_trial")}
</Button>
);
}
return (
<Button
loading={loading}
onClick={() => {
setContactModalOpen(true);
}}
className="flex justify-center">
{t("environments.settings.billing.switch_plan")}
</Button>
);
}
return null;
}, [
isCurrentPlan,
loading,
onUpgrade,
organization.billing.plan,
plan.CTA,
plan.featured,
plan.href,
plan.id,
projectFeatureKeys.CUSTOM,
projectFeatureKeys.FREE,
projectFeatureKeys.STARTUP,
t,
]);
return (
<Button
loading={loading}
variant={plan.featured ? "default" : "secondary"}
onClick={async () => {
setLoading(true);
await onUpgrade();
setLoading(false);
}}
className="flex justify-center">
{plan.CTA ?? t("environments.settings.billing.upgrade")}
</Button>
);
}, [isCurrentPlan, loading, onUpgrade, plan.CTA, plan.featured, t]);
return (
<div
@@ -157,18 +89,19 @@ export const PricingCard = ({
)}>
{displayPrice}
</p>
{plan.id !== projectFeatureKeys.CUSTOM && (
<div className="text-sm leading-5">
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
/ {planPeriod === "monthly" ? "Month" : "Year"}
</p>
</div>
)}
<div className="text-sm leading-5">
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
/{" "}
{planPeriod === "monthly"
? t("environments.settings.billing.month")
: t("environments.settings.billing.year")}
</p>
</div>
</div>
{CTAButton}
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
{plan.id !== "hobby" && isCurrentPlan && (
<Button
loading={loading}
onClick={async () => {
@@ -201,16 +134,6 @@ export const PricingCard = ({
</ul>
</div>
</div>
<ConfirmationModal
title="Please reach out to us"
open={contactModalOpen}
setOpen={setContactModalOpen}
onConfirm={() => setContactModalOpen(false)}
buttonText="Close"
buttonVariant="default"
body="To switch your billing rhythm, please reach out to hola@formbricks.com"
/>
</div>
);
};

View File

@@ -1,11 +1,15 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { cn } from "@/lib/cn";
import {
CLOUD_STRIPE_PRICE_LOOKUP_KEYS,
type TCloudUpgradePriceLookupKey,
} from "@/modules/billing/lib/stripe-catalog";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { isSubscriptionCancelledAction, manageSubscriptionAction, upgradePlanAction } from "../actions";
@@ -19,26 +23,40 @@ interface PricingTableProps {
peopleCount: number;
responseCount: number;
projectCount: number;
stripePriceLookupKeys: {
STARTUP_MAY25_MONTHLY: string;
STARTUP_MAY25_YEARLY: string;
};
projectFeatureKeys: {
FREE: string;
STARTUP: string;
CUSTOM: string;
};
hasBillingRights: boolean;
}
const getCurrentCloudPlan = (
organization: TOrganization
): "hobby" | "pro" | "scale" | "trial" | "unknown" => {
if (organization.billing?.stripe?.plan) {
return organization.billing.stripe.plan;
}
if (organization.billing.plan === "free") return "hobby";
if (organization.billing.plan === "startup") return "pro";
if (organization.billing.plan === "custom") return "scale";
return "unknown";
};
const getCurrentCloudPlanLabel = (
plan: "hobby" | "pro" | "scale" | "trial" | "unknown",
t: (key: string) => string
) => {
if (plan === "hobby") return t("environments.settings.billing.plan_hobby");
if (plan === "pro") return t("environments.settings.billing.plan_pro");
if (plan === "scale") return t("environments.settings.billing.plan_scale");
if (plan === "trial") return t("environments.settings.billing.plan_trial");
return t("environments.settings.billing.plan_unknown");
};
export const PricingTable = ({
environmentId,
organization,
peopleCount,
projectFeatureKeys,
responseCount,
projectCount,
stripePriceLookupKeys,
hasBillingRights,
}: PricingTableProps) => {
const { t } = useTranslation();
@@ -53,6 +71,8 @@ export const PricingTable = ({
const router = useRouter();
const [cancellingOn, setCancellingOn] = useState<Date | null>(null);
const currentCloudPlan = useMemo(() => getCurrentCloudPlan(organization), [organization]);
useEffect(() => {
const checkSubscriptionStatus = async () => {
const isSubscriptionCancelledResponse = await isSubscriptionCancelledAction({
@@ -74,7 +94,7 @@ export const PricingTable = ({
}
};
const upgradePlan = async (priceLookupKey) => {
const upgradePlan = async (priceLookupKey: TCloudUpgradePriceLookupKey) => {
try {
const upgradePlanResponse = await upgradePlanAction({
environmentId,
@@ -87,7 +107,7 @@ export const PricingTable = ({
const { status, newPlan, url } = upgradePlanResponse.data;
if (status != 200) {
if (status !== 200) {
throw new Error(t("common.something_went_wrong_please_try_again"));
}
if (!newPlan) {
@@ -106,41 +126,48 @@ export const PricingTable = ({
}
};
const onUpgrade = async (planId: string) => {
if (planId === "startup") {
const onUpgrade = async (planId: "hobby" | "pro" | "scale") => {
if (planId === "hobby") {
toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default"));
return;
}
if (planId === "pro") {
await upgradePlan(
planPeriod === "monthly"
? stripePriceLookupKeys.STARTUP_MAY25_MONTHLY
: stripePriceLookupKeys.STARTUP_MAY25_YEARLY
? CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_MONTHLY
: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.PRO_YEARLY
);
return;
}
if (planId === "custom") {
window.location.href = "https://formbricks.com/custom-plan?source=billingView";
if (planId === "scale") {
await upgradePlan(
planPeriod === "monthly"
? CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_MONTHLY
: CLOUD_STRIPE_PRICE_LOOKUP_KEYS.SCALE_YEARLY
);
return;
}
if (planId === "free") {
toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default"));
}
toast.error(`${t("environments.settings.billing.unable_to_upgrade_plan")}: ${planId}`);
};
const responsesUnlimitedCheck =
organization.billing.plan === "custom" && organization.billing.limits.monthly.responses === null;
currentCloudPlan === "scale" && organization.billing.limits.monthly.responses === null;
const peopleUnlimitedCheck =
organization.billing.plan === "custom" && organization.billing.limits.monthly.miu === null;
currentCloudPlan === "scale" && organization.billing.limits.monthly.miu === null;
const projectsUnlimitedCheck =
organization.billing.plan === "custom" && organization.billing.limits.projects === null;
currentCloudPlan === "scale" && organization.billing.limits.projects === null;
return (
<main>
<div className="flex flex-col gap-8">
<div className="flex flex-col">
<div className="flex w-full">
<h2 className="mr-2 mb-3 inline-flex w-full text-2xl font-bold text-slate-700">
<h2 className="mb-3 mr-2 inline-flex w-full text-2xl font-bold text-slate-700">
{t("environments.settings.billing.current_plan")}:{" "}
<span className="capitalize">{organization.billing.plan}</span>
<span className="capitalize">{getCurrentCloudPlanLabel(currentCloudPlan, t)}</span>
{cancellingOn && (
<Badge
className="mx-2"
@@ -161,7 +188,7 @@ export const PricingTable = ({
)}
</h2>
{organization.billing.stripeCustomerId && organization.billing.plan === "free" && (
{organization.billing.stripeCustomerId && currentCloudPlan === "hobby" && (
<div className="flex w-full justify-end">
<Button
size="sm"
@@ -203,7 +230,7 @@ export const PricingTable = ({
<div
className={cn(
"relative mx-8 mb-8 flex flex-col gap-4",
peopleUnlimitedCheck && "mt-4 mb-0 flex-row pb-0"
peopleUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">
{t("environments.settings.billing.monthly_identified_users")}
@@ -226,7 +253,7 @@ export const PricingTable = ({
<div
className={cn(
"relative mx-8 flex flex-col gap-4 pb-6",
projectsUnlimitedCheck && "mt-4 mb-0 flex-row pb-0"
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">{t("common.workspaces")}</p>
{organization.billing.limits.projects && (
@@ -264,19 +291,19 @@ export const PricingTable = ({
</button>
<button
aria-pressed={planPeriod === "yearly"}
className={`flex-1 items-center rounded-md py-0.5 pr-2 pl-4 text-center whitespace-nowrap ${
className={`flex-1 items-center whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
}`}
onClick={() => handleMonthlyToggle("yearly")}>
{t("environments.settings.billing.annually")}
<span className="ml-2 inline-flex items-center rounded-full border border-green-200 bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
{t("environments.settings.billing.get_2_months_free")} 🔥
{t("environments.settings.billing.get_2_months_free")}
</span>
</button>
</div>
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-3">
<div
className="hidden lg:absolute lg:inset-x-px lg:top-4 lg:bottom-0 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
aria-hidden="true"
/>
{getCloudPricingData(t).plans.map((plan) => (
@@ -287,8 +314,7 @@ export const PricingTable = ({
onUpgrade={async () => {
await onUpgrade(plan.id);
}}
organization={organization}
projectFeatureKeys={projectFeatureKeys}
currentPlan={currentCloudPlan}
onManageSubscription={openCustomerPortal}
/>
))}

View File

@@ -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 {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
@@ -48,8 +47,6 @@ export const PricingPage = async (props) => {
peopleCount={peopleCount}
responseCount={responseCount}
projectCount={projectCount}
stripePriceLookupKeys={STRIPE_PRICE_LOOKUP_KEYS}
projectFeatureKeys={PROJECT_FEATURE_KEYS}
hasBillingRights={hasBillingRights}
/>
</PageContentWrapper>

View File

@@ -28,7 +28,7 @@ export const checkExternalUrlsPermission = async (
throw new ResourceNotFoundError("Organization", organizationId);
}
const isExternalUrlsAllowed = await getExternalUrlsPermission(organizationBilling.plan);
const isExternalUrlsAllowed = await getExternalUrlsPermission(organizationBilling.plan, organizationId);
if (isExternalUrlsAllowed) {
return;
}

View File

@@ -84,7 +84,7 @@ export const SurveyEditorPage = async (props) => {
getSurveyFollowUpsPermission(organizationBilling.plan),
getIsSpamProtectionEnabled(organizationBilling.plan),
getIsQuotasEnabled(organizationBilling.plan),
getExternalUrlsPermission(organizationBilling.plan),
getExternalUrlsPermission(organizationBilling.plan, projectWithTeamIds.organizationId),
]);
const quotas = isQuotasAllowed && survey ? await getQuotas(survey.id) : [];

View File

@@ -2,6 +2,7 @@ import { Organization } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { hasCloudEntitlementWithLicenseGuard } from "@/modules/billing/lib/feature-access";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { checkSpamProtectionPermission, getExternalUrlsPermission } from "./permission";
@@ -14,6 +15,10 @@ vi.mock("@/modules/survey/lib/survey", () => ({
getOrganizationBilling: vi.fn(),
}));
vi.mock("@/modules/billing/lib/feature-access", () => ({
hasCloudEntitlementWithLicenseGuard: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
PROJECT_FEATURE_KEYS: {
@@ -87,6 +92,18 @@ describe("getExternalUrlsPermission - Formbricks Cloud", () => {
const result = await getExternalUrlsPermission("custom-plan");
expect(result).toBe(true);
});
test("should return true when org-aware entitlement checks return true", async () => {
vi.mocked(hasCloudEntitlementWithLicenseGuard).mockResolvedValue(true);
const result = await getExternalUrlsPermission("free", "org_123");
expect(result).toBe(true);
});
test("should return false when one org-aware entitlement check fails", async () => {
vi.mocked(hasCloudEntitlementWithLicenseGuard).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
const result = await getExternalUrlsPermission("free", "org_123");
expect(result).toBe(false);
});
});
describe("getExternalUrlsPermission - Self-hosted", () => {

View File

@@ -1,6 +1,8 @@
import { Organization } from "@prisma/client";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { hasCloudEntitlementWithLicenseGuard } from "@/modules/billing/lib/feature-access";
import { CLOUD_STRIPE_FEATURE_LOOKUP_KEYS } from "@/modules/billing/lib/stripe-catalog";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
@@ -24,8 +26,24 @@ export const checkSpamProtectionPermission = async (organizationId: string): Pro
};
export const getExternalUrlsPermission = async (
billingPlan: Organization["billing"]["plan"]
billingPlan: Organization["billing"]["plan"],
organizationId?: string
): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD && organizationId) {
const [canUseCustomRedirectUrl, canUseCustomLinksInSurveys] = await Promise.all([
hasCloudEntitlementWithLicenseGuard(
organizationId,
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CUSTOM_REDIRECT_URL
),
hasCloudEntitlementWithLicenseGuard(
organizationId,
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CUSTOM_LINKS_IN_SURVEYS
),
]);
return canUseCustomRedirectUrl && canUseCustomLinksInSurveys;
}
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
return true;
};

View File

@@ -4,20 +4,22 @@ import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/billing/lib/organization-billing";
import { getOrganizationBilling, getSurvey } from "./survey";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findFirst: vi.fn(),
},
survey: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/modules/billing/lib/organization-billing", () => ({
getOrganizationBillingWithReadThroughSync: vi.fn(),
}));
// Mock transformPrismaSurvey
vi.mock("@/modules/survey/lib/utils", () => ({
transformPrismaSurvey: vi.fn((survey) => survey),
@@ -36,34 +38,21 @@ describe("Survey Library Tests", () => {
subscriptionStatus: "active",
nextRenewalDate: new Date(),
} as unknown as Organization["billing"];
vi.mocked(prisma.organization.findFirst).mockResolvedValueOnce({ billing: mockBilling } as any);
vi.mocked(getOrganizationBillingWithReadThroughSync).mockResolvedValueOnce(mockBilling);
const billing = await getOrganizationBilling("org_123");
expect(billing).toEqual(mockBilling);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({
where: { id: "org_123" },
select: { billing: true },
});
expect(getOrganizationBillingWithReadThroughSync).toHaveBeenCalledWith("org_123");
});
test("should throw ResourceNotFoundError when organization not found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValueOnce(null);
vi.mocked(getOrganizationBillingWithReadThroughSync).mockResolvedValueOnce(null);
await expect(getOrganizationBilling("org_nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma client known request error", async () => {
const mockErrorMessage = "Prisma error";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.organization.findFirst).mockRejectedValue(errToThrow);
await expect(getOrganizationBilling("org_dberror")).rejects.toThrow(DatabaseError);
});
test("should throw other errors", async () => {
const genericError = new Error("Generic error");
vi.mocked(prisma.organization.findFirst).mockRejectedValueOnce(genericError);
vi.mocked(getOrganizationBillingWithReadThroughSync).mockRejectedValueOnce(genericError);
await expect(getOrganizationBilling("org_error")).rejects.toThrow(genericError);
});
});

View File

@@ -3,6 +3,7 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/billing/lib/organization-billing";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
export const selectSurvey = {
@@ -97,29 +98,10 @@ export const selectSurvey = {
} satisfies Prisma.SurveySelect;
export const getOrganizationBilling = reactCache(
async (organizationId: string): Promise<Organization["billing"] | null> => {
try {
const organization = await prisma.organization.findFirst({
where: {
id: organizationId,
},
select: {
billing: true,
},
});
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
return organization.billing;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
async (organizationId: string): Promise<Organization["billing"]> => {
const billing = await getOrganizationBillingWithReadThroughSync(organizationId);
if (!billing) throw new ResourceNotFoundError("Organization", organizationId);
return billing as Organization["billing"];
}
);

View File

@@ -1,11 +1,10 @@
import { Prisma } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/billing/lib/organization-billing";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
import {
getExistingContactResponse,
@@ -17,22 +16,8 @@ import {
} from "./data";
// Mock dependencies
vi.mock("@formbricks/cache", () => ({
createCacheKey: {
organization: {
billing: vi.fn(),
},
custom: vi.fn(),
},
}));
// Helper to create branded CacheKey for tests
const mockCacheKey = (key: string) => key as any;
vi.mock("@/lib/cache", () => ({
cache: {
withCache: vi.fn(),
},
vi.mock("@/modules/billing/lib/organization-billing", () => ({
getOrganizationBillingWithReadThroughSync: vi.fn(),
}));
vi.mock("@/modules/survey/lib/utils", () => ({
@@ -47,10 +32,6 @@ vi.mock("@formbricks/database", () => ({
response: {
findFirst: vi.fn(),
},
organization: {
findFirst: vi.fn(),
findUnique: vi.fn(),
},
},
}));
@@ -447,73 +428,43 @@ describe("data", () => {
periodStart: new Date(),
};
const mockOrganization = {
id: "org-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Organization",
billing: mockBilling,
whitelabel: null,
isAIEnabled: true,
};
test("should fetch organization billing successfully", async () => {
const organizationId = "org-1";
vi.mocked(createCacheKey.organization.billing).mockReturnValue(mockCacheKey("billing-cache-key"));
vi.mocked(cache.withCache).mockResolvedValue(mockBilling);
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as any);
vi.mocked(getOrganizationBillingWithReadThroughSync).mockResolvedValue(mockBilling as any);
const result = await getOrganizationBilling(organizationId);
expect(result).toEqual(mockBilling);
expect(createCacheKey.organization.billing).toHaveBeenCalledWith(organizationId);
expect(cache.withCache).toHaveBeenCalledWith(
expect.any(Function),
"billing-cache-key",
60 * 60 * 24 * 1000
);
expect(getOrganizationBillingWithReadThroughSync).toHaveBeenCalledWith(organizationId);
});
test("should throw ResourceNotFoundError when organization not found", async () => {
const organizationId = "nonexistent-org";
vi.mocked(createCacheKey.organization.billing).mockReturnValue(mockCacheKey("billing-cache-key"));
vi.mocked(cache.withCache).mockImplementation(async (fn) => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
return await fn();
});
vi.mocked(getOrganizationBillingWithReadThroughSync).mockResolvedValue(null);
await expect(getOrganizationBilling(organizationId)).rejects.toThrow(ResourceNotFoundError);
await expect(getOrganizationBilling(organizationId)).rejects.toThrow("Organization");
});
test("should throw DatabaseError on Prisma error", async () => {
const organizationId = "org-1";
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(createCacheKey.organization.billing).mockReturnValue(mockCacheKey("billing-cache-key"));
vi.mocked(cache.withCache).mockImplementation(async (fn) => {
vi.mocked(prisma.organization.findUnique).mockRejectedValue(prismaError);
return await fn();
});
await expect(getOrganizationBilling(organizationId)).rejects.toThrow(DatabaseError);
});
test("should rethrow non-Prisma errors", async () => {
const organizationId = "org-1";
const genericError = new Error("Generic error");
vi.mocked(createCacheKey.organization.billing).mockReturnValue(mockCacheKey("billing-cache-key"));
vi.mocked(cache.withCache).mockImplementation(async (fn) => {
vi.mocked(prisma.organization.findUnique).mockRejectedValue(genericError);
return await fn();
});
vi.mocked(getOrganizationBillingWithReadThroughSync).mockRejectedValue(genericError);
await expect(getOrganizationBilling(organizationId)).rejects.toThrow(genericError);
});
test("should rethrow known database errors from sync service", async () => {
const organizationId = "org-1";
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(getOrganizationBillingWithReadThroughSync).mockRejectedValue(prismaError);
await expect(getOrganizationBilling(organizationId)).rejects.toThrow(prismaError);
});
});
});

View File

@@ -1,11 +1,10 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/billing/lib/organization-billing";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -236,29 +235,10 @@ export const getExistingContactResponse = reactCache((surveyId: string, contactI
* Get organization billing information for survey limits
* Cached separately with longer TTL
*/
export const getOrganizationBilling = reactCache(
async (organizationId: string) =>
await cache.withCache(
async () => {
try {
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { billing: true },
});
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
return organization.billing;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
createCacheKey.organization.billing(organizationId),
60 * 60 * 24 * 1000 // 24 hours in milliseconds - billing info changes rarely
)
);
export const getOrganizationBilling = reactCache(async (organizationId: string) => {
const billing = await getOrganizationBillingWithReadThroughSync(organizationId);
if (!billing) {
throw new ResourceNotFoundError("Organization", organizationId);
}
return billing;
});

View File

@@ -10,6 +10,7 @@ export const ZOrganizationWhiteLabel = z.object({
export const ZOrganizationBilling = z.object({
stripeCustomerId: z.string().nullable(),
billingMode: z.enum(["stripe", "legacy"]).optional().default("stripe"),
plan: z.enum(["free", "startup", "scale", "enterprise"]).default("free"),
period: z.enum(["monthly", "yearly"]).default("monthly"),
limits: z
@@ -28,6 +29,17 @@ export const ZOrganizationBilling = z.object({
},
}),
periodStart: z.coerce.date().nullable(),
stripe: z
.object({
billingMode: z.enum(["stripe", "legacy"]).optional(),
plan: z.enum(["hobby", "pro", "scale", "trial", "unknown"]).optional(),
subscriptionId: z.string().nullable().optional(),
features: z.array(z.string()).optional(),
lastStripeEventCreatedAt: z.string().nullable().optional(),
lastSyncedAt: z.string().nullable().optional(),
lastSyncedEventId: z.string().nullable().optional(),
})
.optional(),
});
export const ZOrganization = z.object({

View File

@@ -7,6 +7,21 @@ export type TOrganizationBillingPlan = z.infer<typeof ZOrganizationBillingPlan>;
export const ZOrganizationBillingPeriod = z.enum(["monthly", "yearly"]);
export type TOrganizationBillingPeriod = z.infer<typeof ZOrganizationBillingPeriod>;
export const ZCloudBillingPlan = z.enum(["hobby", "pro", "scale", "trial", "unknown"]);
export type TCloudBillingPlan = z.infer<typeof ZCloudBillingPlan>;
export const ZOrganizationBillingMode = z.enum(["stripe", "legacy"]);
export type TOrganizationBillingMode = z.infer<typeof ZOrganizationBillingMode>;
export const ZOrganizationStripeBilling = z.object({
plan: ZCloudBillingPlan.optional(),
subscriptionId: z.string().nullable().optional(),
features: z.array(z.string()).optional(),
lastStripeEventCreatedAt: z.string().nullable().optional(),
lastSyncedAt: z.string().nullable().optional(),
lastSyncedEventId: z.string().nullable().optional(),
});
// responses and miu can be null to support the unlimited plan
export const ZOrganizationBillingPlanLimits = z.object({
projects: z.number().nullable(),
@@ -20,6 +35,7 @@ export type TOrganizationBillingPlanLimits = z.infer<typeof ZOrganizationBilling
export const ZOrganizationBilling = z.object({
stripeCustomerId: z.string().nullable(),
billingMode: ZOrganizationBillingMode.optional().default("stripe"),
plan: ZOrganizationBillingPlan.default("free"),
period: ZOrganizationBillingPeriod.default("monthly"),
limits: ZOrganizationBillingPlanLimits.default({
@@ -30,6 +46,7 @@ export const ZOrganizationBilling = z.object({
},
}),
periodStart: z.date(),
stripe: ZOrganizationStripeBilling.optional(),
});
export type TOrganizationBilling = z.infer<typeof ZOrganizationBilling>;

View File

@@ -239,6 +239,10 @@
"STRAPI_API_KEY",
"STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET",
"CLOUD_STRIPE_PRODUCT_ID_HOBBY",
"CLOUD_STRIPE_PRODUCT_ID_PRO",
"CLOUD_STRIPE_PRODUCT_ID_SCALE",
"CLOUD_STRIPE_PRODUCT_ID_TRIAL",
"SURVEYS_PACKAGE_MODE",
"SURVEYS_PACKAGE_BUILD",
"PUBLIC_URL",