mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 00:58:29 -06:00
Compare commits
39 Commits
4.7.3-rc.2
...
codex/clou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14155c7d9e | ||
|
|
abd844c70c | ||
|
|
c8bc04fd6b | ||
|
|
b4dd373278 | ||
|
|
6eac4f72b6 | ||
|
|
999a26ed40 | ||
|
|
aa56db0da2 | ||
|
|
c4d3e2d132 | ||
|
|
79754606eb | ||
|
|
d1b2878ba6 | ||
|
|
b2f5345662 | ||
|
|
08bb09a3ea | ||
|
|
efd80e7dbe | ||
|
|
dfaf6dcf87 | ||
|
|
6ecd63817e | ||
|
|
c5fc169fb6 | ||
|
|
a104736df2 | ||
|
|
e5960cd714 | ||
|
|
a413c5a2f7 | ||
|
|
596ab2f11e | ||
|
|
2f1f723083 | ||
|
|
9153de395c | ||
|
|
f4ac9a8292 | ||
|
|
7c8a7606b7 | ||
|
|
225217330b | ||
|
|
589c04a530 | ||
|
|
aa538a3a51 | ||
|
|
817e108ff5 | ||
|
|
33542d0c54 | ||
|
|
f37d22f13d | ||
|
|
202ae903ac | ||
|
|
6ab5cc367c | ||
|
|
21559045ba | ||
|
|
d7c57a7a48 | ||
|
|
11b2ef4788 | ||
|
|
6fefd51cce | ||
|
|
65af826222 | ||
|
|
12eb54c653 | ||
|
|
5aa1427e64 |
426
CLOUD_BILLING_REVAMP_PLAN.md
Normal file
426
CLOUD_BILLING_REVAMP_PLAN.md
Normal 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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "カスタムファビコンをアップロードして、リンク調査の体験をパーソナライズし、ブランドプレゼンスを強化します。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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, чтобы персонализировать опросы по ссылке и усилить узнаваемость бренда.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,个性化您的链接问卷体验,提升品牌形象。",
|
||||
|
||||
@@ -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,讓您的連結問卷體驗更具個人化,並強化品牌形象。",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
89
apps/web/modules/billing/lib/feature-access.test.ts
Normal file
89
apps/web/modules/billing/lib/feature-access.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
28
apps/web/modules/billing/lib/feature-access.ts
Normal file
28
apps/web/modules/billing/lib/feature-access.ts
Normal 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);
|
||||
};
|
||||
506
apps/web/modules/billing/lib/organization-billing.test.ts
Normal file
506
apps/web/modules/billing/lib/organization-billing.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
413
apps/web/modules/billing/lib/organization-billing.ts
Normal file
413
apps/web/modules/billing/lib/organization-billing.ts
Normal 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);
|
||||
};
|
||||
38
apps/web/modules/billing/lib/stripe-catalog.test.ts
Normal file
38
apps/web/modules/billing/lib/stripe-catalog.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
86
apps/web/modules/billing/lib/stripe-catalog.ts
Normal file
86
apps/web/modules/billing/lib/stripe-catalog.ts
Normal 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 };
|
||||
};
|
||||
41
apps/web/modules/billing/lib/stripe-client.test.ts
Normal file
41
apps/web/modules/billing/lib/stripe-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
10
apps/web/modules/billing/lib/stripe-client.ts
Normal file
10
apps/web/modules/billing/lib/stripe-client.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
};
|
||||
|
||||
173
apps/web/modules/ee/billing/api/lib/create-subscription.test.ts
Normal file
173
apps/web/modules/ee/billing/api/lib/create-subscription.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ==========================================
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) : [];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 We’re currently building full-screen survey
|
||||
pop-ups. You’ll be able to prevent users from closing the survey unless they
|
||||
respond to it. It’s 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
|
||||
|
||||

|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||

|
||||
|
||||
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`:
|
||||
|
||||

|
||||

|
||||
|
||||
Please have a look at our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions) if you have questions.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user