Compare commits

..

39 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
Dhruwang Jariwala
f4ac9a8292 fix: always validate only responseData fields in client/management APIs (#7292) (#7296)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 08:56:42 +00:00
Anshuman Pandey
7c8a7606b7 fix: fixes the no segment in draft surveys bug (#7290) 2026-02-19 08:16:18 +00:00
Anshuman Pandey
225217330b fix: adds dataType filter in bc code (#7294) 2026-02-19 07:47:58 +00:00
Dhruwang Jariwala
589c04a530 fix: allow CTA elements to proceed when marked required (#1415) (#7293)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 06:56:03 +00:00
Anshuman Pandey
aa538a3a51 fix: better query in the backwards compatible code (#7288) 2026-02-18 13:00:19 +00:00
Anshuman Pandey
817e108ff5 docs: adds migration docs (#7281)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-02-17 17:01:46 +01:00
Theodór Tómas
33542d0c54 fix: default preview colors (#7277)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-17 11:28:58 +00:00
Matti Nannt
f37d22f13d docs: align rate limiting docs with current code enforcement (#7267)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-17 07:42:53 +00:00
Anshuman Pandey
202ae903ac chore: makes rate limit config const (#7274) 2026-02-17 06:49:56 +00:00
Dhruwang Jariwala
6ab5cc367c fix: reduced default height of input (#7259) 2026-02-17 05:11:29 +00:00
Theodór Tómas
21559045ba fix: input placeholder color (#7265) 2026-02-17 05:11:01 +00:00
Theodór Tómas
d7c57a7a48 fix: disabling cache in dev (#7269) 2026-02-17 04:44:22 +00:00
Chowdhury Tafsir Ahmed Siddiki
11b2ef4788 docs: remove stale 'coming soon' placeholders (#7254) 2026-02-16 13:21:12 +00:00
Theodór Tómas
6fefd51cce fix: suggest colors has better succes copy (#7258) 2026-02-16 13:18:46 +00:00
Theodór Tómas
65af826222 fix: matrix table preview (#7257)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-16 13:18:17 +00:00
Anshuman Pandey
12eb54c653 fix: fixes number being passed into string attribute (#7255) 2026-02-16 11:18:59 +00:00
Dhruwang Jariwala
5aa1427e64 fix: input combobx height (#7256) 2026-02-16 10:03:23 +00:00
69 changed files with 2856 additions and 533 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

@@ -21,7 +21,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -257,16 +256,10 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.map((choice) => choice.imageUrl)
.join("\n");
}
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
return responseValue
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
.join("; ");
}
return processResponseData(responseValue);
};
@@ -375,7 +368,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
.map((choice) => choice.imageUrl);
}
});

View File

@@ -18,7 +18,6 @@ import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
@@ -96,15 +95,12 @@ export const POST = async (request: Request) => {
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,

View File

@@ -10,7 +10,6 @@ import {
TJsEnvironmentStateSurvey,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -178,14 +177,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
overlay: environmentData.project.overlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
styling: environmentData.project.styling,
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
surveys: transformedSurveys,
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,10 +57,7 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
response: responses.successResponse(result.response),
};
} catch (error) {
return {
@@ -192,7 +189,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
response: responses.successResponse(updated),
};
} catch (error) {
return {

View File

@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,9 +54,7 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
response: responses.successResponse(allResponses),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -16,7 +16,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
@@ -59,18 +58,16 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
response: responses.successResponse(result.survey),
};
} catch (error) {
return {
@@ -205,12 +202,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
response: responses.successResponse(surveyWithQuestions),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
return {

View File

@@ -14,7 +14,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
@@ -56,7 +55,7 @@ export const GET = withV1ApiWrapper({
});
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
response: responses.successResponse(surveysWithQuestions),
};
} catch (error) {
if (error instanceof DatabaseError) {

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

@@ -22,7 +22,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -409,10 +408,9 @@ export const getResponseDownloadFile = async (
if (survey.isVerifyEmailEnabled) {
headers.push("Verified Email");
}
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
const jsonData = getResponsesJson(
survey,
resolvedResponses,
responses,
elements,
userAttributes,
hiddenFields,

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

@@ -15,7 +15,7 @@ import {
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -51,10 +51,7 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, response.error as ApiErrorResponseV2);
}
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
return responses.successResponse(response);
},
});
@@ -246,10 +243,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
auditLog.newObject = response.data;
}
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
return responses.successResponse(response);
},
action: "updated",
targetType: "response",

View File

@@ -12,7 +12,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
export const GET = async (request: NextRequest) =>
@@ -44,9 +44,7 @@ export const GET = async (request: NextRequest) =>
environmentResponses.push(...res.data.data);
return responses.successResponse({
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
});
return responses.successResponse({ data: environmentResponses });
},
});

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

@@ -30,4 +30,4 @@ export const rateLimitConfigs = {
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
},
};
} as const;

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

@@ -54,6 +54,7 @@ export const prepareNewSDKAttributeForStorage = (
};
const handleStringType = (value: TRawValue): TAttributeStorageColumns => {
// String type - only use value column
let stringValue: string;
if (value instanceof Date) {

View File

@@ -130,12 +130,7 @@ export const updateAttributes = async (
const messages: TAttributeUpdateMessage[] = [];
const errors: TAttributeUpdateMessage[] = [];
// Coerce boolean values to strings (SDK may send booleans for string attributes)
const coercedAttributes: Record<string, string | number> = {};
for (const [key, value] of Object.entries(contactAttributesParam)) {
coercedAttributes[key] = typeof value === "boolean" ? String(value) : value;
}
// Convert email and userId to strings for lookup (they should always be strings, but handle numbers gracefully)
const emailValue =
contactAttributesParam.email === null || contactAttributesParam.email === undefined
? null
@@ -159,7 +154,7 @@ export const updateAttributes = async (
const userIdExists = !!existingUserIdAttribute;
// Remove email and/or userId from attributes if they already exist on another contact
let contactAttributes = { ...coercedAttributes };
let contactAttributes = { ...contactAttributesParam };
// Determine what the final email and userId values will be after this update
// Only consider a value as "submitted" if it was explicitly included in the attributes

View File

@@ -1229,6 +1229,104 @@ describe("segmentFilterToPrismaQuery", () => {
}
});
test("number filter falls back to raw SQL when un-migrated rows exist", async () => {
mockFindFirst.mockResolvedValue({ id: "unmigrated-row-1" });
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
const filters: TBaseFilters = [
{
id: "filter_1",
connector: null,
resource: {
id: "attr_1",
root: {
type: "attribute" as const,
contactAttributeKey: "age",
},
value: 25,
qualifier: {
operator: "greaterThan",
},
},
},
];
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
expect(result.ok).toBe(true);
if (result.ok) {
const filterClause = result.data.whereClause.AND?.[1] as any;
expect(filterClause.AND[0]).toEqual({
OR: [
{
attributes: {
some: {
attributeKey: { key: "age" },
valueNumber: { gt: 25 },
},
},
},
{ id: { in: ["mock-contact-1"] } },
],
});
}
expect(mockFindFirst).toHaveBeenCalledWith({
where: {
attributeKey: {
key: "age",
environmentId: mockEnvironmentId,
dataType: "number",
},
valueNumber: null,
},
select: { id: true },
});
expect(mockQueryRawUnsafe).toHaveBeenCalled();
const sqlCall = mockQueryRawUnsafe.mock.calls[0];
expect(sqlCall[0]).toContain('cak."environmentId" = $4');
expect(sqlCall[4]).toBe(mockEnvironmentId);
});
test("number filter uses clean Prisma query when backfill is complete", async () => {
const filters: TBaseFilters = [
{
id: "filter_1",
connector: null,
resource: {
id: "attr_1",
root: {
type: "attribute" as const,
contactAttributeKey: "score",
},
value: 100,
qualifier: {
operator: "lessEqual",
},
},
},
];
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
expect(result.ok).toBe(true);
if (result.ok) {
const filterClause = result.data.whereClause.AND?.[1] as any;
expect(filterClause.AND[0]).toEqual({
attributes: {
some: {
attributeKey: { key: "score" },
valueNumber: { lte: 100 },
},
},
});
}
expect(mockFindFirst).toHaveBeenCalled();
expect(mockQueryRawUnsafe).not.toHaveBeenCalled();
});
// ==========================================
// DATE FILTER TESTS
// ==========================================

View File

@@ -37,6 +37,7 @@ vi.mock("@formbricks/database", () => ({
create: vi.fn(),
delete: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
findFirst: vi.fn(),
},
survey: {
@@ -206,6 +207,73 @@ describe("Segment Service Tests", () => {
vi.mocked(prisma.segment.create).mockRejectedValue(new Error("DB error"));
await expect(createSegment(mockSegmentCreateInput)).rejects.toThrow(Error);
});
test("should upsert a private segment without surveyId", async () => {
const privateInput: TSegmentCreateInput = {
...mockSegmentCreateInput,
isPrivate: true,
};
const privateSegmentPrisma = { ...mockSegmentPrisma, isPrivate: true };
vi.mocked(prisma.segment.upsert).mockResolvedValue(privateSegmentPrisma);
const segment = await createSegment(privateInput);
expect(segment).toEqual({ ...mockSegment, isPrivate: true });
expect(prisma.segment.upsert).toHaveBeenCalledWith({
where: {
environmentId_title: {
environmentId,
title: privateInput.title,
},
},
create: {
environmentId,
title: privateInput.title,
description: undefined,
isPrivate: true,
filters: [],
},
update: {
description: undefined,
filters: [],
},
select: selectSegment,
});
expect(prisma.segment.create).not.toHaveBeenCalled();
});
test("should upsert a private segment with surveyId", async () => {
const privateInputWithSurvey: TSegmentCreateInput = {
...mockSegmentCreateInput,
isPrivate: true,
surveyId,
};
const privateSegmentPrisma = { ...mockSegmentPrisma, isPrivate: true };
vi.mocked(prisma.segment.upsert).mockResolvedValue(privateSegmentPrisma);
const segment = await createSegment(privateInputWithSurvey);
expect(segment).toEqual({ ...mockSegment, isPrivate: true });
expect(prisma.segment.upsert).toHaveBeenCalledWith({
where: {
environmentId_title: {
environmentId,
title: privateInputWithSurvey.title,
},
},
create: {
environmentId,
title: privateInputWithSurvey.title,
description: undefined,
isPrivate: true,
filters: [],
surveys: { connect: { id: surveyId } },
},
update: {
description: undefined,
filters: [],
surveys: { connect: { id: surveyId } },
},
select: selectSegment,
});
expect(prisma.segment.create).not.toHaveBeenCalled();
});
});
describe("cloneSegment", () => {

View File

@@ -151,7 +151,7 @@ export const getErrorResponseFromStorageError = (
/**
* Resolves a storage URL to an absolute URL.
* - If already absolute, returns as-is
* - If already absolute, returns as-is (backward compatibility for old data)
* - If relative (/storage/...), prepends the appropriate base URL
* @param url The storage URL (relative or absolute)
* @param accessType The access type to determine which base URL to use (defaults to "public")
@@ -163,7 +163,7 @@ export const resolveStorageUrl = (
): string => {
if (!url) return "";
// Already absolute URL - return as-is
// Already absolute URL - return as-is (backward compatibility for old data)
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
@@ -176,41 +176,3 @@ export const resolveStorageUrl = (
return url;
};
// Matches the actual storage URL format: /storage/{id}/{public|private}/{filename...}
const STORAGE_URL_PATTERN = /^\/storage\/[^/]+\/(public|private)\/.+/;
const isStorageUrl = (value: string): boolean => STORAGE_URL_PATTERN.test(value);
export const resolveStorageUrlAuto = (url: string): string => {
if (!isStorageUrl(url)) return url;
const accessType = url.includes("/private/") ? "private" : "public";
return resolveStorageUrl(url, accessType);
};
/**
* Recursively walks an object/array and resolves all relative storage URLs
* Preserves the original structure; skips Date instances and non-object primitives.
*/
export const resolveStorageUrlsInObject = <T>(obj: T): T => {
if (obj === null || obj === undefined) return obj;
if (typeof obj === "string") {
return resolveStorageUrlAuto(obj) as T;
}
if (typeof obj !== "object") return obj;
if (obj instanceof Date) return obj;
if (Array.isArray(obj)) {
return obj.map((item) => resolveStorageUrlsInObject(item)) as T;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = resolveStorageUrlsInObject(value);
}
return result as T;
};

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

@@ -40,7 +40,10 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
isLoadingScript = true;
try {
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
const response = await fetch(scriptUrl);
const response = await fetch(
scriptUrl,
process.env.NODE_ENV === "development" ? { cache: "no-store" } : {}
);
if (!response.ok) {
throw new Error("Failed to load the surveys package");

View File

@@ -4,12 +4,182 @@ description: "Formbricks Self-hosted version migration"
icon: "arrow-right"
---
## v4.7
Formbricks v4.7 introduces **typed contact attributes** with native `number` and `date` data types. This enables comparison-based segment filters (e.g. "signup date before 2025-01-01") that were previously not possible with string-only attribute values.
### What Happens Automatically
When Formbricks v4.7 starts for the first time, the data migration will:
1. Analyze all existing contact attribute keys and infer their data types (`text`, `number`, or `date`) based on the stored values
2. Update the `ContactAttributeKey` table with the detected `dataType` for each key
3. **If your instance has fewer than 1,000,000 contact attribute rows**: backfill the new `valueNumber` and `valueDate` columns inline. No manual action is needed.
4. **If your instance has 1,000,000 or more contact attribute rows**: the value backfill is skipped to avoid hitting the migration timeout. You will need to run a standalone backfill script after the upgrade.
<Info>
Most self-hosted instances have far fewer than 1,000,000 contact attribute rows (a typical setup with 100K
contacts and 5-10 attributes each lands around 500K-1M rows). If you are below the threshold, the migration
handles everything automatically and you can skip the manual backfill step below.
</Info>
### Steps to Migrate
**1. Backup your Database**
<Tabs>
<Tab title="Docker">
Before running these steps, navigate to the `formbricks` directory where your `docker-compose.yml` file is located.
```bash
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
```
<Info>
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Info>
</Tab>
<Tab title="Kubernetes">
If you are using the **in-cluster PostgreSQL** deployed by the Helm chart:
```bash
kubectl exec -n formbricks formbricks-postgresql-0 -- pg_dump -Fc -U formbricks -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
```
<Info>
If your PostgreSQL pod has a different name, run `kubectl get pods -n formbricks` to find it.
</Info>
If you are using a **managed PostgreSQL** service (e.g. AWS RDS, Cloud SQL), use your provider's backup/snapshot feature or run `pg_dump` directly against the external host.
</Tab>
</Tabs>
**2. Upgrade to Formbricks v4.7**
<Tabs>
<Tab title="Docker">
```bash
# Pull the latest version
docker compose pull
# Stop the current instance
docker compose down
# Start with Formbricks v4.7
docker compose up -d
```
</Tab>
<Tab title="Kubernetes">
```bash
helm upgrade formbricks oci://ghcr.io/formbricks/helm-charts/formbricks \
-n formbricks \
--set deployment.image.tag=v4.7.0
```
<Info>
The Helm chart includes a migration Job that automatically runs Prisma schema migrations as a
PreSync hook before the new pods start. No manual migration step is needed.
</Info>
</Tab>
</Tabs>
**3. Check the Migration Logs**
After Formbricks starts, check the logs to see whether the value backfill was completed or skipped:
<Tabs>
<Tab title="Docker">
```bash
docker compose logs formbricks | grep -i "backfill"
```
</Tab>
<Tab title="Kubernetes">
```bash
# Check the application pod logs
kubectl logs -n formbricks -l app.kubernetes.io/name=formbricks --tail=200 | grep -i "backfill"
```
If the Helm migration Job ran, you can also inspect its logs:
```bash
kubectl logs -n formbricks job/formbricks-migration
```
</Tab>
</Tabs>
If you see a message like `Skipping value backfill (X rows >= 1000000 threshold)`, proceed to step 4. Otherwise, the migration is complete and no further action is needed.
**4. Run the Backfill Script (large datasets only)**
If the migration skipped the value backfill, run the standalone backfill script inside the running Formbricks container:
<Tabs>
<Tab title="Docker">
```bash
docker exec formbricks node packages/database/dist/scripts/backfill-attribute-values.js
```
<Info>Replace `formbricks` with your actual container name if it differs. Use `docker ps` to find it.</Info>
</Tab>
<Tab title="Kubernetes">
```bash
kubectl exec -n formbricks deploy/formbricks -- node packages/database/dist/scripts/backfill-attribute-values.js
```
<Info>
If your Formbricks deployment has a different name, run `kubectl get deploy -n formbricks` to find it.
</Info>
</Tab>
</Tabs>
The script will output progress as it runs:
```
========================================
Attribute Value Backfill Script
========================================
Fetching number-type attribute keys...
Found 12 number-type keys. Backfilling valueNumber...
Number backfill progress: 10/12 keys (48230 rows updated)
Number backfill progress: 12/12 keys (52104 rows updated)
Fetching date-type attribute keys...
Found 5 date-type keys. Backfilling valueDate...
Date backfill progress: 5/5 keys (31200 rows updated)
========================================
Backfill Complete!
========================================
valueNumber rows updated: 52104
valueDate rows updated: 31200
Duration: 42.3s
========================================
```
Key characteristics of the backfill script:
- **Safe to run while Formbricks is live** -- it does not lock the entire table or wrap work in a long transaction
- **Idempotent** -- it only updates rows where the typed columns are still `NULL`, so you can safely run it multiple times
- **Resumable** -- each batch commits independently, so if the process is interrupted you can re-run it and it picks up where it left off
- **No timeout risk** -- unlike the migration, this script runs outside the migration transaction and has no time limit
**5. Verify the Upgrade**
- Access your Formbricks instance at the same URL as before
- If you use contact segments with number or date filters, verify they return the expected results
- Check that existing surveys and response data are intact
---
## v4.0
<Warning>
**Important: Migration Required**
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
</Warning>
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
@@ -17,9 +187,11 @@ Formbricks 4.0 is a **major milestone** that sets up the technical foundation fo
### What's New in Formbricks 4.0
**🚀 New Enterprise Features:**
- **Quotas Management**: Advanced quota controls for enterprise users
**🏗️ Technical Foundation Improvements:**
- **Enhanced File Storage**: Improved file handling with better performance and reliability
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
- **Database Optimization**: Removal of unused database tables and fields for better performance
@@ -39,7 +211,8 @@ These services are already included in the updated one-click setup for self-host
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
- **Advanced features** that require sophisticated caching and file processing
- **Better performance** through optimized, dedicated services
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
@@ -52,7 +225,7 @@ Additional migration steps are needed if you are using a self-hosted Formbricks
### One-Click Setup
For users using our official one-click setup, we provide an automated migration using a migration script:
For users using our official one-click setup, we provide an automated migration using a migration script:
```bash
# Download the latest script
@@ -67,11 +240,11 @@ chmod +x migrate-to-v4.sh
```
This script guides you through the steps for the infrastructure migration and does the following:
- Adds a Redis service to your setup and configures it
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
- Pulls the latest Formbricks image and updates your instance
### Manual Setup
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
@@ -87,6 +260,7 @@ You need to configure the `REDIS_URL` environment variable and point it to your
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
Formbricks supports multiple storage providers (among many other S3-compatible storages):
- AWS S3
- Digital Ocean Spaces
- Hetzner Object Storage
@@ -101,6 +275,7 @@ Please make sure to set up a storage bucket with one of these solutions and then
S3_BUCKET_NAME: formbricks-uploads
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
```
#### Upgrade Process
**1. Backup your Database**
@@ -112,8 +287,8 @@ docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbr
```
<Info>
If you run into "**No such container**", use `docker ps` to find your container name,
e.g. `formbricks_postgres_1`.
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Info>
**2. Upgrade to Formbricks 4.0**
@@ -134,6 +309,7 @@ docker compose up -d
**3. Automatic Database Migration**
When you start Formbricks 4.0 for the first time, it will **automatically**:
- Detect and apply required database schema updates
- Remove unused database tables and fields
- Optimize the database structure for better performance

View File

@@ -1,41 +1,94 @@
---
title: "Rate Limiting"
description: "Rate limiting for Formbricks"
description: "Current request rate limits in Formbricks"
icon: "timer"
---
To protect the platform from abuse and ensure fair usage, rate limiting is enforced by default on an IP-address basis. If a client exceeds the allowed number of requests within the specified time window, the API will return a `429 Too Many Requests` status code.
Formbricks applies request rate limits to protect against abuse and keep API usage fair.
## Default Rate Limits
Rate limits are scoped by identifier, depending on the endpoint:
The following rate limits apply to various endpoints:
- IP hash (for unauthenticated/client-side routes and public actions)
- API key ID (for authenticated API calls)
- User ID (for authenticated session-based calls and server actions)
- Organization ID (for follow-up email dispatch)
| **Endpoint** | **Rate Limit** | **Time Window** |
| ----------------------- | -------------- | --------------- |
| `POST /login` | 30 requests | 15 minutes |
| `POST /signup` | 30 requests | 60 minutes |
| `POST /verify-email` | 10 requests | 60 minutes |
| `POST /forgot-password` | 5 requests | 60 minutes |
| `GET /client-side-api` | 100 requests | 1 minute |
| `POST /share` | 100 requests | 60 minutes |
When a limit is exceeded, the API returns `429 Too Many Requests`.
If a request exceeds the defined rate limit, the server will respond with:
## Management API Rate Limits
These are the current limits for Management APIs:
| **Route Group** | **Limit** | **Window** | **Identifier** |
| --- | --- | --- | --- |
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
## All Enforced Limits
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
| --- | --- | --- | --- | --- |
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
## Current Endpoint Exceptions
The following routes are currently not rate-limited by the server-side limiter:
- `GET /api/v1/client/og` (explicitly excluded)
- `POST /api/v2/client/[environmentId]/responses`
- `POST /api/v2/client/[environmentId]/displays`
- `GET /api/v2/health`
## 429 Response Shape
v1-style endpoints return:
```json
{
"code": 429,
"error": "Too many requests, Please try after a while!"
"code": "too_many_requests",
"message": "Maximum number of requests reached. Please try again later.",
"details": {}
}
```
v2-style endpoints return:
```json
{
"error": {
"code": 429,
"message": "Too Many Requests"
}
}
```
## Disabling Rate Limiting
For self-hosters, rate limiting can be disabled if necessary. However, we **strongly recommend keeping rate limiting enabled in production environments** to prevent abuse.
For self-hosters, rate limiting can be disabled if necessary. We strongly recommend keeping it enabled in production.
To disable rate limiting, set the following environment variable:
Set:
```bash
RATE_LIMITING_DISABLED=1
```
After making this change, restart your server to apply the new setting.
After changing this value, restart the server.
## Operational Notes
- Redis/Valkey is required for robust rate limiting (`REDIS_URL`).
- If Redis is unavailable at runtime, rate-limiter checks currently fail open (requests are allowed through without enforcement).
- Authentication failure audit logging uses a separate throttle (`shouldLogAuthFailure()`) and is intentionally **fail-closed**: when Redis is unavailable or errors occur, audit log entries are **skipped entirely** rather than written without throttle control. This prevents spam while preserving the hash-integrity chain required for compliance. In other words, if Redis is down, no authentication-failure audit logs will be recorded—requests themselves are still allowed (fail-open rate limiting above), but the audit trail for those failures will not be written.

View File

@@ -16,8 +16,6 @@ The Churn Survey is among the most effective ways to identify weaknesses in your
* Follow-up to prevent bad reviews
* Coming soon: Make survey mandatory
## Overview
To run the Churn Survey in your app you want to proceed as follows:
@@ -80,13 +78,6 @@ Whenever a user visits this page, matches the filter conditions above and the re
Here is our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions/) covering [No-Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions) and [Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions) Actions.
<Note>
Pre-churn flow coming soon Were currently building full-screen survey
pop-ups. Youll be able to prevent users from closing the survey unless they
respond to it. Its certainly debatable if you want that but you could force
them to click through the survey before letting them cancel 🤷
</Note>
### 5. Select Action in the “When to ask” card
![Select feedback button action](/images/xm-and-surveys/xm/best-practices/cancel-subscription/select-action.webp)

View File

@@ -46,13 +46,7 @@ _Want to change the button color? Adjust it in the project settings!_
Save, and move over to the **Audience** tab.
### 3. Pre-segment your audience (coming soon)
<Note>
### Filter by Attribute Coming Soon
We're working on pre-segmenting users by attributes. This manual will be updated in the coming days.
</Note>
### 3. Pre-segment your audience
Pre-segmentation isn't needed for this survey since you likely want to target all users who cancel their trial. You can use a specific user action, like clicking **Cancel Trial**, to show the survey only to users trying your product.
@@ -62,13 +56,13 @@ How you trigger your survey depends on your product. There are two options:
- **Trigger by Page view:** If you have a page like `/trial-cancelled` for users who cancel their trial subscription, create a user action with the type "Page View." Select "Limit to specific pages" and apply URL filters with these settings:
![Change text content](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-pageurl.webp)
![Add page URL action](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-pageurl.webp)
Whenever a user visits this page, the survey will be displayed ✅
- **Trigger by Button Click:** In a different case, you have a “Cancel Trial" button in your app. You can setup a user Action with the `Inner Text`:
![Change text content](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-innertext.webp)
![Add inner text action](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-innertext.webp)
Please have a look at our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions) if you have questions.

View File

@@ -54,13 +54,7 @@ In the button settings you have to make sure it is set to “External URL”. In
Save, and move over to the “Audience” tab.
### 3. Pre-segment your audience (coming soon)
<Note>
## Filter by attribute coming soon. We're working on pre-segmenting users by
attributes. We will update this manual in the next few days.
</Note>
### 3. Pre-segment your audience
Once you clicked over to the “Audience” tab you can change the settings. In the **Who To Send** card, select “Filter audience by attribute”. This allows you to only show the prompt to a specific segment of your user base.

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

@@ -16,5 +16,5 @@ export type TContactAttribute = z.infer<typeof ZContactAttribute>;
export const ZContactAttributes = z.record(z.string());
export type TContactAttributes = z.infer<typeof ZContactAttributes>;
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number(), z.boolean()]));
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number()]));
export type TContactAttributesInput = z.infer<typeof ZContactAttributesInput>;

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",