* Init billing tables * Create `IBillingRepository` types * Create billing repositories * Create billingRepositoryFactory * Eslint fix - remove unused organizationOnboarding * internal-team-billing create saveTeamBilling method using repositories * On new teams write to team billing table * On new org write to org billing table * Change fields to organizationId * Add todo comment * Revert "Change fields to organizationId" This reverts commit bbb2e5dfa6b4c20a8a395f5730848a492cd70d68. * test: add comprehensive tests for team billing tables - Fix credit-service.test.ts Prisma mock to export prisma object - Replace any types with proper TypeScript types in credit-service.test.ts - Add unit tests for PrismaTeamBillingRepository covering record creation, enum casting, and error handling - Add unit tests for PrismaOrganizationBillingRepository with same coverage - Add unit tests for BillingRepositoryFactory to verify correct repository selection - Add unit tests for InternalTeamBilling.saveTeamBilling() method testing delegation to correct repositories - All 53 tests pass with TZ=UTC yarn test - Type checking passes with yarn type-check:ci --force Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * refactor: update saveTeamBilling tests to mock repository interface - Replace prismaMock usage with BillingRepositoryFactory mock - Mock IBillingRepository interface instead of Prisma directly - Follow repository mocking pattern from handleResponse.test.ts - All tests passing (53 total) Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Remove log statement * Remove repository tests * Address feedback --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
110 lines
3.6 KiB
TypeScript
110 lines
3.6 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 { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
|
|
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
|
|
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";
|
|
|
|
const querySchema = z.object({
|
|
session_id: z.string().min(1),
|
|
});
|
|
|
|
const checkoutSessionMetadataSchema = z.object({
|
|
teamName: z.string(),
|
|
teamSlug: z.string(),
|
|
userId: z.string().transform(Number),
|
|
});
|
|
|
|
const generateRandomString = () => {
|
|
return Math.random().toString(36).substring(2, 10);
|
|
};
|
|
|
|
async function getHandler(req: NextRequest) {
|
|
const searchParams = req.nextUrl.searchParams;
|
|
const { session_id } = querySchema.parse({
|
|
session_id: searchParams.get("session_id"),
|
|
});
|
|
|
|
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, {
|
|
expand: ["subscription"],
|
|
});
|
|
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
|
|
|
|
const subscription = checkoutSession.subscription as Stripe.Subscription;
|
|
|
|
if (checkoutSession.payment_status !== "paid")
|
|
throw new HttpError({ statusCode: 402, message: "Payment required" });
|
|
|
|
// Let's query to ensure that the team metadata carried over from the checkout session.
|
|
const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata);
|
|
|
|
if (!parseCheckoutSessionMetadata.success) {
|
|
console.error(
|
|
"Team metadata not found in checkout session",
|
|
parseCheckoutSessionMetadata.error,
|
|
checkoutSession.id
|
|
);
|
|
}
|
|
|
|
if (!checkoutSession.metadata?.userId) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Can't publish team/org without userId",
|
|
});
|
|
}
|
|
|
|
const checkoutSessionMetadata = parseCheckoutSessionMetadata.success
|
|
? parseCheckoutSessionMetadata.data
|
|
: {
|
|
teamName: checkoutSession?.metadata?.teamName ?? generateRandomString(),
|
|
teamSlug: checkoutSession?.metadata?.teamSlug ?? generateRandomString(),
|
|
userId: checkoutSession.metadata.userId,
|
|
};
|
|
|
|
const team = await prisma.team.create({
|
|
data: {
|
|
name: checkoutSessionMetadata.teamName,
|
|
slug: checkoutSessionMetadata.teamSlug,
|
|
members: {
|
|
create: {
|
|
userId: checkoutSessionMetadata.userId as number,
|
|
role: MembershipRole.OWNER,
|
|
accepted: true,
|
|
},
|
|
},
|
|
metadata: {
|
|
paymentId: checkoutSession.id,
|
|
subscriptionId: subscription.id || null,
|
|
subscriptionItemId: subscription.items.data[0].id || null,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (checkoutSession && subscription) {
|
|
const internalBillingService = new InternalTeamBilling(team);
|
|
await internalBillingService.saveTeamBilling({
|
|
teamId: team.id,
|
|
subscriptionId: subscription.id,
|
|
subscriptionItemId: subscription.items.data[0].id,
|
|
customerId: subscription.customer as string,
|
|
// TODO: Implement true subscription status when webhook events are implemented
|
|
status: SubscriptionStatus.ACTIVE,
|
|
planName: Plan.TEAM,
|
|
});
|
|
}
|
|
|
|
// redirect to team screen
|
|
return NextResponse.redirect(
|
|
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin),
|
|
{ status: 302 }
|
|
);
|
|
}
|
|
|
|
export const GET = defaultResponderForAppDir(getHandler);
|