import { Controller, Get, Post, Body, Req, Res, HttpCode, HttpStatus, Logger, ForbiddenException, } from "@nestjs/common"; import type { RawBodyRequest } from "@nestjs/common"; import { ApiTags, ApiBearerAuth, ApiOperation, ApiOkResponse, } from "@nestjs/swagger"; import type { Request, Response } from "express"; import { CurrentUser, Public } from "../../common/decorators"; import type { ApiResponse } from "../../common/types/api-response.type"; import { createSuccessResponse, createErrorResponse, } from "../../common/types/api-response.type"; import { SubscriptionsService } from "./subscriptions.service"; import { PaddleService, PaddleWebhookEvent } from "./paddle.service"; import { CreateCheckoutDto, CancelSubscriptionDto, SubscriptionResponseDto, PlanInfo, PlanType, } from "./dto/subscription.dto"; interface AuthenticatedUser { id: string; email: string; role: string; } @ApiTags("Subscriptions") @Controller("subscriptions") export class SubscriptionsController { private readonly logger = new Logger(SubscriptionsController.name); constructor( private readonly subscriptionsService: SubscriptionsService, private readonly paddleService: PaddleService, ) {} /** * GET /subscriptions/plans — Get all available plans (public) */ @Public() @Get("plans") @ApiOperation({ summary: "Get all available subscription plans" }) @ApiOkResponse({ description: "List of available plans" }) getPlans(): ApiResponse { const plans = this.subscriptionsService.getPlans(); return createSuccessResponse(plans, "Plans retrieved"); } /** * GET /subscriptions/me — Get current user subscription */ @ApiBearerAuth() @Get("me") @ApiOperation({ summary: "Get current user subscription status" }) async getMySubscription( @CurrentUser() user: AuthenticatedUser, ): Promise> { const subscription = await this.subscriptionsService.getCurrentSubscription( user.id, ); return createSuccessResponse(subscription, "Subscription retrieved"); } /** * POST /subscriptions/checkout — Get checkout config for Paddle.js */ @ApiBearerAuth() @Post("checkout") @ApiOperation({ summary: "Get Paddle checkout configuration for a plan", }) async getCheckoutConfig( @CurrentUser() user: AuthenticatedUser, @Body() dto: CreateCheckoutDto, ): Promise< ApiResponse<{ priceId: string; clientToken: string; environment: string; userId: string; }> > { if (dto.plan === PlanType.FREE) { throw new ForbiddenException("Cannot checkout for free plan"); } const config = this.subscriptionsService.getCheckoutConfig( dto.plan, dto.billingInterval, ); return createSuccessResponse( { ...config, userId: user.id, }, "Checkout config ready", ); } /** * POST /subscriptions/cancel — Cancel current subscription */ @ApiBearerAuth() @Post("cancel") @ApiOperation({ summary: "Cancel the current subscription" }) async cancelSubscription( @CurrentUser() user: AuthenticatedUser, @Body() _dto: CancelSubscriptionDto, ): Promise> { await this.subscriptionsService.cancelSubscription(user.id); return createSuccessResponse(null, "Subscription cancellation requested"); } /** * POST /subscriptions/webhook/paddle — Paddle webhook receiver * * This endpoint is PUBLIC (no JWT required) — Paddle calls it directly. * Authentication is done via HMAC signature verification. */ @Public() @Post("webhook/paddle") @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Paddle webhook receiver (internal)" }) async handlePaddleWebhook( @Req() req: RawBodyRequest, @Res() res: Response, ): Promise { const signature = req.headers["paddle-signature"] as string | undefined; if (!signature) { this.logger.warn("Paddle webhook received without signature"); res.status(HttpStatus.BAD_REQUEST).json({ error: "Missing signature" }); return; } // Get raw body for signature verification const rawBody = req.rawBody?.toString("utf8"); if (!rawBody) { this.logger.warn("Paddle webhook received without raw body"); res.status(HttpStatus.BAD_REQUEST).json({ error: "Missing body" }); return; } // Verify signature const isValid = this.paddleService.verifyWebhookSignature( rawBody, signature, ); if (!isValid) { this.logger.warn("Paddle webhook signature verification failed"); res.status(HttpStatus.UNAUTHORIZED).json({ error: "Invalid signature" }); return; } // Parse and process try { const event = JSON.parse(rawBody) as PaddleWebhookEvent; await this.subscriptionsService.handleWebhookEvent(event); res.status(HttpStatus.OK).json({ received: true }); } catch (error: unknown) { const err = error as Error; this.logger.error(`Webhook processing failed: ${err.message}`); res .status(HttpStatus.INTERNAL_SERVER_ERROR) .json({ error: "Processing failed" }); } } }