* feat: add graceful webhook secret validation to billing controller - Wrap Stripe constructEventAsync in try-catch block - Add proper error logging for failed webhook signature validation - Return appropriate error responses for invalid webhook secrets - Follow existing webhook handler patterns from payments module Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor: use specific Stripe error type for webhook signature validation - Import Stripe types to access StripeSignatureVerificationError - Check for specific Stripe signature verification error type - Throw BadRequestException for invalid webhook signatures - Maintain generic error handling for other webhook processing errors Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor: throw BadRequestException for missing webhook signature and secret - Use BadRequestException instead of generic Error for missing stripe-signature header - Use BadRequestException instead of generic Error for missing webhook secret configuration - Consistent error handling with appropriate HTTP status codes Co-Authored-By: morgan@cal.com <morgan@cal.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: morgan@cal.com <morgan@cal.com>
177 lines
5.4 KiB
TypeScript
177 lines
5.4 KiB
TypeScript
import { AppConfig } from "@/config/type";
|
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
|
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
|
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
|
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
|
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
|
|
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
|
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
|
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
Param,
|
|
Post,
|
|
Req,
|
|
UseGuards,
|
|
Headers,
|
|
HttpCode,
|
|
HttpStatus,
|
|
Logger,
|
|
Delete,
|
|
BadRequestException,
|
|
} from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { ApiExcludeController } from "@nestjs/swagger";
|
|
import { Request } from "express";
|
|
import Stripe from "stripe";
|
|
|
|
import { ApiResponse } from "@calcom/platform-types";
|
|
|
|
@Controller({
|
|
path: "/v2/billing",
|
|
version: API_VERSIONS_VALUES,
|
|
})
|
|
@ApiExcludeController(true)
|
|
export class BillingController {
|
|
private readonly stripeWhSecret: string;
|
|
private logger = new Logger("Billing Controller");
|
|
|
|
constructor(
|
|
private readonly billingService: BillingService,
|
|
public readonly stripeService: StripeService,
|
|
private readonly configService: ConfigService<AppConfig>
|
|
) {
|
|
this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? "";
|
|
}
|
|
|
|
@Get("/:teamId/check")
|
|
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
|
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
|
|
async checkTeamBilling(
|
|
@Param("teamId") teamId: number
|
|
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
|
|
const { status, plan } = await this.billingService.getBillingData(teamId);
|
|
|
|
return {
|
|
status: "success",
|
|
data: {
|
|
valid: status === "valid",
|
|
plan,
|
|
},
|
|
};
|
|
}
|
|
|
|
@Post("/:teamId/subscribe")
|
|
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
|
@MembershipRoles(["OWNER", "ADMIN"])
|
|
async subscribeTeamToStripe(
|
|
@Param("teamId") teamId: number,
|
|
@Body() input: SubscribeToPlanInput
|
|
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
|
|
const customerId = await this.billingService.createTeamBilling(teamId);
|
|
const url = await this.billingService.redirectToSubscribeCheckout(teamId, input.plan, customerId);
|
|
|
|
return {
|
|
status: "success",
|
|
data: {
|
|
url,
|
|
},
|
|
};
|
|
}
|
|
|
|
@Post("/:teamId/upgrade")
|
|
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
|
@MembershipRoles(["OWNER", "ADMIN"])
|
|
async upgradeTeamBillingInStripe(
|
|
@Param("teamId") teamId: number,
|
|
@Body() input: SubscribeToPlanInput
|
|
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
|
|
const url = await this.billingService.updateSubscriptionForTeam(teamId, input.plan);
|
|
|
|
return {
|
|
status: "success",
|
|
data: {
|
|
url,
|
|
},
|
|
};
|
|
}
|
|
|
|
@Delete("/:organizationId/unsubscribe")
|
|
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
|
@MembershipRoles(["OWNER", "ADMIN"])
|
|
async cancelTeamSubscriptionInStripe(
|
|
@Param("organizationId") organizationId: number
|
|
): Promise<ApiResponse> {
|
|
await this.billingService.cancelTeamSubscription(organizationId);
|
|
|
|
return {
|
|
status: "success",
|
|
};
|
|
}
|
|
|
|
@Post("/webhook")
|
|
@HttpCode(HttpStatus.OK)
|
|
async stripeWebhook(
|
|
@Req() request: Request,
|
|
@Headers("stripe-signature") stripeSignature: string
|
|
): Promise<ApiResponse> {
|
|
try {
|
|
if (!stripeSignature) {
|
|
this.logger.warn("Missing stripe-signature header in webhook request");
|
|
return {
|
|
status: "success",
|
|
};
|
|
}
|
|
|
|
if (!this.stripeWhSecret) {
|
|
this.logger.error("Missing STRIPE_WEBHOOK_SECRET configuration");
|
|
return {
|
|
status: "success",
|
|
};
|
|
}
|
|
|
|
const event = await this.billingService.stripeService
|
|
.getStripe()
|
|
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
|
|
|
switch (event.type) {
|
|
case "checkout.session.completed":
|
|
await this.billingService.handleStripeCheckoutEvents(event);
|
|
break;
|
|
case "customer.subscription.updated":
|
|
await this.billingService.handleStripePaymentPastDue(event);
|
|
break;
|
|
case "customer.subscription.deleted":
|
|
await this.billingService.handleStripeSubscriptionDeleted(event);
|
|
break;
|
|
case "invoice.created":
|
|
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
|
break;
|
|
case "invoice.payment_failed":
|
|
await this.billingService.handleStripePaymentFailed(event);
|
|
break;
|
|
case "invoice.payment_succeeded":
|
|
await this.billingService.handleStripePaymentSuccess(event);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return {
|
|
status: "success",
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof Stripe.errors.StripeSignatureVerificationError) {
|
|
this.logger.error("Webhook signature validation failed", error);
|
|
return {
|
|
status: "success",
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|