fix: add missing Stripe billing setup for setup route org creation (#7470)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dhruwang Jariwala
2026-03-13 18:48:01 +05:30
committed by GitHub
parent 84c668be86
commit 91be2af30b
6 changed files with 123 additions and 47 deletions

View File

@@ -1,12 +1,15 @@
"use server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { gethasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
const ZCreateOrganizationAction = z.object({
@@ -33,6 +36,16 @@ export const createOrganizationAction = authenticatedActionClient
accepted: true,
});
// Stripe setup must run AFTER membership is created so the owner email is available
if (IS_FORMBRICKS_CLOUD) {
ensureCloudStripeSetupForOrganization(newOrganization.id).catch((error) => {
logger.error(
{ error, organizationId: newOrganization.id },
"Stripe setup failed after organization creation"
);
});
}
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;

View File

@@ -308,12 +308,7 @@ export const deleteOrganization = async (organizationId: string) => {
const stripeCustomerId = deletedOrganization.billing?.stripeCustomerId;
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
cleanupStripeCustomer(stripeCustomerId).catch((error) => {
logger.error(
{ error, organizationId, stripeCustomerId },
"Failed to clean up Stripe customer after organization deletion"
);
});
await cleanupStripeCustomer(stripeCustomerId);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -35,6 +35,7 @@ const mocks = vi.hoisted(() => ({
customersUpdate: vi.fn(),
prismaMembershipFindFirst: vi.fn(),
loggerInfo: vi.fn(),
loggerError: vi.fn(),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
@@ -83,6 +84,7 @@ vi.mock("@formbricks/logger", () => ({
logger: {
warn: mocks.loggerWarn,
info: mocks.loggerInfo,
error: mocks.loggerError,
},
}));
@@ -181,7 +183,7 @@ describe("organization-billing", () => {
name: "Org 1",
});
mocks.prismaMembershipFindFirst.mockResolvedValue({
user: { email: "owner@example.com" },
user: { email: "owner@example.com", name: "Owner Name" },
});
mocks.customersList.mockResolvedValue({
data: [{ id: "cus_existing", deleted: false }],
@@ -193,8 +195,8 @@ describe("organization-billing", () => {
expect(result).toEqual({ customerId: "cus_existing" });
expect(mocks.customersCreate).not.toHaveBeenCalled();
expect(mocks.customersUpdate).toHaveBeenCalledWith("cus_existing", {
name: "Org 1",
metadata: { organizationId: "org_1" },
name: "Owner Name",
metadata: { organizationId: "org_1", organizationName: "Org 1" },
});
expect(mocks.prismaOrganizationBillingUpsert).toHaveBeenCalledWith(
expect.objectContaining({
@@ -210,6 +212,9 @@ describe("organization-billing", () => {
id: "org_1",
name: "Org 1",
});
mocks.prismaMembershipFindFirst.mockResolvedValue({
user: { email: "owner@example.com", name: "Owner Name" },
});
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: null,
limits: {
@@ -228,8 +233,9 @@ describe("organization-billing", () => {
expect(result).toEqual({ customerId: "cus_new" });
expect(mocks.customersCreate).toHaveBeenCalledWith(
{
name: "Org 1",
metadata: { organizationId: "org_1" },
name: "Owner Name",
email: "owner@example.com",
metadata: { organizationId: "org_1", organizationName: "Org 1" },
},
{ idempotencyKey: "ensure-customer-org_1" }
);
@@ -767,26 +773,30 @@ describe("organization-billing", () => {
stripe: {},
});
mocks.customersCreate.mockResolvedValue({ id: "cus_new" });
mocks.subscriptionsList.mockResolvedValueOnce({ data: [] }).mockResolvedValueOnce({
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
.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" },
},
},
},
],
],
},
},
},
],
});
],
});
await ensureCloudStripeSetupForOrganization("org_1");

View File

@@ -34,7 +34,7 @@ export const invalidateOrganizationBillingCache = async (organizationId: string)
await cache.del([getBillingCacheKey(organizationId)]);
};
const getDefaultOrganizationBilling = (): TOrganizationBilling => ({
export const getDefaultOrganizationBilling = (): TOrganizationBilling => ({
limits: {
projects: IS_FORMBRICKS_CLOUD ? 1 : 3,
monthly: {
@@ -440,12 +440,15 @@ const ensureOrganizationBillingRecord = async (
* Finds the email of the organization owner by looking up the membership with role "owner"
* and joining to the user table.
*/
const getOrganizationOwnerEmail = async (organizationId: string): Promise<string | null> => {
const getOrganizationOwner = async (
organizationId: string
): Promise<{ email: string; name: string | null } | null> => {
const membership = await prisma.membership.findFirst({
where: { organizationId, role: "owner" },
select: { user: { select: { email: true } } },
select: { user: { select: { email: true, name: true } } },
});
return membership?.user.email ?? null;
if (!membership) return null;
return { email: membership.user.email, name: membership.user.name };
};
/**
@@ -483,10 +486,16 @@ export const ensureStripeCustomerForOrganization = async (
return { customerId: null };
}
// Look up the org owner's email and check if a Stripe customer already exists for it.
// Look up the org owner's email/name and check if a Stripe customer already exists for it.
// This reuses the old customer (and its trial history) when a user deletes their account
// and signs up again with the same email.
const ownerEmail = await getOrganizationOwnerEmail(organization.id);
const owner = await getOrganizationOwner(organization.id);
if (!owner) {
logger.error({ organizationId }, "Cannot set up Stripe customer: organization has no owner");
return { customerId: null };
}
const { email: ownerEmail, name: ownerName } = owner;
let existingCustomer: Stripe.Customer | null = null;
if (ownerEmail) {
@@ -497,8 +506,8 @@ export const ensureStripeCustomerForOrganization = async (
if (!existingBillingOwner || existingBillingOwner === organizationId) {
existingCustomer = foundCustomer;
await stripeClient.customers.update(existingCustomer.id, {
name: organization.name,
metadata: { organizationId: organization.id },
name: ownerName ?? undefined,
metadata: { organizationId: organization.id, organizationName: organization.name },
});
logger.info(
{ organizationId, customerId: existingCustomer.id, email: ownerEmail },
@@ -512,9 +521,9 @@ export const ensureStripeCustomerForOrganization = async (
existingCustomer ??
(await stripeClient.customers.create(
{
name: organization.name,
email: ownerEmail ?? undefined,
metadata: { organizationId: organization.id },
name: ownerName ?? undefined,
email: ownerEmail,
metadata: { organizationId: organization.id, organizationName: organization.name },
},
{ idempotencyKey: `ensure-customer-${organization.id}` }
));
@@ -778,7 +787,17 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
}
if (subscriptionsWithPlanLevel.length === 0) {
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
// Re-check active subscriptions to guard against concurrent reconciliation calls
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
const freshSubscriptions = await client.subscriptions.list({
customer: customerId,
status: "active",
limit: 1,
});
if (freshSubscriptions.data.length === 0) {
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
}
}
};

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import type { TOrganizationBilling } from "@formbricks/types/organizations";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
@@ -7,8 +6,17 @@ import { getCloudOrganizationEntitlementsContext } from "./cloud-provider";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/logger", () => ({
logger: { warn: vi.fn() },
}));
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
getOrganizationBillingWithReadThroughSync: vi.fn(),
getDefaultOrganizationBilling: () => ({
limits: { projects: 1, monthly: { responses: 250 } },
stripeCustomerId: null,
usageCycleAnchor: null,
}),
}));
vi.mock("@/modules/ee/license-check/lib/license", () => ({
@@ -35,11 +43,23 @@ beforeEach(() => {
});
describe("getCloudOrganizationEntitlementsContext", () => {
test("throws ResourceNotFoundError when billing is null", async () => {
test("returns default entitlements when billing is null", async () => {
mockGetBilling.mockResolvedValue(null);
mockGetLicense.mockResolvedValue({ status: "no-license", features: null, active: false });
await expect(getCloudOrganizationEntitlementsContext("org1")).rejects.toThrow(ResourceNotFoundError);
const result = await getCloudOrganizationEntitlementsContext("org1");
expect(result).toEqual({
organizationId: "org1",
source: "cloud_stripe",
features: [],
limits: { projects: 1, monthlyResponses: 250 },
licenseStatus: "no-license",
licenseFeatures: null,
stripeCustomerId: null,
subscriptionStatus: null,
usageCycleAnchor: null,
});
});
test("returns context with billing data", async () => {

View File

@@ -1,6 +1,9 @@
import "server-only";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { logger } from "@formbricks/logger";
import {
getDefaultOrganizationBilling,
getOrganizationBillingWithReadThroughSync,
} from "@/modules/ee/billing/lib/organization-billing";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { type TOrganizationEntitlementsContext, isEntitlementFeature } from "./types";
@@ -19,7 +22,23 @@ export const getCloudOrganizationEntitlementsContext = async (
]);
if (!billing) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
logger.warn({ organizationId }, "Organization billing not found, using default entitlements");
const defaultBilling = getDefaultOrganizationBilling();
return {
organizationId,
source: "cloud_stripe",
features: [],
limits: {
projects: defaultBilling.limits?.projects ?? null,
monthlyResponses: defaultBilling.limits?.monthly?.responses ?? null,
},
licenseStatus: license.status,
licenseFeatures: license.features,
stripeCustomerId: null,
subscriptionStatus: null,
usageCycleAnchor: null,
};
}
return {