Files
calendar/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts
T
sean-brydonGitHubunknown <>Claude Opus 4.5Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1ad4ad6e4e feat: High Water Mark (HWM) billing for monthly subscriptions (#27559)
* 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>
2026-02-05 09:56:31 +00:00

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;