gg
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user