From 3dd7dfde5af2db62c3fd70a42e3e4343911ff649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 9 Jul 2026 12:53:29 +0200 Subject: [PATCH] feat(billing): collect credit card in-app when starting subscription from billing page (#22706) ## Context On the billing page, a trialing workspace without a payment method that clicks "Increase" (credits section) or "Subscribe Now" (subscription card) goes through the end-trial confirmation and then gets redirected to the Stripe billing portal to add a card. We already have an in-app card collection flow (`AddCreditCardModal`, Stripe PaymentElement on a SetupIntent) used by the AI chat credits banner and the global end-trial information banner. The billing page was the last surface still bouncing users to the portal. ## What this does Applies the same pattern as `AIChatNoMoreBillingCreditsBanner` and `InformationBannerEndTrialPeriod` to the billing page: - When the workspace is trialing and `billingHasPaymentMethod === false`, the `endTrialPeriod` modal now renders `AddCreditCardModal` (in-app card entry) instead of the confirmation modal. Once the card is added, the subscription starts via `endTrialPeriod({ skipPaymentMethodRedirect: true })`, and the user stays on the billing page. - When a payment method exists (or the flag is unknown), the existing "Start Your Subscription" confirmation modal is unchanged, including its portal-redirect fallback if the backend reports no card. - Both entry points share the same modal instance (`BILLING_MODAL_IDS.endTrialPeriod`), so the "Increase" button in the credits section and the "Subscribe Now" header action both get the in-app flow. Button labels are unchanged: they state the outcome, and the modal explains the card step. - `AddCreditCardModal` now closes only after `onPaymentMethodAdded` resolves, so the form keeps a visible loading state until the subscription is confirmed instead of closing instantly while activation runs in the background. This also benefits the AI chat and information banner flows that share the component. ## What stays the same - No backend changes. `endSubscriptionTrialPeriod` still verifies the payment method live against Stripe and calls `ensureDefaultPaymentMethod` before activating, so the card added through the SetupIntent becomes the default charged card. - 3DS or redirect-based payment methods return to the billing page with the `start-subscription-after-payment-method` param, which the globally mounted `EndTrialAfterPaymentMethodGater` already handles. - The trial banner ("Trial ends X, please add card details") keeps its portal flow on purpose: its semantic is add a card while keeping the trial, whereas `AddCreditCardModal` starts the subscription immediately. Converting it would need a variant of the form that skips the auto-start param. Same for the past-due "Update payment" path. Both are possible follow-ups. - All strings reuse existing Lingui msgids, no catalog changes needed. ## Testing - `oxlint`, `oxfmt` and `nx typecheck twenty-front` pass. - All 17 `settings/billing` jest suites pass. - Could not drive the Stripe flow end-to-end in this environment (no Stripe test keys); the composition is identical to the two surfaces already shipped, and the state matrix (`hasPaymentMethod` false / true / unknown, trialing / not trialing, permission gating) was traced through both entry points. --- .../billing/components/AddCreditCardModal.tsx | 3 +- .../SettingsBillingSubscriptionInfo.tsx | 11 +++++++ .../SettingsBillingSubscriptionInfoModals.tsx | 30 +++++++++++++------ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/twenty-front/src/modules/settings/billing/components/AddCreditCardModal.tsx b/packages/twenty-front/src/modules/settings/billing/components/AddCreditCardModal.tsx index d739e5e34e..1fda35ad1c 100644 --- a/packages/twenty-front/src/modules/settings/billing/components/AddCreditCardModal.tsx +++ b/packages/twenty-front/src/modules/settings/billing/components/AddCreditCardModal.tsx @@ -34,9 +34,10 @@ export const AddCreditCardModal = ({ const { t } = useLingui(); const { closeModal } = useModal(); + // Close only after activation so the form keeps its loading state visible const handlePaymentMethodAdded = async () => { - closeModal(modalInstanceId); await onPaymentMethodAdded(); + closeModal(modalInstanceId); }; return ( diff --git a/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingSubscriptionInfo.tsx b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingSubscriptionInfo.tsx index a2a604a781..18d1f7134f 100644 --- a/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingSubscriptionInfo.tsx +++ b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingSubscriptionInfo.tsx @@ -17,6 +17,7 @@ import { useGetResourceCreditUsage } from '@/settings/billing/hooks/useGetResour import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase'; import { useNextPlan } from '@/settings/billing/hooks/useNextPlan'; import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices'; +import { billingHasPaymentMethodSelector } from '@/settings/billing/states/billingHasPaymentMethodSelector'; import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; @@ -137,6 +138,14 @@ export const SettingsBillingSubscriptionInfo = ({ const { endTrialPeriod, isLoading: isEndTrialPeriodLoading } = useEndSubscriptionTrialPeriod(); + const billingHasPaymentMethod = useAtomStateValue( + billingHasPaymentMethodSelector, + ); + + const startSubscriptionAfterPaymentMethodAdded = async () => { + await endTrialPeriod({ skipPaymentMethodRedirect: true }); + }; + const { [PermissionFlagType.WORKSPACE]: hasPermissionToEndTrialPeriod } = usePermissionFlagMap(); @@ -517,6 +526,7 @@ export const SettingsBillingSubscriptionInfo = ({ workspaceMembers={currentWorkspaceMembers} /> void; onCancelResourceCreditSwitching: () => void; onEndTrialPeriod: () => void; + onPaymentMethodAdded: () => Promise; onSwitchInterval: () => void; startSubscriptionSubtitle: string; switchToMonthlySubtitle: string; @@ -21,6 +24,7 @@ type SettingsBillingSubscriptionInfoModalsProps = { }; export const SettingsBillingSubscriptionInfoModals = ({ + billingHasPaymentMethod, cancelIntervalSwitchingSubtitle, cancelPlanSwitchingSubtitle, isCancellingIntervalSwitch, @@ -32,6 +36,7 @@ export const SettingsBillingSubscriptionInfoModals = ({ onCancelPlanSwitching, onCancelResourceCreditSwitching, onEndTrialPeriod, + onPaymentMethodAdded, onSwitchInterval, startSubscriptionSubtitle, switchToMonthlySubtitle, @@ -77,15 +82,22 @@ export const SettingsBillingSubscriptionInfoModals = ({ confirmButtonAccent="blue" loading={isCancellingPlanSwitch} /> - + {billingHasPaymentMethod === false ? ( + + ) : ( + + )}