diff --git a/apps/web/app/setup/organization/create/actions.ts b/apps/web/app/setup/organization/create/actions.ts index 715270b77d..80080329e3 100644 --- a/apps/web/app/setup/organization/create/actions.ts +++ b/apps/web/app/setup/organization/create/actions.ts @@ -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; diff --git a/apps/web/lib/organization/service.ts b/apps/web/lib/organization/service.ts index 336a8da242..9af7947364 100644 --- a/apps/web/lib/organization/service.ts +++ b/apps/web/lib/organization/service.ts @@ -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) { diff --git a/apps/web/modules/ee/billing/lib/organization-billing.test.ts b/apps/web/modules/ee/billing/lib/organization-billing.test.ts index 7d45e848c1..784c5a8b07 100644 --- a/apps/web/modules/ee/billing/lib/organization-billing.test.ts +++ b/apps/web/modules/ee/billing/lib/organization-billing.test.ts @@ -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"); diff --git a/apps/web/modules/ee/billing/lib/organization-billing.ts b/apps/web/modules/ee/billing/lib/organization-billing.ts index 5689402cab..371deb6fc6 100644 --- a/apps/web/modules/ee/billing/lib/organization-billing.ts +++ b/apps/web/modules/ee/billing/lib/organization-billing.ts @@ -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 => { +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); + } } }; diff --git a/apps/web/modules/entitlements/lib/cloud-provider.test.ts b/apps/web/modules/entitlements/lib/cloud-provider.test.ts index 05f725b236..846b7bb683 100644 --- a/apps/web/modules/entitlements/lib/cloud-provider.test.ts +++ b/apps/web/modules/entitlements/lib/cloud-provider.test.ts @@ -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 () => { diff --git a/apps/web/modules/entitlements/lib/cloud-provider.ts b/apps/web/modules/entitlements/lib/cloud-provider.ts index 63de40bc99..86b6e0f67e 100644 --- a/apps/web/modules/entitlements/lib/cloud-provider.ts +++ b/apps/web/modules/entitlements/lib/cloud-provider.ts @@ -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 {