mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
f4abbb2de1
* factory and statergie * chore: use correct method of DI * feat: add onchagne * add logic to HWM stat * add webhook resolver methods to each statergy * move seat tracking + webhooks over to own statergy * Move to factory base approach * move logic to correct class * rename create -> createByTeamId * fix: remove debug `true ||` overrides from IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED Remove accidentally committed debug overrides that short-circuited IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED to always be true, bypassing Stripe credential checks. This would break self-hosted instances without Stripe configured. Identified by cubic (https://cubic.dev) Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
234 lines
9.5 KiB
TypeScript
234 lines
9.5 KiB
TypeScript
import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
|
|
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
|
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
|
|
import logger from "@calcom/lib/logger";
|
|
import { Redirect } from "@calcom/lib/redirect";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import { prisma } from "@calcom/prisma";
|
|
import type { Prisma } from "@calcom/prisma/client";
|
|
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
|
|
import type { z } from "zod";
|
|
import type {
|
|
IBillingRepository,
|
|
IBillingRepositoryCreateArgs,
|
|
} from "../../repository/billing/IBillingRepository";
|
|
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
|
|
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
|
|
import type { SeatChangeType } from "../seatBillingStrategy/ISeatBillingStrategy";
|
|
import type { SeatBillingStrategyFactory } from "../seatBillingStrategy/SeatBillingStrategyFactory";
|
|
import {
|
|
type ITeamBillingService,
|
|
type TeamBillingInput,
|
|
TeamBillingPublishResponseStatus,
|
|
} from "./ITeamBillingService";
|
|
|
|
const log = logger.getSubLogger({ prefix: ["TeamBilling"] });
|
|
|
|
const teamPaymentMetadataSchema = teamMetadataStrictSchema.unwrap();
|
|
|
|
export class TeamBillingService implements ITeamBillingService {
|
|
private _team!: Omit<TeamBillingInput, "metadata"> & {
|
|
metadata: NonNullable<z.infer<typeof teamPaymentMetadataSchema>>;
|
|
};
|
|
private billingProviderService: IBillingProviderService;
|
|
private billingRepository: IBillingRepository;
|
|
private teamBillingDataRepository: ITeamBillingDataRepository;
|
|
private seatBillingStrategyFactory: SeatBillingStrategyFactory;
|
|
|
|
constructor({
|
|
team,
|
|
billingProviderService,
|
|
teamBillingDataRepository,
|
|
billingRepository,
|
|
seatBillingStrategyFactory,
|
|
}: {
|
|
team: TeamBillingInput;
|
|
billingProviderService: IBillingProviderService;
|
|
teamBillingDataRepository: ITeamBillingDataRepository;
|
|
billingRepository: IBillingRepository;
|
|
seatBillingStrategyFactory: SeatBillingStrategyFactory;
|
|
}) {
|
|
this.team = team;
|
|
this.billingProviderService = billingProviderService;
|
|
this.teamBillingDataRepository = teamBillingDataRepository;
|
|
this.billingRepository = billingRepository;
|
|
this.seatBillingStrategyFactory = seatBillingStrategyFactory;
|
|
}
|
|
set team(team: TeamBillingInput) {
|
|
const metadata = teamPaymentMetadataSchema.parse(team.metadata || {});
|
|
this._team = { ...team, metadata };
|
|
}
|
|
get team(): typeof this._team {
|
|
return this._team;
|
|
}
|
|
private async getOrgIfNeeded() {
|
|
if (!this.team.parentId) return;
|
|
const parentTeam = await this.teamBillingDataRepository.find(this.team.parentId);
|
|
this.team = parentTeam;
|
|
}
|
|
private logErrorFromUnknown(error: unknown) {
|
|
let message = "Unknown error on InternalTeamBilling.";
|
|
if (error instanceof Error) message = error.message;
|
|
log.error(message);
|
|
}
|
|
async cancel() {
|
|
try {
|
|
const { subscriptionId } = this.team.metadata;
|
|
log.info(`Cancelling subscription ${subscriptionId} for team ${this.team.id}`);
|
|
if (!subscriptionId) throw Error("missing subscriptionId");
|
|
await this.billingProviderService.handleSubscriptionCancel(subscriptionId);
|
|
await this.downgrade();
|
|
log.info(`Cancelled subscription ${subscriptionId} for team ${this.team.id}`);
|
|
} catch (error) {
|
|
this.logErrorFromUnknown(error);
|
|
}
|
|
}
|
|
// New teams are published on creation, this is for backwards compatibility
|
|
async publish() {
|
|
const { url } = await this.checkIfTeamPaymentRequired();
|
|
const teamId = this.team.id;
|
|
if (url) {
|
|
// TODO: We should probably hit the logic of this URL handled by the /upgrade API handler as it just generates the url to check the payment status and upgrade if needed
|
|
return { redirectUrl: url, status: TeamBillingPublishResponseStatus.REQUIRES_UPGRADE };
|
|
}
|
|
const requestedSlug = this.team.metadata?.requestedSlug || "";
|
|
// if payment needed, respond with checkout url
|
|
const membershipCount = await prisma.membership.count({ where: { teamId } });
|
|
const owner = await prisma.membership.findFirstOrThrow({
|
|
where: { teamId, role: "OWNER" },
|
|
select: {
|
|
userId: true,
|
|
},
|
|
});
|
|
|
|
try {
|
|
// TODO: extract this to new billing service
|
|
const checkoutSession = await purchaseTeamOrOrgSubscription({
|
|
teamId,
|
|
seatsUsed: membershipCount,
|
|
userId: owner.userId,
|
|
pricePerSeat: null,
|
|
});
|
|
|
|
if (checkoutSession.url) {
|
|
return {
|
|
redirectUrl: checkoutSession.url,
|
|
status: TeamBillingPublishResponseStatus.REQUIRES_PAYMENT,
|
|
};
|
|
}
|
|
|
|
const { mergeMetadata } = getMetadataHelpers(teamPaymentMetadataSchema, this.team.metadata);
|
|
const data: Prisma.TeamUpdateInput = {
|
|
metadata: mergeMetadata({ requestedSlug: undefined }),
|
|
};
|
|
if (requestedSlug) data.slug = requestedSlug;
|
|
await prisma.team.update({ where: { id: teamId }, data });
|
|
return { status: TeamBillingPublishResponseStatus.SUCCESS, redirectUrl: null };
|
|
} catch (error) {
|
|
if (error instanceof Redirect) throw error;
|
|
const { message } = getRequestedSlugError(error, requestedSlug);
|
|
throw Error(message);
|
|
}
|
|
}
|
|
async downgrade() {
|
|
try {
|
|
const { mergeMetadata } = getMetadataHelpers(teamPaymentMetadataSchema, this.team.metadata);
|
|
const metadata = mergeMetadata({
|
|
paymentId: undefined,
|
|
subscriptionId: undefined,
|
|
subscriptionItemId: undefined,
|
|
});
|
|
await prisma.team.update({ where: { id: this.team.id }, data: { metadata } });
|
|
log.info(`Downgraded team ${this.team.id}`);
|
|
} catch (error) {
|
|
this.logErrorFromUnknown(error);
|
|
}
|
|
}
|
|
async updateQuantity(changeType: SeatChangeType) {
|
|
try {
|
|
await this.getOrgIfNeeded();
|
|
const { id: teamId, metadata, isOrganization } = this.team;
|
|
|
|
const { url } = await this.checkIfTeamPaymentRequired();
|
|
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 && !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 } = metadata;
|
|
const membershipCount = await prisma.membership.count({ where: { teamId } });
|
|
if (!subscriptionId) throw Error("missing subscriptionId");
|
|
if (!subscriptionItemId) throw Error("missing subscriptionItemId");
|
|
|
|
const strategy = await this.seatBillingStrategyFactory.createByTeamId(teamId);
|
|
await strategy.onSeatChange({
|
|
teamId,
|
|
subscriptionId,
|
|
subscriptionItemId,
|
|
membershipCount,
|
|
changeType,
|
|
});
|
|
log.info(`Seat change processed for team ${teamId} (${membershipCount} seats).`);
|
|
} catch (error) {
|
|
this.logErrorFromUnknown(error);
|
|
}
|
|
}
|
|
/** Used to prevent double charges for the same team */
|
|
async checkIfTeamPaymentRequired() {
|
|
const { paymentId } = this.team.metadata || {};
|
|
/** If there's no paymentId, we need to pay this team */
|
|
if (!paymentId) return { url: null, paymentId: null, paymentRequired: true };
|
|
/** If there's a pending session but it isn't paid, we need to pay this team */
|
|
const checkoutSessionIsPaid = await this.billingProviderService.checkoutSessionIsPaid(paymentId);
|
|
if (!checkoutSessionIsPaid) return { url: null, paymentId, paymentRequired: true };
|
|
/** If the session is already paid we return the upgrade URL so team is updated. */
|
|
return {
|
|
url: `${WEBAPP_URL}/api/teams/${this.team.id}/upgrade?session_id=${paymentId}`,
|
|
paymentId,
|
|
paymentRequired: false,
|
|
};
|
|
}
|
|
/** Returns the subscription status (active, past_due, trialing, ...) */
|
|
async getSubscriptionStatus() {
|
|
const { subscriptionId } = this.team.metadata;
|
|
if (!subscriptionId) return null;
|
|
return this.billingProviderService.getSubscriptionStatus(subscriptionId);
|
|
}
|
|
|
|
/**
|
|
* Ends the trial period for a team subscription by converting it to a regular subscription
|
|
* @returns {Promise<boolean>} True if successful, false otherwise
|
|
*/
|
|
async endTrial() {
|
|
try {
|
|
const { subscriptionId } = this.team.metadata;
|
|
log.info(`Ending trial for subscription ${subscriptionId} of team ${this.team.id}`);
|
|
|
|
if (!subscriptionId) {
|
|
log.warn(`No subscription ID found for team ${this.team.id}`);
|
|
return false;
|
|
}
|
|
|
|
// End the trial by converting to regular subscription
|
|
await this.billingProviderService.handleEndTrial(subscriptionId);
|
|
log.info(`Successfully ended trial for team ${this.team.id}`);
|
|
return true;
|
|
} catch (error) {
|
|
this.logErrorFromUnknown(error);
|
|
return false;
|
|
}
|
|
}
|
|
async saveTeamBilling(args: IBillingRepositoryCreateArgs) {
|
|
await this.billingRepository.create(args);
|
|
}
|
|
}
|