- Add PriceHistory model + migration, price-changed domain event, and event handler - Add GetPriceHistory query handler and controller endpoint - Implement StringeeSmsService and ZaloOaService with unit tests - Add Zalo ZNS templates for Vietnamese notification messages - Add WebSocket notification gateway for real-time push - Add FeatureListingCommand for promoted listings - Apply remaining consistent-type-imports lint fixes across API modules Co-Authored-By: Paperclip <noreply@paperclip.ing>
163 lines
6.6 KiB
TypeScript
163 lines
6.6 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
Ip,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiResponse,
|
|
ApiBearerAuth,
|
|
ApiParam,
|
|
} from '@nestjs/swagger';
|
|
import { Throttle } from '@nestjs/throttler';
|
|
import { type PaymentProvider } from '@prisma/client';
|
|
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
|
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
|
import { ConfirmBankTransferCommand } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command';
|
|
import { type ConfirmBankTransferResult } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.handler';
|
|
import { CreatePaymentCommand } from '../../application/commands/create-payment/create-payment.command';
|
|
import { type CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler';
|
|
import { HandleCallbackCommand } from '../../application/commands/handle-callback/handle-callback.command';
|
|
import { type HandleCallbackResult } from '../../application/commands/handle-callback/handle-callback.handler';
|
|
import { RefundPaymentCommand } from '../../application/commands/refund-payment/refund-payment.command';
|
|
import { type RefundPaymentResult } from '../../application/commands/refund-payment/refund-payment.handler';
|
|
import { type PaymentStatusDto } from '../../application/queries/get-payment-status/get-payment-status.handler';
|
|
import { GetPaymentStatusQuery } from '../../application/queries/get-payment-status/get-payment-status.query';
|
|
import { type TransactionListDto } from '../../application/queries/list-transactions/list-transactions.handler';
|
|
import { ListTransactionsQuery } from '../../application/queries/list-transactions/list-transactions.query';
|
|
import { type ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto';
|
|
import { type CreatePaymentDto } from '../dto/create-payment.dto';
|
|
import { type ListTransactionsDto } from '../dto/list-transactions.dto';
|
|
import { type RefundPaymentDto } from '../dto/refund-payment.dto';
|
|
|
|
@ApiTags('payments')
|
|
@Controller('payments')
|
|
export class PaymentsController {
|
|
constructor(
|
|
private readonly commandBus: CommandBus,
|
|
private readonly queryBus: QueryBus,
|
|
) {}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Create a new payment' })
|
|
@ApiResponse({ status: 201, description: 'Payment created successfully' })
|
|
@ApiResponse({ status: 400, description: 'Bad request' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Post()
|
|
async createPayment(
|
|
@Body() dto: CreatePaymentDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
@Ip() ip: string,
|
|
): Promise<CreatePaymentResult> {
|
|
return this.commandBus.execute(
|
|
new CreatePaymentCommand(
|
|
user.sub,
|
|
dto.provider,
|
|
dto.type,
|
|
BigInt(dto.amountVND),
|
|
dto.description,
|
|
dto.returnUrl,
|
|
ip || '127.0.0.1',
|
|
dto.transactionId,
|
|
dto.idempotencyKey,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Handle payment provider callback (webhook)' })
|
|
@ApiResponse({ status: 201, description: 'Callback processed successfully' })
|
|
@ApiParam({ name: 'provider', enum: ['vnpay', 'momo', 'zalopay', 'bank_transfer'] })
|
|
@Throttle({ 'payment-callback': { ttl: 60_000, limit: 20 } })
|
|
@EndpointRateLimit({ limit: 100, windowSeconds: 60, keyStrategy: 'ip', adminBypass: false })
|
|
@UseGuards(EndpointRateLimitGuard)
|
|
@Post('callback/:provider')
|
|
async handleCallback(
|
|
@Param('provider') provider: string,
|
|
@Body() callbackData: Record<string, string>,
|
|
@Query() queryData: Record<string, string>,
|
|
): Promise<HandleCallbackResult> {
|
|
const providerUpper = provider.toUpperCase() as PaymentProvider;
|
|
// Merge query params and body (VNPay sends via query, MoMo/ZaloPay via body)
|
|
const mergedData = { ...queryData, ...callbackData };
|
|
return this.commandBus.execute(
|
|
new HandleCallbackCommand(providerUpper, mergedData),
|
|
);
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Get payment status by ID' })
|
|
@ApiResponse({ status: 200, description: 'Payment status retrieved' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 404, description: 'Payment not found' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Get(':id')
|
|
async getPaymentStatus(
|
|
@Param('id') id: string,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<PaymentStatusDto> {
|
|
return this.queryBus.execute(new GetPaymentStatusQuery(id, user.sub));
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'List transactions for the authenticated user' })
|
|
@ApiResponse({ status: 200, description: 'Transactions retrieved' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Get()
|
|
async listTransactions(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Query() dto: ListTransactionsDto,
|
|
): Promise<TransactionListDto> {
|
|
return this.queryBus.execute(
|
|
new ListTransactionsQuery(user.sub, dto.status, dto.limit, dto.offset),
|
|
);
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Refund a payment (admin only)' })
|
|
@ApiResponse({ status: 201, description: 'Refund initiated successfully' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
|
|
@ApiResponse({ status: 404, description: 'Payment not found' })
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN')
|
|
@Post(':id/refund')
|
|
async refundPayment(
|
|
@Param('id') id: string,
|
|
@Body() dto: RefundPaymentDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<RefundPaymentResult> {
|
|
return this.commandBus.execute(
|
|
new RefundPaymentCommand(id, dto.reason, user.sub),
|
|
);
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Confirm a bank transfer payment (admin only)' })
|
|
@ApiResponse({ status: 201, description: 'Bank transfer confirmed successfully' })
|
|
@ApiResponse({ status: 400, description: 'Payment is not a bank transfer or invalid status' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
|
|
@ApiResponse({ status: 404, description: 'Payment not found' })
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN')
|
|
@Post(':id/confirm-transfer')
|
|
async confirmBankTransfer(
|
|
@Param('id') id: string,
|
|
@Body() dto: ConfirmBankTransferDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<ConfirmBankTransferResult> {
|
|
return this.commandBus.execute(
|
|
new ConfirmBankTransferCommand(id, user.sub, dto.bankReference),
|
|
);
|
|
}
|
|
}
|