This commit is contained in:
2026-05-10 10:37:45 +03:00
parent 4f7090e2d9
commit c525b12dfd
32 changed files with 2374 additions and 209 deletions
@@ -0,0 +1,186 @@
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<readonly PlanInfo[]> {
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<ApiResponse<SubscriptionResponseDto | null>> {
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<ApiResponse<null>> {
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<Request>,
@Res() res: Response,
): Promise<void> {
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" });
}
}
}