fix: defer hobby subscription creation

This commit is contained in:
Matti Nannt
2026-03-13 16:26:09 +01:00
parent bddcec0466
commit 71672f206c
5 changed files with 246 additions and 71 deletions

View File

@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { startScaleTrialAction, stayOnHobbyAction } from "./actions";
const mocks = vi.hoisted(() => ({
checkAuthorizationUpdated: vi.fn(),
getOrganization: vi.fn(),
createScaleTrialSubscription: vi.fn(),
ensureCloudStripeSetupForOrganization: vi.fn(),
ensureStripeCustomerForOrganization: vi.fn(),
reconcileCloudStripeSubscriptionsForOrganization: vi.fn(),
syncOrganizationBillingFromStripe: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
createCustomerPortalSession: vi.fn(),
isSubscriptionCancelled: vi.fn(),
stripeCustomerSessionsCreate: vi.fn(),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "https://app.formbricks.com",
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
}));
vi.mock("@/lib/organization/service", () => ({
getOrganization: mocks.getOrganization,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
}));
vi.mock("@/modules/ee/billing/api/lib/create-customer-portal-session", () => ({
createCustomerPortalSession: mocks.createCustomerPortalSession,
}));
vi.mock("@/modules/ee/billing/api/lib/is-subscription-cancelled", () => ({
isSubscriptionCancelled: mocks.isSubscriptionCancelled,
}));
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
createScaleTrialSubscription: mocks.createScaleTrialSubscription,
ensureCloudStripeSetupForOrganization: mocks.ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization: mocks.ensureStripeCustomerForOrganization,
reconcileCloudStripeSubscriptionsForOrganization: mocks.reconcileCloudStripeSubscriptionsForOrganization,
syncOrganizationBillingFromStripe: mocks.syncOrganizationBillingFromStripe,
}));
vi.mock("@/modules/ee/billing/lib/stripe-client", () => ({
stripeClient: {
customerSessions: {
create: mocks.stripeCustomerSessionsCreate,
},
},
}));
describe("billing actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
mocks.getOrganization.mockResolvedValue({
id: "org_1",
billing: {
stripeCustomerId: null,
},
});
mocks.ensureStripeCustomerForOrganization.mockResolvedValue({ customerId: "cus_1" });
mocks.createScaleTrialSubscription.mockResolvedValue(undefined);
mocks.reconcileCloudStripeSubscriptionsForOrganization.mockResolvedValue(undefined);
mocks.syncOrganizationBillingFromStripe.mockResolvedValue(undefined);
});
test("stayOnHobbyAction ensures a customer, reconciles hobby, and syncs billing", async () => {
const result = await stayOnHobbyAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"stay-on-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" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createScaleTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"scale-trial"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
});

View File

