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.
This commit is contained in:
+2
-1
@@ -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 (
|
||||
|
||||
+11
@@ -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}
|
||||
/>
|
||||
<SettingsBillingSubscriptionInfoModals
|
||||
billingHasPaymentMethod={billingHasPaymentMethod}
|
||||
cancelIntervalSwitchingSubtitle={confirmationModalCancelIntervalSwitchingMessage()}
|
||||
cancelPlanSwitchingSubtitle={confirmationModalCancelPlanSwitchingMessage()}
|
||||
isCancellingIntervalSwitch={isCancellingIntervalSwitch}
|
||||
@@ -528,6 +538,7 @@ export const SettingsBillingSubscriptionInfo = ({
|
||||
onCancelPlanSwitching={cancelPlanSwitching}
|
||||
onCancelResourceCreditSwitching={cancelResourceCreditSwitching}
|
||||
onEndTrialPeriod={endTrialPeriod}
|
||||
onPaymentMethodAdded={startSubscriptionAfterPaymentMethodAdded}
|
||||
onSwitchInterval={switchInterval}
|
||||
startSubscriptionSubtitle={startSubscriptionSubtitle}
|
||||
switchToMonthlySubtitle={confirmationModalSwitchToMonthlyMessage()}
|
||||
|
||||
+21
-9
@@ -1,8 +1,10 @@
|
||||
import { AddCreditCardModal } from '@/settings/billing/components/AddCreditCardModal';
|
||||
import { BILLING_MODAL_IDS } from '@/settings/billing/constants/BillingModalIds';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
type SettingsBillingSubscriptionInfoModalsProps = {
|
||||
billingHasPaymentMethod: boolean | null | undefined;
|
||||
cancelIntervalSwitchingSubtitle: string;
|
||||
cancelPlanSwitchingSubtitle: string;
|
||||
isCancellingIntervalSwitch: boolean;
|
||||
@@ -14,6 +16,7 @@ type SettingsBillingSubscriptionInfoModalsProps = {
|
||||
onCancelPlanSwitching: () => void;
|
||||
onCancelResourceCreditSwitching: () => void;
|
||||
onEndTrialPeriod: () => void;
|
||||
onPaymentMethodAdded: () => Promise<void>;
|
||||
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}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
modalInstanceId={BILLING_MODAL_IDS.endTrialPeriod}
|
||||
title={t`Start Your Subscription`}
|
||||
subtitle={startSubscriptionSubtitle}
|
||||
onConfirmClick={onEndTrialPeriod}
|
||||
confirmButtonText={t`Confirm`}
|
||||
confirmButtonAccent="blue"
|
||||
loading={isEndTrialPeriodLoading}
|
||||
/>
|
||||
{billingHasPaymentMethod === false ? (
|
||||
<AddCreditCardModal
|
||||
modalInstanceId={BILLING_MODAL_IDS.endTrialPeriod}
|
||||
onPaymentMethodAdded={onPaymentMethodAdded}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmationModal
|
||||
modalInstanceId={BILLING_MODAL_IDS.endTrialPeriod}
|
||||
title={t`Start Your Subscription`}
|
||||
subtitle={startSubscriptionSubtitle}
|
||||
onConfirmClick={onEndTrialPeriod}
|
||||
confirmButtonText={t`Confirm`}
|
||||
confirmButtonAccent="blue"
|
||||
loading={isEndTrialPeriodLoading}
|
||||
/>
|
||||
)}
|
||||
<ConfirmationModal
|
||||
modalInstanceId={BILLING_MODAL_IDS.cancelSwitchMeteredPrice}
|
||||
title={t`Cancel credit pack switching?`}
|
||||
|
||||
Reference in New Issue
Block a user