mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-13 19:30:36 -05:00
fix: defer hobby subscription creation
This commit is contained in:
128
apps/web/modules/ee/billing/actions.test.ts
Normal file
128
apps/web/modules/ee/billing/actions.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user