@@ -14,6 +14,7 @@ import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscri
import {
createScaleTrialSubscription,
ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization,
reconcileCloudStripeSubscriptionsForOrganization,
syncOrganizationBillingFromStripe,
} from "@/modules/ee/billing/lib/organization-billing";
@@ -149,6 +150,35 @@ const ZStartScaleTrialAction = z.object({
organizationId: ZId,
});
export const stayOnHobbyAction = authenticatedActionClient
.inputSchema(ZStartScaleTrialAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(parsedInput.organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
}
const { customerId } = await ensureStripeCustomerForOrganization(parsedInput.organizationId);
if (!customerId) {
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "stay-on-hobby");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
export const startScaleTrialAction = authenticatedActionClient
.inputSchema(ZStartScaleTrialAction)
.action(async ({ ctx, parsedInput }) => {
@@ -168,11 +198,12 @@ export const startScaleTrialAction = authenticatedActionClient
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
}
if (!organization.billing?.stripeCustomerId) {
const { customerId } = await ensureStripeCustomerForOrganization(parsedInput.organizationId);
if (!customerId) {
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await createScaleTrialSubscription(parsedInput.organizationId, organization.billing.stripeCustomerId);
await createScaleTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "scale-trial");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };

View File

@@ -11,7 +11,7 @@ import ethereumLogo from "@/images/customer-logos/ethereum-logo.png";
import flixbusLogo from "@/images/customer-logos/flixbus-white.svg";
import githubLogo from "@/images/customer-logos/github-logo.png";
import siemensLogo from "@/images/customer-logos/siemens.png";
import { startScaleTrialAction } from "@/modules/ee/billing/actions";
import { startScaleTrialAction, stayOnHobbyAction } from "@/modules/ee/billing/actions";
import { Button } from "@/modules/ui/components/button";
interface SelectPlanCardProps {
@@ -31,6 +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 { t } = useTranslation();
const TRIAL_FEATURE_KEYS = [
@@ -60,8 +61,20 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
}
};
const handleContinueFree = () => {
router.push(nextUrl);
const handleContinueFree = async () => {
setIsStayingOnHobby(true);
try {
const result = await stayOnHobbyAction({ organizationId });
if (result?.data) {
router.push(nextUrl);
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
setIsStayingOnHobby(false);
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
setIsStayingOnHobby(false);
}
};
return (
@@ -94,7 +107,7 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
onClick={handleStartTrial}
className="mt-4 w-full"
loading={isStartingTrial}
disabled={isStartingTrial}>
disabled={isStartingTrial || isStayingOnHobby}>
{t("common.start_free_trial")}
</Button>
</div>
@@ -121,8 +134,9 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
<button
onClick={handleContinueFree}
disabled={isStartingTrial || isStayingOnHobby}
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline">
{t("environments.settings.billing.stay_on_hobby_plan")}
{isStayingOnHobby ? t("common.loading") : t("environments.settings.billing.stay_on_hobby_plan")}
</button>
</div>
);

View File

@@ -278,6 +278,40 @@ describe("organization-billing", () => {
expect(mocks.subscriptionsList).not.toHaveBeenCalled();
});
test("syncOrganizationBillingFromStripe stores hobby plan when customer has no active subscription", async () => {
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_1",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: { lastSyncedEventId: null },
});
mocks.subscriptionsList.mockResolvedValue({ data: [] });
mocks.entitlementsList.mockResolvedValue({ data: [], has_more: false });
const result = await syncOrganizationBillingFromStripe("org_1");
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
data: expect.objectContaining({
stripeCustomerId: "cus_1",
stripe: expect.objectContaining({
plan: "hobby",
subscriptionStatus: null,
subscriptionId: null,
features: [],
lastSyncedAt: expect.any(String),
}),
}),
});
expect(result?.stripe?.plan).toBe("hobby");
expect(result?.stripe?.subscriptionStatus).toBeNull();
});
test("syncOrganizationBillingFromStripe ignores duplicate webhook events", async () => {
const billing = {
stripeCustomerId: "cus_1",
@@ -742,81 +776,50 @@ describe("organization-billing", () => {
expect(mocks.prismaOrganizationFindUnique).not.toHaveBeenCalled();
});
test("ensureCloudStripeSetupForOrganization provisions hobby subscription when org has no active subscription", async () => {
test("ensureCloudStripeSetupForOrganization creates customer and syncs billing without provisioning hobby", async () => {
mocks.prismaOrganizationFindUnique.mockResolvedValueOnce({
id: "org_1",
name: "Org 1",
});
// ensureStripeCustomerForOrganization no longer reads billing;
// reconcile and sync each read billing once
mocks.prismaOrganizationBillingFindUnique
.mockResolvedValueOnce({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
mocks.prismaMembershipFindFirst.mockResolvedValue({
user: { email: "owner@example.com", name: "Owner Name" },
});
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
usageCycleAnchor: new Date(),
stripe: {},
})
.mockResolvedValueOnce({
stripeCustomerId: "cus_new",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: {},
});
},
usageCycleAnchor: new Date(),
stripe: {},
});
mocks.customersCreate.mockResolvedValue({ id: "cus_new" });
mocks.subscriptionsList
.mockResolvedValueOnce({ data: [] }) // reconciliation initial list (status: "all")
.mockResolvedValueOnce({ data: [] }) // fresh re-check before hobby creation (status: "active")
.mockResolvedValueOnce({
// sync reads subscriptions after hobby is created
data: [
{
id: "sub_hobby",
created: 1739923200,
status: "active",
billing_cycle_anchor: 1739923200,
items: {
data: [
{
price: {
product: { id: "prod_hobby" },
recurring: { usage_type: "licensed", interval: "month" },
},
},
],
},
},
],
});
mocks.subscriptionsList.mockResolvedValue({ data: [] });
await ensureCloudStripeSetupForOrganization("org_1");
expect(mocks.productsList).toHaveBeenCalledWith({
active: true,
limit: 100,
});
expect(mocks.pricesList).toHaveBeenCalledWith({
product: "prod_hobby",
active: true,
limit: 100,
});
expect(mocks.subscriptionsCreate).toHaveBeenCalledWith(
expect(mocks.customersCreate).toHaveBeenCalledWith(
{
customer: "cus_new",
items: [{ price: "price_hobby_1", quantity: 1 }],
metadata: { organizationId: "org_1" },
name: "Owner Name",
email: "owner@example.com",
metadata: { organizationId: "org_1", organizationName: "Org 1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
{ idempotencyKey: "ensure-customer-org_1" }
);
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
data: expect.objectContaining({
stripeCustomerId: "cus_new",
stripe: expect.objectContaining({
plan: "hobby",
subscriptionStatus: null,
subscriptionId: null,
}),
}),
});
});
test("reconcileCloudStripeSubscriptionsForOrganization cancels hobby when paid subscription is active", async () => {

View File

@@ -804,6 +804,5 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await syncOrganizationBillingFromStripe(organizationId);
};