mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-13 19:30:36 -05:00
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:
committed by
GitHub
parent
84c668be86
commit
91be2af30b
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user