fix: reuse existing stripe customer for billing actions

This commit is contained in:
Matti Nannt
2026-03-13 16:37:35 +01:00
parent 38e84f4e15
commit 3f638c4ae8
3 changed files with 58 additions and 9 deletions

View File

@@ -109,6 +109,28 @@ describe("billing actions", () => {
expect(result).toEqual({ success: true });
});
test("startHobbyAction reuses an existing stripe customer id", async () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
billing: {
stripeCustomerId: "cus_existing",
},
});
const result = await startHobbyAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
test("startScaleTrialAction uses ensured customer when org snapshot has no stripe customer id", async () => {
const result = await startScaleTrialAction({
ctx: { user: { id: "user_1" } },
@@ -125,4 +147,27 @@ describe("billing actions", () => {
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
test("startScaleTrialAction reuses an existing stripe customer id", async () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
billing: {
stripeCustomerId: "cus_existing",
},
});
const result = await startScaleTrialAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createScaleTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"scale-trial"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
});

View File

@@ -169,7 +169,9 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
}
const { customerId } = await ensureStripeCustomerForOrganization(parsedInput.organizationId);
const customerId =
organization.billing?.stripeCustomerId ??
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
if (!customerId) {
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
@@ -198,7 +200,9 @@ export const startScaleTrialAction = authenticatedActionClient
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
}
const { customerId } = await ensureStripeCustomerForOrganization(parsedInput.organizationId);
const customerId =
organization.billing?.stripeCustomerId ??
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
if (!customerId) {
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}

View File

@@ -31,7 +31,7 @@ const CUSTOMER_LOGOS = [
export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps) => {
const router = useRouter();
const [isStartingTrial, setIsStartingTrial] = useState(false);
const [isStayingOnHobby, setIsStayingOnHobby] = useState(false);
const [isStartingHobby, setIsStartingHobby] = useState(false);
const { t } = useTranslation();
const TRIAL_FEATURE_KEYS = [
@@ -62,18 +62,18 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
};
const handleContinueFree = async () => {
setIsStayingOnHobby(true);
setIsStartingHobby(true);
try {
const result = await startHobbyAction({ organizationId });
if (result?.data) {
router.push(nextUrl);
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
setIsStayingOnHobby(false);
setIsStartingHobby(false);
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
setIsStayingOnHobby(false);
setIsStartingHobby(false);
}
};
@@ -107,7 +107,7 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
onClick={handleStartTrial}
className="mt-4 w-full"
loading={isStartingTrial}
disabled={isStartingTrial || isStayingOnHobby}>
disabled={isStartingTrial || isStartingHobby}>
{t("common.start_free_trial")}
</Button>
</div>
@@ -134,9 +134,9 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
<button
onClick={handleContinueFree}
disabled={isStartingTrial || isStayingOnHobby}
disabled={isStartingTrial || isStartingHobby}
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline">
{isStayingOnHobby ? t("common.loading") : t("environments.settings.billing.stay_on_hobby_plan")}
{isStartingHobby ? t("common.loading") : t("environments.settings.billing.stay_on_hobby_plan")}
</button>
</div>
);