From 23b602708c0eaf31a225f8c95f54e5101805084e Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Sat, 15 Feb 2025 17:40:06 +0530 Subject: [PATCH] Get updateQuantity working --- apps/web/next.config.js | 5 ++ .../billing/api/webhook/_invoice.paid.org.ts | 11 ++- .../ee/billing/teams/internal-team-billing.ts | 17 ++++- .../ee/organizations/lib/onboardingStore.ts | 19 ++--- .../createOrganizationFromOnboarding.ts | 70 +++++++++++++++++-- .../lib/server/repository/organization.ts | 18 ++++- .../repository/organizationOnboarding.ts | 10 +++ .../migration.sql | 1 + packages/prisma/schema.prisma | 1 + 9 files changed, 133 insertions(+), 19 deletions(-) rename packages/prisma/migrations/{20250215101255_add_org_onboarding => 20250215120654_add_org_onboarding}/migration.sql (98%) diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 4ce4cbeafc2232..d34c4688c15dd2 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -536,6 +536,11 @@ const nextConfig = { }, async redirects() { const redirects = [ + { + source: "/settings/organizations", + destination: "/settings/organizations/profile", + permanent: false, + }, { source: "/apps/routing-forms", destination: "/routing/forms", diff --git a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts index 154a9dc132a605..ff507fbb6e5028 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts @@ -11,11 +11,19 @@ const invoicePaidSchema = z.object({ object: z.object({ customer: z.string(), subscription: z.string(), + lines: z.object({ + data: z.array(z.object({ + subscription_item: z.string(), + })), + }), }), }); + const handler = async (data: SWHMap["invoice.paid"]["data"]) => { const { object: invoice } = invoicePaidSchema.parse(data); + const subscriptionItemId = invoice.lines.data[0]?.subscription_item; + const subscriptionId = invoice.subscription; logger.debug( `Processing invoice paid webhook for customer ${invoice.customer} and subscription ${invoice.subscription}` ); @@ -38,7 +46,8 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => { try { const { organization } = await createOrganizationFromOnboarding({ organizationOnboarding, - paymentSubscriptionId: invoice.subscription, + paymentSubscriptionId: subscriptionId, + paymentSubscriptionItemId: subscriptionItemId, }); logger.debug(`Marking onboarding as complete for organization ${organization.id}`); diff --git a/packages/features/ee/billing/teams/internal-team-billing.ts b/packages/features/ee/billing/teams/internal-team-billing.ts index 18beff9ead2f24..def61c2ec37e30 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.ts @@ -1,6 +1,6 @@ import type { Prisma } from "@prisma/client"; import type { z } from "zod"; - +import { safeStringify } from "@calcom/lib/safeStringify"; import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { MINIMUM_NUMBER_OF_ORG_SEATS, WEBAPP_URL } from "@calcom/lib/constants"; @@ -12,6 +12,7 @@ import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import billing from ".."; import { TeamBillingPublishResponseStatus, type TeamBilling, type TeamBillingInput } from "./team-billing"; +import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; const log = logger.getSubLogger({ prefix: ["TeamBilling"] }); @@ -120,13 +121,23 @@ export class InternalTeamBilling implements TeamBilling { async updateQuantity() { try { await this.getOrgIfNeeded(); + const { id: teamId, metadata, isOrganization } = this.team; + const { url } = await this.checkIfTeamPaymentRequired(); + const organizationOnboarding = await OrganizationOnboardingRepository.findByOrganizationId(this.team.id); + log.debug("updateQuantity", safeStringify({ url, team: this.team })); + /** * If there's no pending checkout URL it means that this team has not been paid. * We cannot update the subscription yet, this will be handled on publish/checkout. + * + * An organization can only be created if it is paid for and updateQuantity is called only when we have an organization. + * For some old organizations, it is possible that they aren't paid for yet, but then they wouldn't have been published as well(i.e. slug would be null and are unusable) + * So, we can safely assume go forward for organizations. **/ - if (!url) return; - const { id: teamId, metadata, isOrganization } = this.team; + if (!url && !isOrganization) return; + + // TODO: To be read from organizationOnboarding for Organizations later, but considering the fact that certain old organization won't have onboarding const { subscriptionId, subscriptionItemId, orgSeats } = metadata; // Either it would be custom pricing where minimum number of seats are changed(available in orgSeats) or it would be default MINIMUM_NUMBER_OF_ORG_SEATS // We can't go below this quantity for subscription diff --git a/packages/features/ee/organizations/lib/onboardingStore.ts b/packages/features/ee/organizations/lib/onboardingStore.ts index 77061511e25bea..0fd5ae486237ac 100644 --- a/packages/features/ee/organizations/lib/onboardingStore.ts +++ b/packages/features/ee/organizations/lib/onboardingStore.ts @@ -69,6 +69,15 @@ export const useSetOnboardingIdFromParam = ({ step }: { step: "start" | "status" state.onboardingId, state.setOnboardingId, ]); + const requireOnboardingIdInStore = step !== "start" && step !== "status"; + + useEffect(() => { + const onboardingId = onboardingIdFromParams || onboardingIdFromStore; + if (!onboardingId && requireOnboardingIdInStore) { + console.warn("No onboardingId found in store, redirecting to /settings/organizations/new"); + router.push("/settings/organizations/new"); + } + }, [onboardingIdFromStore, requireOnboardingIdInStore, router]); const onboardingIdFromParams = searchParams?.get("onboardingId"); @@ -78,14 +87,8 @@ export const useSetOnboardingIdFromParam = ({ step }: { step: "start" | "status" return; } - const requireOnboardingIdInStore = step !== "start" && step !== "status"; - useEffect(() => { - if (!onboardingIdFromStore && requireOnboardingIdInStore) { - console.warn("No onboardingId found in store, redirecting to /settings/organizations/new"); - router.push("/settings/organizations/new"); - } - }, [onboardingIdFromStore, requireOnboardingIdInStore, router]); + }; export const useOnboardingStore = create()( @@ -140,6 +143,6 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) => const searchString = !searchParams ? "" : `${searchParams.toString()}`; router.push(`/auth/login?callbackUrl=${WEBAPP_URL}${path}${searchString ? `?${searchString}` : ""}`); } - }, [session, router, path, searchParams]); + }, [session, router, path]); return useOnboardingStore; }; diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts index 4ea959f64c7faa..9d1a338247f2a3 100644 --- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts +++ b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts @@ -1,5 +1,5 @@ import { z } from "zod"; - +import type { Prisma } from "@calcom/prisma/client"; import { sendOrganizationCreationEmail } from "@calcom/emails/email-manager"; import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; @@ -23,7 +23,7 @@ import { MembershipRole, CreationSource } from "@calcom/prisma/enums"; import { userMetadata } from "@calcom/prisma/zod-utils"; import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler"; import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"; - +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; // Onboarding can only be done from webapp currently and so we consider the source for User as WEBAPP const creationSource = CreationSource.WEBAPP; type OrganizationOnboardingId = string; @@ -293,6 +293,60 @@ async function ensureStripeCustomerIdIsUpdated({ }); } + +/** + * Temporary till we adapt all other code reading from metadata about stripeSubscriptionId and stripeSubscriptionItemId + */ +async function backwardCompatibilityForSubscriptionDetails({ + organization, + paymentSubscriptionId, + paymentSubscriptionItemId, +}: { + organization: { + id: number; + metadata: Prisma.JsonValue; + } + paymentSubscriptionId: string; + paymentSubscriptionItemId: string; +}) { + const existingMetadata = teamMetadataSchema.parse(organization.metadata); + await OrganizationRepository.updateStripeSubscriptionDetails({ + id: organization.id, + stripeSubscriptionId: paymentSubscriptionId, + stripeSubscriptionItemId: paymentSubscriptionItemId, + existingMetadata, + }); +} + +async function updateSubscriptionDetails({ + organization , + paymentSubscriptionId, + paymentSubscriptionItemId, + organizationOnboardingId, +}: { + organization: { + id: number; + metadata: Prisma.JsonValue; + } + organizationOnboardingId: string; + paymentSubscriptionId: string; + paymentSubscriptionItemId: string; +}) { + // Connect the organization onboarding to the organization so that for further attempts after a failed update, we can use the organizationId itself from the onboarding. + await OrganizationOnboardingRepository.update(organizationOnboardingId, { + organizationId: organization.id, + stripeSubscriptionId: paymentSubscriptionId, + stripeSubscriptionItemId: paymentSubscriptionItemId, + }); + + + await backwardCompatibilityForSubscriptionDetails({ + organization, + paymentSubscriptionId, + paymentSubscriptionItemId, + }); +} + /** * This function is used by stripe webhook, so it should expect to be called multiple times till the entire flow completes without any error. * So, it should be idempotent. @@ -300,6 +354,7 @@ async function ensureStripeCustomerIdIsUpdated({ export const createOrganizationFromOnboarding = async ({ organizationOnboarding, paymentSubscriptionId, + paymentSubscriptionItemId, }: { organizationOnboarding: Pick< OrganizationOnboarding, @@ -320,6 +375,7 @@ export const createOrganizationFromOnboarding = async ({ | "isDomainConfigured" >; paymentSubscriptionId: string; + paymentSubscriptionItemId: string; }) => { let owner = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail); const orgOwnerTranslation = await getTranslation(owner?.locale || "en", "common"); @@ -373,16 +429,18 @@ export const createOrganizationFromOnboarding = async ({ } if (organizationOnboarding.stripeCustomerId) { + // Mostly needed for newly created user through the flow await ensureStripeCustomerIdIsUpdated({ owner, stripeCustomerId: organizationOnboarding.stripeCustomerId, }); } - // Connect the organization onboarding to the organization so that for further attempts after a failed update, we can use the organizationId itself from the onboarding. - await OrganizationOnboardingRepository.update(organizationOnboarding.id, { - organizationId: organization.id, - stripeSubscriptionId: paymentSubscriptionId, + await updateSubscriptionDetails({ + organizationOnboardingId: organizationOnboarding.id, + paymentSubscriptionId, + paymentSubscriptionItemId, + organization, }); const invitedMembers = z diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index 93d4abedc62bb4..9e6f8badd7e800 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -5,7 +5,7 @@ import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import type { CreationSource } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; - +import { z } from "zod"; import { createAProfileForAnExistingUser } from "../../createAProfileForAnExistingUser"; import { getParsedTeam } from "./teamUtils"; import { UserRepository } from "./user"; @@ -155,6 +155,9 @@ export class OrganizationRepository { }, metadata: { isPlatform: orgData.isPlatform, + orgSeats: orgData.seats, + orgPricePerSeat: orgData.pricePerSeat, + billingPeriod: orgData.billingPeriod, // We set it here as required by various places in app. We could plan to move it to OrganizationOnboarding later subscriptionId: orgData.paymentSubscriptionId, }, @@ -405,4 +408,17 @@ export class OrganizationRepository { data: { slug }, }); } + + static async updateStripeSubscriptionDetails({ id, stripeSubscriptionId, stripeSubscriptionItemId, existingMetadata }: { id: number; stripeSubscriptionId: string; stripeSubscriptionItemId: string; existingMetadata: z.infer }) { + return await prisma.team.update({ + where: { id, isOrganization: true }, + data: { + metadata: { + ...existingMetadata, + subscriptionId: stripeSubscriptionId, + subscriptionItemId: stripeSubscriptionItemId, + } + }, + }); + } } diff --git a/packages/lib/server/repository/organizationOnboarding.ts b/packages/lib/server/repository/organizationOnboarding.ts index 6b84706c775ebf..f55188ac3ab8bb 100644 --- a/packages/lib/server/repository/organizationOnboarding.ts +++ b/packages/lib/server/repository/organizationOnboarding.ts @@ -32,6 +32,7 @@ export type CreateOrganizationOnboardingInput = { bio?: string | null; stripeCustomerId?: string; stripeSubscriptionId?: string; + stripeSubscriptionItemId?: string; invitedMembers?: { email: string; name?: string }[]; teams?: { id: number; name: string; isBeingMigrated: boolean; slug: string | null }[]; error?: string | null; @@ -134,6 +135,15 @@ export class OrganizationOnboardingRepository { }); } + static async findByOrganizationId(organizationId: number) { + logger.debug("Finding organization onboarding by organization id", safeStringify({ organizationId })); + return await prisma.organizationOnboarding.findUnique({ + where: { + organizationId, + }, + }); + } + static async delete(id: OnboardingId) { logger.debug("Deleting organization onboarding", { id }); return await prisma.organizationOnboarding.delete({ diff --git a/packages/prisma/migrations/20250215101255_add_org_onboarding/migration.sql b/packages/prisma/migrations/20250215120654_add_org_onboarding/migration.sql similarity index 98% rename from packages/prisma/migrations/20250215101255_add_org_onboarding/migration.sql rename to packages/prisma/migrations/20250215120654_add_org_onboarding/migration.sql index 7355dbe5243ee4..f505ec6432286b 100644 --- a/packages/prisma/migrations/20250215101255_add_org_onboarding/migration.sql +++ b/packages/prisma/migrations/20250215120654_add_org_onboarding/migration.sql @@ -21,6 +21,7 @@ CREATE TABLE "OrganizationOnboarding" ( "isDomainConfigured" BOOLEAN NOT NULL DEFAULT false, "stripeCustomerId" TEXT, "stripeSubscriptionId" TEXT, + "stripeSubscriptionItemId" TEXT, "invitedMembers" JSONB NOT NULL DEFAULT '[]', "teams" JSONB NOT NULL DEFAULT '[]', "isComplete" BOOLEAN NOT NULL DEFAULT false, diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index dbd704fbd4a280..5987d33055a817 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1878,6 +1878,7 @@ model OrganizationOnboarding { stripeCustomerId String? @unique // TODO: Can we make it required stripeSubscriptionId String? + stripeSubscriptionItemId String? // Invited members - stored as JSON invitedMembers Json @default("[]")