187 lines
5.1 KiB
TypeScript
187 lines
5.1 KiB
TypeScript
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" });
|
|
}
|
|
}
|
|
}
|