Skip to content

Commit

Permalink
Get updateQuantity working
Browse files Browse the repository at this point in the history
  • Loading branch information
Hariom Balhara committed Feb 15, 2025
1 parent da5c31c commit 23b6027
Show file tree
Hide file tree
Showing 9 changed files with 133 additions and 19 deletions.
5 changes: 5 additions & 0 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion packages/features/ee/billing/api/webhook/_invoice.paid.org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
);
Expand All @@ -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}`);
Expand Down
17 changes: 14 additions & 3 deletions packages/features/ee/billing/teams/internal-team-billing.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"] });

Expand Down Expand Up @@ -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
Expand Down
19 changes: 11 additions & 8 deletions packages/features/ee/organizations/lib/onboardingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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<OnboardingStoreState>()(
Expand Down Expand Up @@ -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;
};
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -293,13 +293,68 @@ 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.
*/
export const createOrganizationFromOnboarding = async ({
organizationOnboarding,
paymentSubscriptionId,
paymentSubscriptionItemId,
}: {
organizationOnboarding: Pick<
OrganizationOnboarding,
Expand All @@ -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");
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion packages/lib/server/repository/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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<typeof teamMetadataSchema> }) {
return await prisma.team.update({
where: { id, isOrganization: true },
data: {
metadata: {
...existingMetadata,
subscriptionId: stripeSubscriptionId,
subscriptionItemId: stripeSubscriptionItemId,
}
},
});
}
}
10 changes: 10 additions & 0 deletions packages/lib/server/repository/organizationOnboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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("[]")
Expand Down

0 comments on commit 23b6027

Please sign in to comment.