From 2f8d1682acbdb714778616951ab7135159940b07 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:17:02 +0530 Subject: [PATCH 1/3] fix(billing): stop reverse trial laundering into active paid plan [ENG-1968] (#8602) --- apps/web/i18n.lock | 1 + apps/web/locales/de-DE.json | 1 + apps/web/locales/en-US.json | 1 + apps/web/locales/es-ES.json | 1 + apps/web/locales/fr-FR.json | 1 + apps/web/locales/hu-HU.json | 1 + apps/web/locales/ja-JP.json | 1 + apps/web/locales/nl-NL.json | 1 + apps/web/locales/pt-BR.json | 1 + apps/web/locales/pt-PT.json | 1 + apps/web/locales/ro-RO.json | 1 + apps/web/locales/ru-RU.json | 1 + apps/web/locales/sv-SE.json | 1 + apps/web/locales/tr-TR.json | 1 + apps/web/locales/zh-Hans-CN.json | 1 + apps/web/locales/zh-Hant-TW.json | 1 + apps/web/modules/ee/billing/actions.ts | 7 + .../ee/billing/components/pricing-table.tsx | 4 + .../billing/lib/organization-billing.test.ts | 305 ++++++++++++++++++ .../ee/billing/lib/organization-billing.ts | 92 +++++- 20 files changed, 423 insertions(+), 1 deletion(-) 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: From 2623e5f92db73200e1143fdead8447797b4c356c Mon Sep 17 00:00:00 2001 From: Matti Nannt <675065+mattinannt@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:15:07 +0200 Subject: [PATCH 2/3] fix: bump next to 16.2.11 to patch 9 Next.js CVEs (#8633) Co-authored-by: Claude --- apps/web/package.json | 2 +- pnpm-lock.yaml | 112 +++++++++++++++++++++--------------------- pnpm-workspace.yaml | 18 +++++++ 3 files changed, 75 insertions(+), 57 deletions(-) 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' From f95191edde93d88de672297caaacc4cec33e1c8a Mon Sep 17 00:00:00 2001 From: Matti Nannt <675065+mattinannt@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:15:27 +0200 Subject: [PATCH 3/3] docs(pr): retarget PR template for agent-authored, Linear-driven PRs (#8620) Co-authored-by: Claude --- .github/pull_request_template.md | 56 +++++++++++++------------------- AGENTS.md | 2 +- 2 files changed, 24 insertions(+), 34 deletions(-) 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