diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 84e3102eec2d..69d96dc1b4db 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,22 +1,34 @@
-
+
-## What does this PR do?
+
-
+## What & why
-Fixes #(issue)
+
-
+## Linear ticket
+
+
+
+## How this was tested
+
+
+
+-
+
+
## QA / Test Plan
**How to test**
@@ -35,25 +47,3 @@ limits, timeouts) for human verification.
**Migrations / env / cutover**
- none
-
-## Checklist
-
-
-
-### Required
-
-- [ ] Filled out the "QA / Test Plan" section in this PR (behavior, edge cases, preconditions, risks, migrations/env)
-- [ ] Read [How we Code at Formbricks](<[https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md](https://formbricks.com/docs/contributing/how-we-code)>)
-- [ ] Self-reviewed my own code
-- [ ] Commented on my code in hard-to-understand bits
-- [ ] Ran `pnpm build`
-- [ ] Checked for warnings, there are none
-- [ ] Removed all `console.logs`
-- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
-- [ ] My changes don't cause any responsiveness issues
-- [ ] First PR at Formbricks? [Please sign the CLA!](https://cla-assistant.io/formbricks/formbricks) Without it we wont be able to merge it 🙏
-
-### Appreciated
-
-- [ ] If a UI change was made: Added a screen recording or screenshots to this PR
-- [ ] Updated the Formbricks Docs if changes were necessary
diff --git a/AGENTS.md b/AGENTS.md
index 3c2da0170ac9..d13294fb6bc6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -133,7 +133,7 @@ Heuristic:
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
-Every PR must use `.github/pull_request_template.md` — including its `## QA / Test Plan` section, which is the source of truth for release QA (follow the guidance inline in the template; keep its subsection headings). Agents fill it on PR open and re-update it in the same turn on every PR change (new commits, scope or review fixes) so it never drifts from the diff — treat a stale QA section as a bug.
+Every PR must use `.github/pull_request_template.md` and follow its inline guidance — the template is the source of truth for PR structure, including the `## QA / Test Plan` section used to generate release QA. Fill every section from the actual diff on PR open, and re-update it in the same turn on every change (new commits, scope or review fixes) so it never drifts — treat a stale section as a bug.
## Next.js Documentation
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index a6baf63d3325..e1b97a726c7c 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -2374,6 +2374,7 @@ checksums:
workspace/settings/billing/payment_error_contact_support: 3145080022ff17d06e41afe78363a6cc
workspace/settings/billing/payment_error_failed_invoice: 5344b45e2acaf0287b69b043d37a964d
workspace/settings/billing/payment_error_requires_action: 934ac70b26f53819314add46305b6d87
+ workspace/settings/billing/payment_method_required: 34c8095c7fe1f9c64aadf2206abf9c80
workspace/settings/billing/pending_change_removed: c80cc7f1f83f28db186e897fb18282a3
workspace/settings/billing/pending_plan_badge: 1283929a2810dcf6110765f387dc118e
workspace/settings/billing/pending_plan_change_description: 6923bada769d33cadcad557521362c1f
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index fbfc3e4efba9..41dac2bece10 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Bitte kontaktiere hola@formbricks.com für Unterstützung.",
"payment_error_failed_invoice": "Die Zahlung konnte nicht abgeschlossen werden.",
"payment_error_requires_action": "Die Zahlung erfordert eine zusätzliche Authentifizierung.",
+ "payment_method_required": "Füge eine Zahlungsmethode hinzu, um zu einem kostenpflichtigen Tarif zu wechseln.",
"pending_change_removed": "Geplante Planänderung entfernt.",
"pending_plan_badge": "Geplant",
"pending_plan_change_description": "Dein Plan wechselt am {date} zu {plan}.",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index 2e63a1821483..c502ae8b464a 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Please contact hola@formbricks.com for support.",
"payment_error_failed_invoice": "Payment could not be completed.",
"payment_error_requires_action": "Payment requires additional authentication.",
+ "payment_method_required": "Add a payment method to switch to a paid plan.",
"pending_change_removed": "Scheduled plan change removed.",
"pending_plan_badge": "Scheduled",
"pending_plan_change_description": "Your plan will switch to {plan} on {date}.",
diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json
index 2ea5c6e379c0..b15e0b47bfe5 100644
--- a/apps/web/locales/es-ES.json
+++ b/apps/web/locales/es-ES.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Por favor, contacta con hola@formbricks.com para recibir asistencia.",
"payment_error_failed_invoice": "No se pudo completar el pago.",
"payment_error_requires_action": "El pago requiere autenticación adicional.",
+ "payment_method_required": "Añade un método de pago para cambiar a un plan de pago.",
"pending_change_removed": "Cambio de plan programado eliminado.",
"pending_plan_badge": "Programado",
"pending_plan_change_description": "Tu plan cambiará a {plan} el {date}.",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index c2025343779e..b1000c17a173 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Merci de contacter hola@formbricks.com pour obtenir de l'aide.",
"payment_error_failed_invoice": "Le paiement n'a pas pu être effectué.",
"payment_error_requires_action": "Le paiement nécessite une authentification supplémentaire.",
+ "payment_method_required": "Ajoute un moyen de paiement pour passer à un forfait payant.",
"pending_change_removed": "Changement de formule programmé supprimé.",
"pending_plan_badge": "Programmé",
"pending_plan_change_description": "Votre forfait passera à {plan} le {date}.",
diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json
index 08d7983d7b6a..37155e9a7374 100644
--- a/apps/web/locales/hu-HU.json
+++ b/apps/web/locales/hu-HU.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Kérem, vegye fel a kapcsolatot a következő címmel támogatásért: hola@formbricks.com.",
"payment_error_failed_invoice": "A fizetés nem volt teljesíthető.",
"payment_error_requires_action": "A fizetés további hitelesítést igényel.",
+ "payment_method_required": "Adjon hozzá egy fizetési módot a fizetős csomagra való váltáshoz.",
"pending_change_removed": "Az ütemezett csomagváltoztatás eltávolítva.",
"pending_plan_badge": "Ütemezett",
"pending_plan_change_description": "A csomagja {plan} csomagra fog váltani ekkor: {date}.",
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index 1f1acd204fcc..bc76d33bbc99 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "サポートについてはhola@formbricks.comまでお問い合わせください。",
"payment_error_failed_invoice": "支払いを完了できませんでした。",
"payment_error_requires_action": "支払いには追加の認証が必要です。",
+ "payment_method_required": "有料プランに切り替えるには、お支払い方法を追加してください。",
"pending_change_removed": "予定されていたプラン変更を取り消しました。",
"pending_plan_badge": "変更予定",
"pending_plan_change_description": "プランは {date} に {plan} に切り替わります。",
diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json
index ba1d60df62a8..f3028dc6935a 100644
--- a/apps/web/locales/nl-NL.json
+++ b/apps/web/locales/nl-NL.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Neem contact op met hola@formbricks.com voor ondersteuning.",
"payment_error_failed_invoice": "De betaling kon niet worden voltooid.",
"payment_error_requires_action": "De betaling vereist aanvullende verificatie.",
+ "payment_method_required": "Voeg een betaalmethode toe om over te schakelen naar een betaald abonnement.",
"pending_change_removed": "Geplande abonnementswijziging verwijderd.",
"pending_plan_badge": "Gepland",
"pending_plan_change_description": "Je abonnement wordt op {date} omgezet naar {plan}.",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index 5db42e5f30c7..49ca3a225c21 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Entre em contato com hola@formbricks.com para obter suporte.",
"payment_error_failed_invoice": "Não foi possível concluir o pagamento.",
"payment_error_requires_action": "O pagamento requer autenticação adicional.",
+ "payment_method_required": "Adicione um método de pagamento para mudar para um plano pago.",
"pending_change_removed": "Mudança de plano agendada removida.",
"pending_plan_badge": "Agendado",
"pending_plan_change_description": "Seu plano mudará para {plan} em {date}.",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index 0571db1dfb23..849e14d76743 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Por favor, contacta hola@formbricks.com para obter suporte.",
"payment_error_failed_invoice": "O pagamento não pôde ser concluído.",
"payment_error_requires_action": "O pagamento requer autenticação adicional.",
+ "payment_method_required": "Adiciona um método de pagamento para mudares para um plano pago.",
"pending_change_removed": "Alteração de plano agendada removida.",
"pending_plan_badge": "Agendado",
"pending_plan_change_description": "O teu plano mudará para {plan} em {date}.",
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index 652adc845792..f93f8e79bf38 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Te rugăm să contactezi hola@formbricks.com pentru asistență.",
"payment_error_failed_invoice": "Plata nu a putut fi finalizată.",
"payment_error_requires_action": "Plata necesită autentificare suplimentară.",
+ "payment_method_required": "Adaugă o metodă de plată pentru a trece la un plan plătit.",
"pending_change_removed": "Schimbarea de plan programată a fost anulată.",
"pending_plan_badge": "Programat",
"pending_plan_change_description": "Abonamentul tău va trece la {plan} pe {date}.",
diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json
index f9bfedabb68a..fdce7f4b60f6 100644
--- a/apps/web/locales/ru-RU.json
+++ b/apps/web/locales/ru-RU.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Пожалуйста, свяжитесь с нами hola@formbricks.com для получения помощи.",
"payment_error_failed_invoice": "Не удалось завершить платёж.",
"payment_error_requires_action": "Платёж требует дополнительной аутентификации.",
+ "payment_method_required": "Добавь способ оплаты, чтобы перейти на платный тариф.",
"pending_change_removed": "Запланированное изменение тарифа отменено.",
"pending_plan_badge": "Запланирован",
"pending_plan_change_description": "Твой тариф сменится на {plan} на {date}.",
diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json
index 0b1616b1d201..ca110793548f 100644
--- a/apps/web/locales/sv-SE.json
+++ b/apps/web/locales/sv-SE.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Vänligen kontakta hola@formbricks.com för support.",
"payment_error_failed_invoice": "Betalningen kunde inte genomföras.",
"payment_error_requires_action": "Betalningen kräver ytterligare autentisering.",
+ "payment_method_required": "Lägg till en betalningsmetod för att byta till en betald plan.",
"pending_change_removed": "Schemalagd abonnemangsändring har tagits bort.",
"pending_plan_badge": "Schemalagd",
"pending_plan_change_description": "Din plan kommer att byta till {plan} den {date}.",
diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json
index d5143243a3fc..ebd42cc39144 100644
--- a/apps/web/locales/tr-TR.json
+++ b/apps/web/locales/tr-TR.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "Destek için lütfen hola@formbricks.com ile iletişime geç.",
"payment_error_failed_invoice": "Ödeme tamamlanamadı.",
"payment_error_requires_action": "Ödeme için ek doğrulama gerekiyor.",
+ "payment_method_required": "Ücretli bir plana geçmek için bir ödeme yöntemi ekle.",
"pending_change_removed": "Planlanmış plan değişikliği kaldırıldı.",
"pending_plan_badge": "Planlandı",
"pending_plan_change_description": "Planınız {date} tarihinde {plan} olarak değişecek.",
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index e71707b8fa0d..b62faa11140f 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "请联系 hola@formbricks.com 获取支持。",
"payment_error_failed_invoice": "支付无法完成。",
"payment_error_requires_action": "支付需要额外验证。",
+ "payment_method_required": "添加付款方式以切换到付费套餐。",
"pending_change_removed": "已取消预定的方案变更。",
"pending_plan_badge": "已预定",
"pending_plan_change_description": "你的套餐将在 {date} 切换为 {plan}。",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index 6c39548feb2f..5d019e3884fe 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -2471,6 +2471,7 @@
"payment_error_contact_support": "如需協助,請聯絡 hola@formbricks.com。",
"payment_error_failed_invoice": "付款無法完成。",
"payment_error_requires_action": "付款需要額外的身份驗證。",
+ "payment_method_required": "新增付款方式以切換至付費方案。",
"pending_change_removed": "已取消預定的方案變更。",
"pending_plan_badge": "已排程",
"pending_plan_change_description": "您的方案將於 {date} 切換至 {plan}。",
diff --git a/apps/web/modules/ee/billing/actions.ts b/apps/web/modules/ee/billing/actions.ts
index c9c33a699d01..95637bbec123 100644
--- a/apps/web/modules/ee/billing/actions.ts
+++ b/apps/web/modules/ee/billing/actions.ts
@@ -388,6 +388,13 @@ export const changeBillingPlanAction = authenticatedActionClient.inputSchema(ZCh
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
+ // Mirror the client rule server-side: a paid plan change requires a collected payment method.
+ // The client routes no-card paid changes through the add-card checkout; this rejects any direct
+ // call that bypasses it (e.g. a trialing no-card org trying to launder into active paid Pro).
+ if (parsedInput.targetPlan !== "hobby" && organization.billing.stripe?.hasPaymentMethod !== true) {
+ throw new OperationNotAllowedError("payment_method_required");
+ }
+
const result = await switchOrganizationToCloudPlan({
organizationId,
customerId: organization.billing.stripeCustomerId,
diff --git a/apps/web/modules/ee/billing/components/pricing-table.tsx b/apps/web/modules/ee/billing/components/pricing-table.tsx
index 31a4232cfd0c..57f1c0f77a53 100644
--- a/apps/web/modules/ee/billing/components/pricing-table.tsx
+++ b/apps/web/modules/ee/billing/components/pricing-table.tsx
@@ -161,6 +161,10 @@ const getActionErrorMessage = (serverError: string, t: (key: string) => string)
return t("workspace.settings.billing.yearly_checkout_unavailable");
}
+ if (serverError === "payment_method_required") {
+ return t("workspace.settings.billing.payment_method_required");
+ }
+
return t("common.something_went_wrong_please_try_again");
};
diff --git a/apps/web/modules/ee/billing/lib/organization-billing.test.ts b/apps/web/modules/ee/billing/lib/organization-billing.test.ts
index 9902aa8266c8..ba57b649b37b 100644
--- a/apps/web/modules/ee/billing/lib/organization-billing.test.ts
+++ b/apps/web/modules/ee/billing/lib/organization-billing.test.ts
@@ -372,6 +372,13 @@ describe("organization-billing", () => {
}));
mocks.subscriptionSchedulesRelease.mockResolvedValue({});
mocks.subscriptionsUpdate.mockResolvedValue({});
+ // Default: no card at the customer level either. The payment-method check falls back to the
+ // customer default when the subscription has none, so it must always resolve to a customer.
+ mocks.customersRetrieve.mockResolvedValue({
+ id: "cus_1",
+ deleted: false,
+ invoice_settings: { default_payment_method: null },
+ });
});
test("ensureStripeCustomerForOrganization returns null when org does not exist", async () => {
@@ -921,6 +928,304 @@ describe("organization-billing", () => {
expect(mocks.cacheDel).toHaveBeenCalledWith(["billing-cache-key"]);
});
+ test("switchOrganizationToCloudPlan cancels a no-card trial at period end on downgrade instead of building a billable schedule", async () => {
+ mocks.subscriptionsList.mockResolvedValue({
+ data: [
+ {
+ id: "sub_trial",
+ status: "trialing",
+ billing_cycle_anchor: 1739923200,
+ cancel_at_period_end: false,
+ default_payment_method: null,
+ trial_end: 1742515200,
+ schedule: null,
+ items: {
+ data: [
+ {
+ id: "si_pro_base",
+ current_period_end: 1742515200,
+ price: {
+ id: "price_pro_monthly",
+ metadata: {
+ formbricks_plan: "pro",
+ formbricks_price_kind: "base",
+ formbricks_interval: "monthly",
+ },
+ product: { id: "prod_pro", metadata: { formbricks_plan: "pro" }, active: true },
+ recurring: { usage_type: "licensed", interval: "month" },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ });
+ // No card on the subscription and none on the customer either.
+ mocks.customersRetrieve.mockResolvedValue({
+ id: "cus_1",
+ deleted: false,
+ invoice_settings: { default_payment_method: null },
+ });
+ mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
+ stripeCustomerId: "cus_1",
+ limits: { workspaces: 3, monthly: { responses: 1500 } },
+ usageCycleAnchor: new Date(),
+ stripe: {
+ subscriptionId: "sub_trial",
+ plan: "pro",
+ interval: "monthly",
+ subscriptionStatus: "trialing",
+ hasPaymentMethod: false,
+ },
+ });
+
+ const result = await switchOrganizationToCloudPlan({
+ organizationId: "org_1",
+ customerId: "cus_1",
+ targetPlan: "hobby",
+ targetInterval: "monthly",
+ });
+
+ expect(result).toEqual({
+ mode: "scheduled",
+ pendingChange: {
+ type: "plan_change",
+ targetPlan: "hobby",
+ targetInterval: "monthly",
+ effectiveAt: new Date(1742515200 * 1000).toISOString(),
+ },
+ clientSecret: null,
+ requiresAction: false,
+ });
+ // The trial is cancelled at period end — never rebuilt into a billable schedule.
+ expect(mocks.subscriptionsUpdate).toHaveBeenCalledWith("sub_trial", {
+ cancel_at_period_end: true,
+ });
+ expect(mocks.subscriptionSchedulesCreate).not.toHaveBeenCalled();
+ expect(mocks.subscriptionSchedulesUpdate).not.toHaveBeenCalled();
+ expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
+ where: { organizationId: "org_1" },
+ data: {
+ stripe: expect.objectContaining({
+ pendingChange: {
+ type: "plan_change",
+ targetPlan: "hobby",
+ targetInterval: "monthly",
+ effectiveAt: new Date(1742515200 * 1000).toISOString(),
+ },
+ }),
+ },
+ });
+ });
+
+ test("switchOrganizationToCloudPlan rejects a paid switch from a no-card trial", async () => {
+ mocks.subscriptionsList.mockResolvedValue({
+ data: [
+ {
+ id: "sub_trial",
+ status: "trialing",
+ billing_cycle_anchor: 1739923200,
+ cancel_at_period_end: false,
+ default_payment_method: null,
+ trial_end: 1742515200,
+ schedule: null,
+ items: {
+ data: [
+ {
+ id: "si_pro_base",
+ current_period_end: 1742515200,
+ price: {
+ id: "price_pro_monthly",
+ metadata: {
+ formbricks_plan: "pro",
+ formbricks_price_kind: "base",
+ formbricks_interval: "monthly",
+ },
+ product: { id: "prod_pro", metadata: { formbricks_plan: "pro" }, active: true },
+ recurring: { usage_type: "licensed", interval: "month" },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ });
+ mocks.customersRetrieve.mockResolvedValue({
+ id: "cus_1",
+ deleted: false,
+ invoice_settings: { default_payment_method: null },
+ });
+ mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
+ stripeCustomerId: "cus_1",
+ limits: { workspaces: 3, monthly: { responses: 1500 } },
+ usageCycleAnchor: new Date(),
+ stripe: {
+ subscriptionId: "sub_trial",
+ plan: "pro",
+ interval: "monthly",
+ subscriptionStatus: "trialing",
+ hasPaymentMethod: false,
+ },
+ });
+
+ await expect(
+ switchOrganizationToCloudPlan({
+ organizationId: "org_1",
+ customerId: "cus_1",
+ targetPlan: "scale",
+ targetInterval: "monthly",
+ })
+ ).rejects.toThrow("payment_method_required");
+
+ // No billable conversion is attempted.
+ expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled();
+ expect(mocks.subscriptionSchedulesCreate).not.toHaveBeenCalled();
+ expect(mocks.subscriptionSchedulesUpdate).not.toHaveBeenCalled();
+ });
+
+ test("switchOrganizationToCloudPlan releases a stray schedule before cancelling a no-card trial on downgrade", async () => {
+ mocks.subscriptionsList.mockResolvedValue({
+ data: [
+ {
+ id: "sub_trial",
+ status: "trialing",
+ billing_cycle_anchor: 1739923200,
+ cancel_at_period_end: false,
+ default_payment_method: null,
+ trial_end: 1742515200,
+ schedule: "sched_trial",
+ items: {
+ data: [
+ {
+ id: "si_pro_base",
+ current_period_end: 1742515200,
+ price: {
+ id: "price_pro_monthly",
+ metadata: {
+ formbricks_plan: "pro",
+ formbricks_price_kind: "base",
+ formbricks_interval: "monthly",
+ },
+ product: { id: "prod_pro", metadata: { formbricks_plan: "pro" }, active: true },
+ recurring: { usage_type: "licensed", interval: "month" },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ });
+ mocks.customersRetrieve.mockResolvedValue({
+ id: "cus_1",
+ deleted: false,
+ invoice_settings: { default_payment_method: null },
+ });
+ mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
+ stripeCustomerId: "cus_1",
+ limits: { workspaces: 3, monthly: { responses: 1500 } },
+ usageCycleAnchor: new Date(),
+ stripe: {
+ subscriptionId: "sub_trial",
+ plan: "pro",
+ interval: "monthly",
+ subscriptionStatus: "trialing",
+ hasPaymentMethod: false,
+ },
+ });
+
+ const result = await switchOrganizationToCloudPlan({
+ organizationId: "org_1",
+ customerId: "cus_1",
+ targetPlan: "hobby",
+ targetInterval: "monthly",
+ });
+
+ expect(result.mode).toBe("scheduled");
+ // The stray schedule (which would otherwise rebuild the trial into a billable Pro phase) is
+ // released first, then the trial is cancelled at period end — never converted to a schedule.
+ expect(mocks.subscriptionSchedulesRelease).toHaveBeenCalledWith("sched_trial", {
+ preserve_cancel_date: false,
+ });
+ expect(mocks.subscriptionsUpdate).toHaveBeenCalledWith("sub_trial", {
+ cancel_at_period_end: true,
+ });
+ expect(mocks.subscriptionSchedulesCreate).not.toHaveBeenCalled();
+ expect(mocks.subscriptionSchedulesUpdate).not.toHaveBeenCalled();
+ });
+
+ test("switchOrganizationToCloudPlan allows a paid switch from a trial when the card is only on the customer", async () => {
+ mocks.subscriptionsList.mockResolvedValue({
+ data: [
+ {
+ id: "sub_trial",
+ status: "trialing",
+ billing_cycle_anchor: 1739923200,
+ cancel_at_period_end: false,
+ // No card on the subscription itself...
+ default_payment_method: null,
+ trial_end: 1742515200,
+ schedule: null,
+ items: {
+ data: [
+ {
+ id: "si_pro_base",
+ current_period_end: 1742515200,
+ price: {
+ id: "price_pro_monthly",
+ metadata: {
+ formbricks_plan: "pro",
+ formbricks_price_kind: "base",
+ formbricks_interval: "monthly",
+ },
+ product: { id: "prod_pro", metadata: { formbricks_plan: "pro" }, active: true },
+ recurring: { usage_type: "licensed", interval: "month" },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ });
+ // ...but a card is saved at the customer level, so the paid switch must be allowed through.
+ mocks.customersRetrieve.mockResolvedValue({
+ id: "cus_1",
+ deleted: false,
+ invoice_settings: { default_payment_method: "pm_customer" },
+ });
+ mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
+ stripeCustomerId: "cus_1",
+ limits: { workspaces: 3, monthly: { responses: 1500 } },
+ usageCycleAnchor: new Date(),
+ stripe: {
+ subscriptionId: "sub_trial",
+ plan: "pro",
+ interval: "monthly",
+ subscriptionStatus: "trialing",
+ hasPaymentMethod: false,
+ },
+ });
+
+ const result = await switchOrganizationToCloudPlan({
+ organizationId: "org_1",
+ customerId: "cus_1",
+ targetPlan: "scale",
+ targetInterval: "monthly",
+ });
+
+ // Proceeds through the normal paid path instead of being rejected or cancelled.
+ expect(result.mode).toBe("immediate");
+ expect(mocks.subscriptionsUpdate).toHaveBeenCalledWith(
+ "sub_trial",
+ expect.objectContaining({
+ payment_behavior: "pending_if_incomplete",
+ proration_behavior: "always_invoice",
+ })
+ );
+ expect(mocks.subscriptionsUpdate).not.toHaveBeenCalledWith("sub_trial", {
+ cancel_at_period_end: true,
+ });
+ });
+
test("switchOrganizationToCloudPlan uses pending_if_incomplete for immediate upgrades so the plan is granted only once paid", async () => {
mocks.subscriptionsList.mockResolvedValue({
data: [
diff --git a/apps/web/modules/ee/billing/lib/organization-billing.ts b/apps/web/modules/ee/billing/lib/organization-billing.ts
index 690ee694a0ec..b0e9d5afd26a 100644
--- a/apps/web/modules/ee/billing/lib/organization-billing.ts
+++ b/apps/web/modules/ee/billing/lib/organization-billing.ts
@@ -907,6 +907,75 @@ const scheduleSubscriptionPlanChange = async (
return pendingChange;
};
+/**
+ * Whether a payment method has been collected for the subscription. Checks the subscription
+ * default first, then falls back to the customer-level default so a card saved on the customer
+ * (but not yet attached to the subscription) still counts — this avoids falsely blocking a
+ * legitimately card-backed org on a paid switch.
+ */
+const hasCollectedPaymentMethod = async (
+ subscription: NonNullable>>,
+ customerId: string
+): Promise => {
+ if (subscription.default_payment_method != null) {
+ return true;
+ }
+
+ if (!stripeClient) {
+ return false;
+ }
+
+ const customer = await stripeClient.customers.retrieve(customerId);
+ if (customer.deleted) {
+ return false;
+ }
+
+ return customer.invoice_settings?.default_payment_method != null;
+};
+
+/**
+ * Cancels a no-card trial at period end so the org lands on Hobby without ever being converted
+ * into a billable schedule. Releases any stray schedule first (a schedule would otherwise rebuild
+ * the trial into a billable Pro phase), then sets cancel_at_period_end so Stripe cancels the
+ * trialing subscription instead of charging it — no schedule, no card required.
+ */
+const cancelTrialToHobbyAtPeriodEnd = async (
+ organizationId: string,
+ subscription: NonNullable>>
+): Promise => {
+ if (!stripeClient) {
+ throw new Error("Stripe is not configured");
+ }
+
+ if (subscription.schedule) {
+ const scheduleId =
+ typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule.id;
+ await stripeClient.subscriptionSchedules.release(scheduleId, {
+ preserve_cancel_date: false,
+ });
+ }
+
+ await stripeClient.subscriptions.update(subscription.id, {
+ cancel_at_period_end: true,
+ });
+
+ const effectiveAt =
+ resolvePendingChangeEffectiveAt(subscription) ??
+ (subscription.trial_end ? new Date(subscription.trial_end * 1000).toISOString() : null) ??
+ new Date().toISOString();
+
+ const pendingChange: TOrganizationStripePendingChange = {
+ type: "plan_change",
+ targetPlan: "hobby",
+ targetInterval: "monthly",
+ effectiveAt,
+ };
+
+ await updatePendingPlanChangeSnapshot(organizationId, pendingChange);
+
+ return pendingChange;
+};
+
export const switchOrganizationToCloudPlan = async (input: {
organizationId: string;
customerId: string;
@@ -933,6 +1002,23 @@ export const switchOrganizationToCloudPlan = async (input: {
return { mode: "immediate", pendingChange: null, clientSecret: null, requiresAction: false };
}
+ // A trialing subscription with no payment method must never be converted into a billable
+ // subscription. The scheduled path rebuilds phases without the trial guard, ending the trial and
+ // turning phase 1 into a billable Pro phase; undoing that pending change would then leave the org
+ // on active paid Pro with no card. So special-case it: a downgrade cancels the trial (landing on
+ // Hobby, no card), and any paid switch is rejected and routed through the add-card checkout.
+ if (
+ subscription.status === "trialing" &&
+ !(await hasCollectedPaymentMethod(subscription, input.customerId))
+ ) {
+ if (input.targetPlan === "hobby") {
+ const pendingChange = await cancelTrialToHobbyAtPeriodEnd(input.organizationId, subscription);
+ return { mode: "scheduled", pendingChange, clientSecret: null, requiresAction: false };
+ }
+
+ throw new OperationNotAllowedError("payment_method_required");
+ }
+
if (isImmediateUpgrade) {
const confirmation = await updateSubscriptionItemsImmediately(
subscription,
@@ -1397,6 +1483,10 @@ export const syncOrganizationBillingFromStripe = async (
const subscriptionStatus = resolveSubscriptionStatus(subscription);
const usageCycleAnchor = resolveUsageCycleAnchor(subscription);
const pendingChange = await resolvePendingPlanChange(subscription);
+ // Match the guard in switchOrganizationToCloudPlan / changeBillingPlanAction: a card saved on the
+ // customer (but not yet attached to the subscription) still counts, so the cached flag must not
+ // falsely block a legitimately card-backed org on a paid switch.
+ const hasPaymentMethod = subscription ? await hasCollectedPaymentMethod(subscription, customerId) : false;
const transition = resolveSubscriptionLifecycleTransition(
existingStripeSnapshot,
@@ -1423,7 +1513,7 @@ export const syncOrganizationBillingFromStripe = async (
interval: billingInterval,
subscriptionStatus,
subscriptionId: subscription?.id ?? null,
- hasPaymentMethod: subscription?.default_payment_method != null,
+ hasPaymentMethod,
features: featureLookupKeys,
pendingChange,
// Clear the payment-failure banner only when this sync reflects a real settlement:
diff --git a/apps/web/package.json b/apps/web/package.json
index 387e0623e694..ed2e620dcdb6 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -121,7 +121,7 @@
"lucide-react": "0.577.0",
"markdown-it": "14.2.0",
"mcp-handler": "1.1.0",
- "next": "16.2.6",
+ "next": "16.2.11",
"next-safe-action": "8.1.10",
"nodemailer": "9.0.1",
"otplib": "12.0.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a07571553dbc..989612f1e2ae 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -156,7 +156,7 @@ importers:
dependencies:
'@better-auth/oauth-provider':
specifier: 1.6.18
- version: 1.6.18(9ed33187870d0018bc92f5357781a657)
+ version: 1.6.18(8fea9c40cddf92368b333fccf3e99f8e)
'@better-auth/utils':
specifier: 0.4.1
version: 0.4.1
@@ -351,7 +351,7 @@ importers:
version: 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@sentry/nextjs':
specifier: 10.43.0
- version: 10.43.0(@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.14))
+ version: 10.43.0(@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.14))
'@stripe/stripe-js':
specifier: 5.6.0
version: 5.6.0
@@ -381,7 +381,7 @@ importers:
version: 3.0.3
better-auth:
specifier: 1.6.18
- version: 1.6.18(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6)
+ version: 1.6.18(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6)
boring-avatars:
specifier: 2.0.4
version: 2.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -447,13 +447,13 @@ importers:
version: 14.2.0
mcp-handler:
specifier: 1.1.0
- version: 1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))
+ version: 1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))
next:
- specifier: 16.2.6
- version: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ specifier: 16.2.11
+ version: 16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-safe-action:
specifier: 8.1.10
- version: 8.1.10(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ version: 8.1.10(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
nodemailer:
specifier: 9.0.1
version: 9.0.1
@@ -2926,60 +2926,60 @@ packages:
'@neoconfetti/react@1.0.0':
resolution: {integrity: sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==}
- '@next/env@16.2.6':
- resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
+ '@next/env@16.2.11':
+ resolution: {integrity: sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==}
'@next/eslint-plugin-next@15.5.18':
resolution: {integrity: sha512-w4MYq8M26a8PNrfto0JosLf5/3ssln1rsyP96g2DkC8uFVymStM5DLSz5ElxxrPRg2XnTMnFo3kREFlhYvxhWw==}
- '@next/swc-darwin-arm64@16.2.6':
- resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
+ '@next/swc-darwin-arm64@16.2.11':
+ resolution: {integrity: sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.2.6':
- resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
+ '@next/swc-darwin-x64@16.2.11':
+ resolution: {integrity: sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.2.6':
- resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
+ '@next/swc-linux-arm64-gnu@16.2.11':
+ resolution: {integrity: sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.2.6':
- resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
+ '@next/swc-linux-arm64-musl@16.2.11':
+ resolution: {integrity: sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.2.6':
- resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
+ '@next/swc-linux-x64-gnu@16.2.11':
+ resolution: {integrity: sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.2.6':
- resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
+ '@next/swc-linux-x64-musl@16.2.11':
+ resolution: {integrity: sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.2.6':
- resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
+ '@next/swc-win32-arm64-msvc@16.2.11':
+ resolution: {integrity: sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.2.6':
- resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
+ '@next/swc-win32-x64-msvc@16.2.11':
+ resolution: {integrity: sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -9897,8 +9897,8 @@ packages:
react: '>= 18.2.0'
react-dom: '>= 18.2.0'
- next@16.2.6:
- resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
+ next@16.2.11:
+ resolution: {integrity: sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -14129,12 +14129,12 @@ snapshots:
optionalDependencies:
mongodb: 7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7)
- '@better-auth/oauth-provider@1.6.18(9ed33187870d0018bc92f5357781a657)':
+ '@better-auth/oauth-provider@1.6.18(8fea9c40cddf92368b333fccf3e99f8e)':
dependencies:
'@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.3.0
- better-auth: 1.6.18(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6)
+ better-auth: 1.6.18(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6)
better-call: 1.3.6(zod@4.3.6)
jose: 6.2.2
zod: 4.3.6
@@ -15053,34 +15053,34 @@ snapshots:
'@neoconfetti/react@1.0.0': {}
- '@next/env@16.2.6': {}
+ '@next/env@16.2.11': {}
'@next/eslint-plugin-next@15.5.18':
dependencies:
fast-glob: 3.3.1
- '@next/swc-darwin-arm64@16.2.6':
+ '@next/swc-darwin-arm64@16.2.11':
optional: true
- '@next/swc-darwin-x64@16.2.6':
+ '@next/swc-darwin-x64@16.2.11':
optional: true
- '@next/swc-linux-arm64-gnu@16.2.6':
+ '@next/swc-linux-arm64-gnu@16.2.11':
optional: true
- '@next/swc-linux-arm64-musl@16.2.6':
+ '@next/swc-linux-arm64-musl@16.2.11':
optional: true
- '@next/swc-linux-x64-gnu@16.2.6':
+ '@next/swc-linux-x64-gnu@16.2.11':
optional: true
- '@next/swc-linux-x64-musl@16.2.6':
+ '@next/swc-linux-x64-musl@16.2.11':
optional: true
- '@next/swc-win32-arm64-msvc@16.2.6':
+ '@next/swc-win32-arm64-msvc@16.2.11':
optional: true
- '@next/swc-win32-x64-msvc@16.2.6':
+ '@next/swc-win32-x64-msvc@16.2.11':
optional: true
'@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
@@ -17559,7 +17559,7 @@ snapshots:
'@sentry/core@10.43.0': {}
- '@sentry/nextjs@10.43.0(@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.14))':
+ '@sentry/nextjs@10.43.0(@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.14))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.40.0
@@ -17572,7 +17572,7 @@ snapshots:
'@sentry/react': 10.43.0(react@19.2.6)
'@sentry/vercel-edge': 10.43.0
'@sentry/webpack-plugin': 5.1.1(encoding@0.1.13)(webpack@5.105.4(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.14))
- next: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ next: 16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
rollup: 4.59.0
stacktrace-parser: 0.1.11
transitivePeerDependencies:
@@ -19899,7 +19899,7 @@ snapshots:
jsonpointer: 5.0.1
leven: 3.1.0
- better-auth@1.6.18(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6):
+ better-auth@1.6.18(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6):
dependencies:
'@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
@@ -19923,7 +19923,7 @@ snapshots:
better-sqlite3: 12.8.0
mongodb: 7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7)
mysql2: 3.20.0(@types/node@25.4.0)
- next: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ next: 16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
pg: 8.20.0
prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
@@ -22651,14 +22651,14 @@ snapshots:
math-intrinsics@1.1.0: {}
- mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)):
+ mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)):
dependencies:
'@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6)
chalk: 5.6.2
commander: 11.1.0
redis: 4.7.1
optionalDependencies:
- next: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ next: 16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
mdn-data@2.0.14: {}
@@ -22931,16 +22931,16 @@ snapshots:
neo-async@2.6.2: {}
- next-safe-action@8.1.10(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
+ next-safe-action@8.1.10(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
deepmerge-ts: 7.1.5
- next: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ next: 16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
- next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
+ next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
- '@next/env': 16.2.6
+ '@next/env': 16.2.11
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.31
caniuse-lite: 1.0.30001793
@@ -22949,14 +22949,14 @@ snapshots:
react-dom: 19.2.6(react@19.2.6)
styled-jsx: 5.1.6(react@19.2.6)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.2.6
- '@next/swc-darwin-x64': 16.2.6
- '@next/swc-linux-arm64-gnu': 16.2.6
- '@next/swc-linux-arm64-musl': 16.2.6
- '@next/swc-linux-x64-gnu': 16.2.6
- '@next/swc-linux-x64-musl': 16.2.6
- '@next/swc-win32-arm64-msvc': 16.2.6
- '@next/swc-win32-x64-msvc': 16.2.6
+ '@next/swc-darwin-arm64': 16.2.11
+ '@next/swc-darwin-x64': 16.2.11
+ '@next/swc-linux-arm64-gnu': 16.2.11
+ '@next/swc-linux-arm64-musl': 16.2.11
+ '@next/swc-linux-x64-gnu': 16.2.11
+ '@next/swc-linux-x64-musl': 16.2.11
+ '@next/swc-win32-arm64-msvc': 16.2.11
+ '@next/swc-win32-x64-msvc': 16.2.11
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.58.2
sharp: 0.35.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 65b81bdcdaeb..fd3093fa36de 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -154,3 +154,21 @@ minimumReleaseAgeExclude:
# a fresh hub bump isn't blocked. Version-pinned scoped entries (e.g. '@formbricks/hub@0.9.0') are not
# reliably matched by the exclude, so match by name.
- '@formbricks/hub'
+ # Urgent CVE fix, approved via the dependency-cooldown exception process (see the
+ # "Dependency Cooldown Security Exceptions" page in the engineering handbook / Notion):
+ # next@16.2.11 fixes GHSA-6gpp-xcg3-4w24, GHSA-m99w-x7hq-7vfj, GHSA-89xv-2m56-2m9x,
+ # GHSA-p9j2-gv94-2wf4 (high) and GHSA-68g3-v927-f742, GHSA-4633-3j49-mh5q,
+ # GHSA-4c39-4ccg-62r3, GHSA-q8wf-6r8g-63ch, GHSA-955p-x3mx-jcvp (moderate). Published
+ # 2026-07-21, cooldown clears 2026-07-24T16:00 UTC — remove this entry once past that date.
+ # `next`'s platform-specific companion packages publish under the same version and need their
+ # own entries; pnpm's release-age check applies per npm package, not per dependency graph.
+ - next@16.2.11
+ - '@next/env@16.2.11'
+ - '@next/swc-darwin-arm64@16.2.11'
+ - '@next/swc-darwin-x64@16.2.11'
+ - '@next/swc-linux-arm64-gnu@16.2.11'
+ - '@next/swc-linux-arm64-musl@16.2.11'
+ - '@next/swc-linux-x64-gnu@16.2.11'
+ - '@next/swc-linux-x64-musl@16.2.11'
+ - '@next/swc-win32-arm64-msvc@16.2.11'
+ - '@next/swc-win32-x64-msvc@16.2.11'