* feat: add seat tracking infrastructure for monthly proration Add seat change logging infrastructure with operationId for idempotency. This PR adds the foundation for monthly proration billing by tracking seat additions and removals, gated behind the monthly-proration feature flag. - Add operationId field to SeatChangeLog for idempotency - Update SeatChangeLogRepository to support upsert with operationId - Add feature flag guard in SeatChangeTrackingService - Integrate seat tracking in team member invites - Integrate seat tracking in bulk user deletions - Integrate seat tracking in team service operations - Integrate seat tracking in DSYNC user creation When monthly-proration feature flag is disabled, seat logging is skipped and behavior remains unchanged. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: add monthly proration processing Add monthly proration billing processing that works on top of the seat tracking infrastructure. This PR implements the core proration logic, webhook handlers, and integration with Stripe billing. - Enhance MonthlyProrationService to process seat change logs - Add payment webhook handlers (invoice.payment_succeeded, invoice.payment_failed) - Update subscription webhook to sync billing period on renewals - Update TeamBillingService to skip real-time updates when proration enabled - Enhance StripeBillingService with proration capabilities - Add Tasker enhancements for processing queues - Update team creation/upgrade routes Depends on: feat/monthly-proration-seat-tracking Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: remove unused logger from SeatChangeTrackingService * fix: description for calculation * fix null check on trial * chore: no more prisma calls * add feature flag check * fix stub --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
150 lines
5.7 KiB
TypeScript
150 lines
5.7 KiB
TypeScript
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
|
import type { NextRequest } from "next/server";
|
|
import { NextResponse } from "next/server";
|
|
import type Stripe from "stripe";
|
|
import { z } from "zod";
|
|
|
|
import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing";
|
|
import { getTeamBillingServiceFactory } 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 stripe from "@calcom/features/ee/payments/server/stripe";
|
|
import { HttpError } from "@calcom/lib/http-error";
|
|
import { prisma } from "@calcom/prisma";
|
|
import { MembershipRole } from "@calcom/prisma/enums";
|
|
import { MembershipSchema } from "@calcom/prisma/zod/modelSchema/MembershipSchema";
|
|
import { TeamSchema } from "@calcom/prisma/zod/modelSchema/TeamSchema";
|
|
|
|
const querySchema = z.object({
|
|
session_id: z.string().min(1),
|
|
});
|
|
|
|
const checkoutSessionMetadataSchema = z.object({
|
|
pendingPaymentTeamId: z.string().transform(Number),
|
|
ownerId: z.string().transform(Number),
|
|
});
|
|
|
|
type CheckoutSessionMetadata = z.infer<typeof checkoutSessionMetadataSchema>;
|
|
|
|
export const schemaTeamReadPublic = TeamSchema.omit({});
|
|
export const schemaMembershipPublic = MembershipSchema.merge(z.object({ team: TeamSchema }).partial());
|
|
|
|
async function handler(request: NextRequest) {
|
|
try {
|
|
const { session_id } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
|
|
|
|
const checkoutSession = await getCheckoutSession(session_id);
|
|
validateCheckoutSession(checkoutSession);
|
|
const checkoutSessionSubscription = getCheckoutSessionSubscription(checkoutSession);
|
|
const checkoutSessionMetadata = getCheckoutSessionMetadata(checkoutSession);
|
|
|
|
const finalizedTeam = await prisma.team.update({
|
|
where: { id: checkoutSessionMetadata.pendingPaymentTeamId },
|
|
data: {
|
|
pendingPayment: false,
|
|
members: {
|
|
create: {
|
|
userId: checkoutSessionMetadata.ownerId as number,
|
|
role: MembershipRole.OWNER,
|
|
accepted: true,
|
|
},
|
|
},
|
|
metadata: {
|
|
paymentId: checkoutSession.id,
|
|
subscriptionId: checkoutSessionSubscription.id || null,
|
|
subscriptionItemId: checkoutSessionSubscription.items.data[0].id || null,
|
|
},
|
|
},
|
|
include: { members: true },
|
|
});
|
|
|
|
if (checkoutSessionSubscription) {
|
|
const billingService = getBillingProviderService();
|
|
const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } =
|
|
billingService.extractSubscriptionDates(checkoutSessionSubscription);
|
|
|
|
const { billingPeriod, pricePerSeat, paidSeats } =
|
|
extractBillingDataFromStripeSubscription(checkoutSessionSubscription);
|
|
|
|
const teamBillingServiceFactory = getTeamBillingServiceFactory();
|
|
const teamBillingService = teamBillingServiceFactory.init(finalizedTeam);
|
|
await teamBillingService.saveTeamBilling({
|
|
teamId: finalizedTeam.id,
|
|
subscriptionId: checkoutSessionSubscription.id,
|
|
subscriptionItemId: checkoutSessionSubscription.items.data[0].id,
|
|
customerId: checkoutSessionSubscription.customer as string,
|
|
// TODO: Implement true subscription status when webhook events are implemented
|
|
status: SubscriptionStatus.ACTIVE,
|
|
planName: Plan.TEAM,
|
|
subscriptionStart: subscriptionStart ?? undefined,
|
|
subscriptionEnd: subscriptionEnd ?? undefined,
|
|
subscriptionTrialEnd: subscriptionTrialEnd ?? undefined,
|
|
billingPeriod,
|
|
pricePerSeat,
|
|
paidSeats,
|
|
});
|
|
}
|
|
|
|
const response = {
|
|
message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`,
|
|
team: schemaTeamReadPublic.parse(finalizedTeam),
|
|
owner: schemaMembershipPublic.parse(finalizedTeam.members[0]),
|
|
};
|
|
|
|
return NextResponse.json(response);
|
|
} catch (error) {
|
|
console.error("Error creating team:", error);
|
|
|
|
if (error instanceof HttpError) {
|
|
return NextResponse.json({ message: error.message }, { status: error.statusCode });
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ message: error instanceof Error ? error.message : "An unexpected error occurred" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
async function getCheckoutSession(sessionId: string) {
|
|
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId, {
|
|
expand: ["subscription"],
|
|
});
|
|
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
|
|
|
|
return checkoutSession;
|
|
}
|
|
|
|
function validateCheckoutSession(checkoutSession: Stripe.Response<Stripe.Checkout.Session>) {
|
|
if (checkoutSession.payment_status !== "paid")
|
|
throw new HttpError({ statusCode: 402, message: "Payment required" });
|
|
}
|
|
|
|
function getCheckoutSessionSubscription(checkoutSession: Stripe.Response<Stripe.Checkout.Session>) {
|
|
if (!checkoutSession.subscription) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Can't publish team/org without subscription",
|
|
});
|
|
}
|
|
|
|
return checkoutSession.subscription as Stripe.Subscription;
|
|
}
|
|
|
|
function getCheckoutSessionMetadata(
|
|
checkoutSession: Stripe.Response<Stripe.Checkout.Session>
|
|
): CheckoutSessionMetadata {
|
|
const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata);
|
|
|
|
if (!parseCheckoutSessionMetadata.success) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: `Incorrect metadata in checkout session. Error: ${parseCheckoutSessionMetadata.error}`,
|
|
});
|
|
}
|
|
|
|
return parseCheckoutSessionMetadata.data;
|
|
}
|
|
|
|
export const GET = defaultResponderForAppDir(handler);
|