* wip * fix: claue reveiew commentsd * add seed test * fix: seed test plus subscriptin undefined in webhook * chore: udpate triggr * Integration test + HWM seed script * add feature flag for hmw * Remove redudant docs * feat: add hwm-seating feature flag for high water mark billing - Add new hwm-seating feature flag to control HWM billing logic - Update HighWaterMarkService to use hwm-seating flag - Update SeatChangeTrackingService to use hwm-seating flag - Update BillingPeriodService to use hwm-seating flag - Add flag to migration SQL - Enable flag in seed script and integration test Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add hwm-seating to useFlags and fix test mocks - Add hwm-seating to useFlags.ts initial data - Add shouldApplyHighWaterMark mock to TeamBillingService tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix test * feat: move to invoice paid subscription * fix: transactions for transiants updates + reusable util * fix: address Cubic AI review feedback for HWM billing - Fix dotenv import hoisting in seed-hwm-test.ts by using side-effect import - Remove logging of sensitive information (password/emails) in seed script - Fix incorrect newHighWaterMark return value when update is not performed Co-Authored-By: unknown <> * add additional unit tests * improve DI * fix migration + restore package.json trigger update * feat:move to featurerepo+update tests * fix mocks * fix DI calls * revert faeturerepo to featuresrepository * fix: pass featureRepository to BillingPeriodService and HighWaterMarkService - Fix TeamBillingService.ts: pass featureRepository to BillingPeriodService constructor - Fix _invoice.upcoming.ts: pass featureRepository to HighWaterMarkService constructor - Fix hwm-webhook-utils.ts: pass featureRepository to HighWaterMarkService constructor These services require featureRepository for feature flag checks but production call sites were missing this required dependency, causing runtime errors. Addresses Cubic AI review feedback on PR #27559 Co-Authored-By: unknown <> * fix import * update feature repo usage to features * remove test thats mocking the wrong stuff * revert RegularBookingService changes * remove hide branding * remove change in redudant file --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
191 lines
7.3 KiB
TypeScript
191 lines
7.3 KiB
TypeScript
import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing";
|
|
import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils";
|
|
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/billing/IBillingRepository";
|
|
import { BillingEnabledOrgOnboardingService } from "@calcom/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService";
|
|
import stripe from "@calcom/features/ee/payments/server/stripe";
|
|
import { OrganizationOnboardingRepository } from "@calcom/features/organizations/repositories/OrganizationOnboardingRepository";
|
|
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import { prisma } from "@calcom/prisma";
|
|
import { z } from "zod";
|
|
import { getTeamBillingServiceFactory } from "../../di/containers/Billing";
|
|
import type { SWHMap } from "./__handler";
|
|
import { handleHwmResetAfterRenewal, validateInvoiceLinesForHwm } from "./hwm-webhook-utils";
|
|
|
|
const invoicePaidSchema = z.object({
|
|
object: z.object({
|
|
customer: z.string(),
|
|
subscription: z.string(),
|
|
billing_reason: z.string().nullable(),
|
|
lines: z.object({
|
|
data: z.array(
|
|
z.object({
|
|
subscription_item: z.string(),
|
|
period: z
|
|
.object({
|
|
start: z.number(),
|
|
end: z.number(),
|
|
})
|
|
.optional(),
|
|
})
|
|
),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
async function handlePaymentReceivedForOnboarding({
|
|
organizationOnboarding,
|
|
paymentSubscriptionId,
|
|
paymentSubscriptionItemId,
|
|
}: {
|
|
organizationOnboarding: { id: string };
|
|
paymentSubscriptionId: string;
|
|
paymentSubscriptionItemId: string;
|
|
}) {
|
|
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
|
|
stripeSubscriptionId: paymentSubscriptionId,
|
|
stripeSubscriptionItemId: paymentSubscriptionItemId,
|
|
});
|
|
}
|
|
|
|
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}`
|
|
);
|
|
|
|
const organizationOnboarding = await OrganizationOnboardingRepository.findByStripeCustomerId(
|
|
invoice.customer
|
|
);
|
|
|
|
if (!organizationOnboarding) {
|
|
// Invoice Paid is received for all organizations, even those that were created before Organization Onboarding was introduced.
|
|
// For renewals, we still need to reset the HWM
|
|
if (invoice.billing_reason === "subscription_cycle") {
|
|
logger.info(`Processing renewal invoice for subscription ${subscriptionId}`);
|
|
const validation = validateInvoiceLinesForHwm(invoice.lines.data, subscriptionId, logger);
|
|
if (validation.isValid) {
|
|
await handleHwmResetAfterRenewal(subscriptionId, validation.periodStart, logger);
|
|
}
|
|
} else {
|
|
logger.info(
|
|
`No onboarding record found for stripe customer id: ${invoice.customer}, Organization created before Organization Onboarding was introduced, so ignoring the webhook`
|
|
);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
};
|
|
}
|
|
|
|
const paymentSubscriptionId = subscriptionId;
|
|
const paymentSubscriptionItemId = subscriptionItemId;
|
|
|
|
await handlePaymentReceivedForOnboarding({
|
|
organizationOnboarding,
|
|
paymentSubscriptionId,
|
|
paymentSubscriptionItemId,
|
|
});
|
|
|
|
try {
|
|
logger.info(
|
|
safeStringify({
|
|
orgId: organizationOnboarding.organizationId,
|
|
orgSlug: organizationOnboarding.slug,
|
|
isDomainConfigured: organizationOnboarding.isDomainConfigured,
|
|
createdAt: organizationOnboarding.createdAt,
|
|
stripeSubscriptionId: organizationOnboarding.stripeSubscriptionId,
|
|
})
|
|
);
|
|
|
|
if (organizationOnboarding.isComplete) {
|
|
// If the organization is already complete, handle renewal HWM reset
|
|
if (invoice.billing_reason === "subscription_cycle") {
|
|
logger.info(`Processing renewal invoice for completed org, subscription ${subscriptionId}`);
|
|
const validation = validateInvoiceLinesForHwm(invoice.lines.data, subscriptionId, logger);
|
|
if (validation.isValid) {
|
|
await handleHwmResetAfterRenewal(subscriptionId, validation.periodStart, logger);
|
|
}
|
|
}
|
|
return {
|
|
success: true,
|
|
message: "Onboarding already completed",
|
|
};
|
|
}
|
|
|
|
// Get the user who created the onboarding (for service instantiation)
|
|
const userRepo = new UserRepository(prisma);
|
|
const creator = organizationOnboarding.createdById
|
|
? await userRepo.findById({ id: organizationOnboarding.createdById })
|
|
: null;
|
|
|
|
// Create a minimal user context for the service
|
|
// If no creator, use a system user context (webhook is system-initiated)
|
|
const userContext = creator
|
|
? {
|
|
id: creator.id,
|
|
email: creator.email,
|
|
role: "ADMIN" as const,
|
|
name: creator.name || undefined,
|
|
}
|
|
: {
|
|
id: 0, // System user
|
|
email: organizationOnboarding.orgOwnerEmail,
|
|
role: "ADMIN" as const,
|
|
};
|
|
|
|
const onboardingService = new BillingEnabledOrgOnboardingService(userContext);
|
|
const { organization } = await onboardingService.createOrganization(organizationOnboarding, {
|
|
subscriptionId: paymentSubscriptionId,
|
|
subscriptionItemId: paymentSubscriptionItemId,
|
|
});
|
|
|
|
// Get the Stripe subscription object
|
|
const stripeSubscription = await stripe.subscriptions.retrieve(paymentSubscriptionId);
|
|
const billingService = getBillingProviderService();
|
|
const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } =
|
|
billingService.extractSubscriptionDates(stripeSubscription);
|
|
|
|
const { billingPeriod, pricePerSeat, paidSeats } =
|
|
extractBillingDataFromStripeSubscription(stripeSubscription);
|
|
|
|
const teamBillingServiceFactory = getTeamBillingServiceFactory();
|
|
const teamBillingService = teamBillingServiceFactory.init(organization);
|
|
await teamBillingService.saveTeamBilling({
|
|
teamId: organization.id,
|
|
subscriptionId: paymentSubscriptionId,
|
|
subscriptionItemId: paymentSubscriptionItemId,
|
|
customerId: invoice.customer,
|
|
// TODO: Write actual status when webhook events are added
|
|
status: SubscriptionStatus.ACTIVE,
|
|
planName: Plan.ORGANIZATION,
|
|
subscriptionStart: subscriptionStart ?? undefined,
|
|
subscriptionEnd: subscriptionEnd ?? undefined,
|
|
subscriptionTrialEnd: subscriptionTrialEnd ?? undefined,
|
|
billingPeriod,
|
|
pricePerSeat,
|
|
paidSeats,
|
|
});
|
|
|
|
logger.debug(`Marking onboarding as complete for organization ${organization.id}`);
|
|
await OrganizationOnboardingRepository.markAsComplete(organizationOnboarding.id);
|
|
return { success: true };
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
|
|
error: error.message,
|
|
});
|
|
}
|
|
logger.error(
|
|
`Error creating organization from onboarding:${organizationOnboarding.id}`,
|
|
safeStringify({ error: error instanceof Error ? error.message : error })
|
|
);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export default handler;
|