Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code af36eee11b Duplicate checkout submission throws error instead of handling idempotently
https://sonarly.com/issue/4334?type=bug

The `createDirectSubscription` method throws a `BillingException` when a user's workspace already has a non-canceled subscription, instead of returning the existing subscription's success URL. This is triggered by duplicate form submissions (double-click, browser back + retry) on the 7-day no-credit-card trial flow.

Fix: **Backend** (`billing-portal.workspace-service.ts`): Replaced the `throw new BillingException('Customer already has a non-canceled billing subscription')` with `return successUrl`. When a workspace already has a non-canceled subscription (from a prior successful request), the method now returns the success URL idempotently instead of throwing an error. This makes `createDirectSubscription` safe to call multiple times — the first call creates the subscription, and subsequent calls simply redirect the user to the success page.

**Frontend** (`useHandleCheckoutSession.ts`): Added a `useRef`-based guard (`isSubmittingRef`) that synchronously prevents duplicate submissions. Unlike React `useState` (which is async and allows re-entry during the same render cycle), `useRef` is checked and set synchronously before the mutation fires. On error, both the ref and state are reset so the user can retry. On success, the ref stays locked since the user is being redirected.
2026-03-06 21:38:37 +00:00
2 changed files with 12 additions and 5 deletions
@@ -1,7 +1,7 @@
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRef, useState } from 'react';
import {
type BillingPlanKey,
type SubscriptionInterval,
@@ -26,8 +26,13 @@ export const useHandleCheckoutSession = ({
const [checkoutSession] = useCheckoutSessionMutation();
const [isSubmitting, setIsSubmitting] = useState(false);
const isSubmittingRef = useRef(false);
const handleCheckoutSession = async () => {
if (isSubmittingRef.current) {
return;
}
isSubmittingRef.current = true;
setIsSubmitting(true);
const { data } = await checkoutSession({
variables: {
@@ -37,8 +42,9 @@ export const useHandleCheckoutSession = ({
requirePaymentMethod,
},
});
setIsSubmitting(false);
if (!data?.checkoutSession.url) {
isSubmittingRef.current = false;
setIsSubmitting(false);
enqueueErrorSnackBar({
message: t`Checkout session error. Please retry or contact Twenty team`,
});
@@ -109,10 +109,11 @@ export class BillingPortalWorkspaceService {
(subscription) => subscription.status !== SubscriptionStatus.Canceled,
)
) {
throw new BillingException(
'Customer already has a non-canceled billing subscription',
BillingExceptionCode.BILLING_SUBSCRIPTION_INVALID,
this.logger.warn(
`Workspace ${workspace.id} already has a non-canceled billing subscription, returning success URL`,
);
return successUrl;
}
const stripeSubscription =