From ad7713968a24ba228ef11b7ff7e8123998071d0c Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 01:57:23 +0700 Subject: [PATCH] feat(payments): implement Payments module with VNPay, MoMo, ZaloPay integration Implement complete payment processing module following DDD + CQRS patterns: - Domain layer: PaymentEntity aggregate, Money value object, domain events - Infrastructure: PrismaPaymentRepository, VnpayService, MomoService, ZalopayService - PaymentGatewayFactory pattern for provider abstraction - CQRS Commands: CreatePayment, HandleCallback, RefundPayment - CQRS Queries: GetPaymentStatus, ListTransactions - Callback/webhook endpoints with signature verification and idempotency - 23 unit tests covering domain, VNPay service, and gateway factory Co-Authored-By: Paperclip --- apps/api/src/app.module.ts | 2 + .../create-payment/create-payment.command.ts | 15 ++ .../create-payment/create-payment.handler.ts | 108 ++++++++++ .../handle-callback.command.ts | 8 + .../handle-callback.handler.ts | 98 +++++++++ .../refund-payment/refund-payment.command.ts | 7 + .../refund-payment/refund-payment.handler.ts | 81 +++++++ .../src/modules/payments/application/index.ts | 11 + .../get-payment-status.handler.ts | 59 ++++++ .../get-payment-status.query.ts | 6 + .../list-transactions.handler.ts | 58 +++++ .../list-transactions.query.ts | 10 + .../domain/__tests__/money.vo.spec.ts | 32 +++ .../domain/__tests__/payment.entity.spec.ts | 130 ++++++++++++ .../modules/payments/domain/entities/index.ts | 1 + .../domain/entities/payment.entity.ts | 125 +++++++++++ .../modules/payments/domain/events/index.ts | 3 + .../domain/events/payment-completed.event.ts | 14 ++ .../domain/events/payment-created.event.ts | 15 ++ .../domain/events/payment-failed.event.ts | 13 ++ .../payments/domain/repositories/index.ts | 1 + .../domain/repositories/payment.repository.ts | 17 ++ .../payments/domain/value-objects/index.ts | 1 + .../payments/domain/value-objects/money.vo.ts | 22 ++ apps/api/src/modules/payments/index.ts | 3 + .../__tests__/payment-gateway.factory.spec.ts | 37 ++++ .../__tests__/vnpay.service.spec.ts | 103 +++++++++ .../infrastructure/repositories/index.ts | 1 + .../repositories/prisma-payment.repository.ts | 105 +++++++++ .../payments/infrastructure/services/index.ts | 14 ++ .../infrastructure/services/momo.service.ts | 198 +++++++++++++++++ .../services/payment-gateway.factory.ts | 34 +++ .../services/payment-gateway.interface.ts | 46 ++++ .../infrastructure/services/vnpay.service.ts | 181 ++++++++++++++++ .../services/zalopay.service.ts | 200 ++++++++++++++++++ .../src/modules/payments/payments.module.ts | 54 +++++ .../presentation/controllers/index.ts | 1 + .../controllers/payments.controller.ts | 107 ++++++++++ .../presentation/dto/create-payment.dto.ts | 35 +++ .../payments/presentation/dto/index.ts | 3 + .../presentation/dto/list-transactions.dto.ts | 19 ++ .../presentation/dto/refund-payment.dto.ts | 7 + 42 files changed, 1985 insertions(+) create mode 100644 apps/api/src/modules/payments/application/commands/create-payment/create-payment.command.ts create mode 100644 apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts create mode 100644 apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.command.ts create mode 100644 apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.handler.ts create mode 100644 apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.command.ts create mode 100644 apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.handler.ts create mode 100644 apps/api/src/modules/payments/application/index.ts create mode 100644 apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts create mode 100644 apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.query.ts create mode 100644 apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts create mode 100644 apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.query.ts create mode 100644 apps/api/src/modules/payments/domain/__tests__/money.vo.spec.ts create mode 100644 apps/api/src/modules/payments/domain/__tests__/payment.entity.spec.ts create mode 100644 apps/api/src/modules/payments/domain/entities/index.ts create mode 100644 apps/api/src/modules/payments/domain/entities/payment.entity.ts create mode 100644 apps/api/src/modules/payments/domain/events/index.ts create mode 100644 apps/api/src/modules/payments/domain/events/payment-completed.event.ts create mode 100644 apps/api/src/modules/payments/domain/events/payment-created.event.ts create mode 100644 apps/api/src/modules/payments/domain/events/payment-failed.event.ts create mode 100644 apps/api/src/modules/payments/domain/repositories/index.ts create mode 100644 apps/api/src/modules/payments/domain/repositories/payment.repository.ts create mode 100644 apps/api/src/modules/payments/domain/value-objects/index.ts create mode 100644 apps/api/src/modules/payments/domain/value-objects/money.vo.ts create mode 100644 apps/api/src/modules/payments/index.ts create mode 100644 apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts create mode 100644 apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts create mode 100644 apps/api/src/modules/payments/infrastructure/repositories/index.ts create mode 100644 apps/api/src/modules/payments/infrastructure/repositories/prisma-payment.repository.ts create mode 100644 apps/api/src/modules/payments/infrastructure/services/index.ts create mode 100644 apps/api/src/modules/payments/infrastructure/services/momo.service.ts create mode 100644 apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts create mode 100644 apps/api/src/modules/payments/infrastructure/services/payment-gateway.interface.ts create mode 100644 apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts create mode 100644 apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts create mode 100644 apps/api/src/modules/payments/payments.module.ts create mode 100644 apps/api/src/modules/payments/presentation/controllers/index.ts create mode 100644 apps/api/src/modules/payments/presentation/controllers/payments.controller.ts create mode 100644 apps/api/src/modules/payments/presentation/dto/create-payment.dto.ts create mode 100644 apps/api/src/modules/payments/presentation/dto/index.ts create mode 100644 apps/api/src/modules/payments/presentation/dto/list-transactions.dto.ts create mode 100644 apps/api/src/modules/payments/presentation/dto/refund-payment.dto.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2296f2f..c7a54db 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -3,6 +3,7 @@ import { AuthModule } from '@modules/auth'; import { ListingsModule } from '@modules/listings'; import { SearchModule } from '@modules/search'; import { NotificationsModule } from '@modules/notifications'; +import { PaymentsModule } from '@modules/payments'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { CqrsModule } from '@nestjs/cqrs'; @@ -18,6 +19,7 @@ import { AppController } from './app.controller'; ListingsModule, SearchModule, NotificationsModule, + PaymentsModule, // ── Rate Limiting ── // Default: 60 requests per 60 seconds per IP diff --git a/apps/api/src/modules/payments/application/commands/create-payment/create-payment.command.ts b/apps/api/src/modules/payments/application/commands/create-payment/create-payment.command.ts new file mode 100644 index 0000000..bf7e2db --- /dev/null +++ b/apps/api/src/modules/payments/application/commands/create-payment/create-payment.command.ts @@ -0,0 +1,15 @@ +import { type PaymentProvider, type PaymentType } from '@prisma/client'; + +export class CreatePaymentCommand { + constructor( + public readonly userId: string, + public readonly provider: PaymentProvider, + public readonly type: PaymentType, + public readonly amountVND: bigint, + public readonly description: string, + public readonly returnUrl: string, + public readonly ipAddress: string, + public readonly transactionId?: string, + public readonly idempotencyKey?: string, + ) {} +} diff --git a/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts b/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts new file mode 100644 index 0000000..9e16962 --- /dev/null +++ b/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts @@ -0,0 +1,108 @@ +import { + BadRequestException, + ConflictException, + Inject, + Logger, +} from '@nestjs/common'; +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { createId } from '@paralleldrive/cuid2'; +import { CreatePaymentCommand } from './create-payment.command'; +import { + PAYMENT_REPOSITORY, + type IPaymentRepository, +} from '../../../domain/repositories/payment.repository'; +import { + PAYMENT_GATEWAY_FACTORY, + type IPaymentGatewayFactory, +} from '../../../infrastructure/services/payment-gateway.interface'; +import { PaymentEntity } from '../../../domain/entities/payment.entity'; +import { Money } from '../../../domain/value-objects/money.vo'; +import { ErrorCode } from '@modules/shared/domain/error-codes'; + +export interface CreatePaymentResult { + paymentId: string; + paymentUrl: string; + providerTxId: string; +} + +@CommandHandler(CreatePaymentCommand) +export class CreatePaymentHandler implements ICommandHandler { + private readonly logger = new Logger(CreatePaymentHandler.name); + + constructor( + @Inject(PAYMENT_REPOSITORY) + private readonly paymentRepo: IPaymentRepository, + @Inject(PAYMENT_GATEWAY_FACTORY) + private readonly gatewayFactory: IPaymentGatewayFactory, + private readonly eventBus: EventBus, + ) {} + + async execute(command: CreatePaymentCommand): Promise { + // Idempotency check + if (command.idempotencyKey) { + const existing = await this.paymentRepo.findByIdempotencyKey(command.idempotencyKey); + if (existing) { + if (existing.status === 'PENDING' || existing.status === 'PROCESSING') { + throw new ConflictException({ + code: ErrorCode.PAYMENT_ALREADY_PROCESSED, + message: 'Thanh toán với idempotency key này đã tồn tại', + paymentId: existing.id, + }); + } + throw new ConflictException({ + code: ErrorCode.PAYMENT_ALREADY_PROCESSED, + message: 'Thanh toán đã được xử lý', + }); + } + } + + // Validate amount + const moneyResult = Money.create(command.amountVND); + if (moneyResult.isErr) { + throw new BadRequestException({ + code: ErrorCode.PAYMENT_INVALID_AMOUNT, + message: moneyResult.unwrapErr(), + }); + } + + const money = moneyResult.unwrap(); + const paymentId = createId(); + + // Create domain entity + const payment = PaymentEntity.createNew( + paymentId, + command.userId, + command.provider, + command.type, + money, + command.transactionId, + command.idempotencyKey, + ); + + // Get payment gateway and create URL + const gateway = this.gatewayFactory.getGateway(command.provider); + const { paymentUrl, providerTxId } = await gateway.createPaymentUrl({ + orderId: paymentId, + amountVND: command.amountVND, + description: command.description, + returnUrl: command.returnUrl, + ipAddress: command.ipAddress, + }); + + // Mark processing and save + payment.markProcessing(providerTxId); + await this.paymentRepo.save(payment); + + // Publish domain events + const events = payment.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log( + `Payment created: id=${paymentId}, provider=${command.provider}, amount=${command.amountVND}`, + ); + + return { paymentId, paymentUrl, providerTxId }; + } +} diff --git a/apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.command.ts b/apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.command.ts new file mode 100644 index 0000000..2ae93bf --- /dev/null +++ b/apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.command.ts @@ -0,0 +1,8 @@ +import { type PaymentProvider } from '@prisma/client'; + +export class HandleCallbackCommand { + constructor( + public readonly provider: PaymentProvider, + public readonly callbackData: Record, + ) {} +} diff --git a/apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.handler.ts b/apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.handler.ts new file mode 100644 index 0000000..c497fe4 --- /dev/null +++ b/apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.handler.ts @@ -0,0 +1,98 @@ +import { + BadRequestException, + Inject, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { HandleCallbackCommand } from './handle-callback.command'; +import { + PAYMENT_REPOSITORY, + type IPaymentRepository, +} from '../../../domain/repositories/payment.repository'; +import { + PAYMENT_GATEWAY_FACTORY, + type IPaymentGatewayFactory, +} from '../../../infrastructure/services/payment-gateway.interface'; +import { ErrorCode } from '@modules/shared/domain/error-codes'; + +export interface HandleCallbackResult { + paymentId: string; + status: string; + isSuccess: boolean; +} + +@CommandHandler(HandleCallbackCommand) +export class HandleCallbackHandler implements ICommandHandler { + private readonly logger = new Logger(HandleCallbackHandler.name); + + constructor( + @Inject(PAYMENT_REPOSITORY) + private readonly paymentRepo: IPaymentRepository, + @Inject(PAYMENT_GATEWAY_FACTORY) + private readonly gatewayFactory: IPaymentGatewayFactory, + private readonly eventBus: EventBus, + ) {} + + async execute(command: HandleCallbackCommand): Promise { + const gateway = this.gatewayFactory.getGateway(command.provider); + const result = gateway.verifyCallback(command.callbackData); + + if (!result.isValid) { + this.logger.warn( + `Invalid callback signature for provider=${command.provider}`, + ); + throw new BadRequestException({ + code: ErrorCode.PAYMENT_FAILED, + message: 'Chữ ký callback không hợp lệ', + }); + } + + // Find payment by orderId (which is the payment ID) + const payment = await this.paymentRepo.findById(result.orderId); + if (!payment) { + this.logger.warn(`Payment not found for orderId=${result.orderId}`); + throw new NotFoundException({ + code: ErrorCode.NOT_FOUND, + message: 'Không tìm thấy thanh toán', + }); + } + + // Idempotency: if already completed/failed, return current state + if (payment.status === 'COMPLETED' || payment.status === 'FAILED' || payment.status === 'REFUNDED') { + this.logger.log( + `Payment ${payment.id} already in terminal state: ${payment.status}`, + ); + return { + paymentId: payment.id, + status: payment.status, + isSuccess: payment.status === 'COMPLETED', + }; + } + + // Update payment status + if (result.isSuccess) { + payment.markCompleted(result.rawData); + } else { + payment.markFailed(result.rawData); + } + + await this.paymentRepo.update(payment); + + // Publish domain events + const events = payment.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log( + `Payment ${payment.id} callback processed: status=${payment.status}`, + ); + + return { + paymentId: payment.id, + status: payment.status, + isSuccess: result.isSuccess, + }; + } +} diff --git a/apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.command.ts b/apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.command.ts new file mode 100644 index 0000000..3b7dad6 --- /dev/null +++ b/apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.command.ts @@ -0,0 +1,7 @@ +export class RefundPaymentCommand { + constructor( + public readonly paymentId: string, + public readonly reason: string, + public readonly requestedBy: string, + ) {} +} diff --git a/apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.handler.ts b/apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.handler.ts new file mode 100644 index 0000000..051fa57 --- /dev/null +++ b/apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.handler.ts @@ -0,0 +1,81 @@ +import { + BadRequestException, + Inject, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { RefundPaymentCommand } from './refund-payment.command'; +import { + PAYMENT_REPOSITORY, + type IPaymentRepository, +} from '../../../domain/repositories/payment.repository'; +import { + PAYMENT_GATEWAY_FACTORY, + type IPaymentGatewayFactory, +} from '../../../infrastructure/services/payment-gateway.interface'; +import { ErrorCode } from '@modules/shared/domain/error-codes'; + +export interface RefundPaymentResult { + paymentId: string; + refundTxId: string | null; + success: boolean; +} + +@CommandHandler(RefundPaymentCommand) +export class RefundPaymentHandler implements ICommandHandler { + private readonly logger = new Logger(RefundPaymentHandler.name); + + constructor( + @Inject(PAYMENT_REPOSITORY) + private readonly paymentRepo: IPaymentRepository, + @Inject(PAYMENT_GATEWAY_FACTORY) + private readonly gatewayFactory: IPaymentGatewayFactory, + ) {} + + async execute(command: RefundPaymentCommand): Promise { + const payment = await this.paymentRepo.findById(command.paymentId); + if (!payment) { + throw new NotFoundException({ + code: ErrorCode.NOT_FOUND, + message: 'Không tìm thấy thanh toán', + }); + } + + if (payment.status !== 'COMPLETED') { + throw new BadRequestException({ + code: ErrorCode.PAYMENT_FAILED, + message: 'Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất', + }); + } + + if (!payment.providerTxId) { + throw new BadRequestException({ + code: ErrorCode.PAYMENT_FAILED, + message: 'Không có mã giao dịch từ nhà cung cấp', + }); + } + + const gateway = this.gatewayFactory.getGateway(payment.provider); + const result = await gateway.refund({ + providerTxId: payment.providerTxId, + amountVND: payment.amount.value, + reason: command.reason, + }); + + if (result.success) { + payment.markRefunded(); + await this.paymentRepo.update(payment); + } + + this.logger.log( + `Refund ${result.success ? 'successful' : 'failed'} for payment ${command.paymentId}`, + ); + + return { + paymentId: command.paymentId, + refundTxId: result.refundTxId, + success: result.success, + }; + } +} diff --git a/apps/api/src/modules/payments/application/index.ts b/apps/api/src/modules/payments/application/index.ts new file mode 100644 index 0000000..2467794 --- /dev/null +++ b/apps/api/src/modules/payments/application/index.ts @@ -0,0 +1,11 @@ +export { CreatePaymentCommand } from './commands/create-payment/create-payment.command'; +export { CreatePaymentHandler, type CreatePaymentResult } from './commands/create-payment/create-payment.handler'; +export { HandleCallbackCommand } from './commands/handle-callback/handle-callback.command'; +export { HandleCallbackHandler, type HandleCallbackResult } from './commands/handle-callback/handle-callback.handler'; +export { RefundPaymentCommand } from './commands/refund-payment/refund-payment.command'; +export { RefundPaymentHandler, type RefundPaymentResult } from './commands/refund-payment/refund-payment.handler'; + +export { GetPaymentStatusQuery } from './queries/get-payment-status/get-payment-status.query'; +export { GetPaymentStatusHandler, type PaymentStatusDto } from './queries/get-payment-status/get-payment-status.handler'; +export { ListTransactionsQuery } from './queries/list-transactions/list-transactions.query'; +export { ListTransactionsHandler, type TransactionListDto } from './queries/list-transactions/list-transactions.handler'; diff --git a/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts b/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts new file mode 100644 index 0000000..fdbb872 --- /dev/null +++ b/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts @@ -0,0 +1,59 @@ +import { + ForbiddenException, + Inject, + NotFoundException, +} from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { GetPaymentStatusQuery } from './get-payment-status.query'; +import { + PAYMENT_REPOSITORY, + type IPaymentRepository, +} from '../../../domain/repositories/payment.repository'; +import { ErrorCode } from '@modules/shared/domain/error-codes'; + +export interface PaymentStatusDto { + id: string; + provider: string; + type: string; + amountVND: string; + status: string; + providerTxId: string | null; + createdAt: Date; + updatedAt: Date; +} + +@QueryHandler(GetPaymentStatusQuery) +export class GetPaymentStatusHandler implements IQueryHandler { + constructor( + @Inject(PAYMENT_REPOSITORY) + private readonly paymentRepo: IPaymentRepository, + ) {} + + async execute(query: GetPaymentStatusQuery): Promise { + const payment = await this.paymentRepo.findById(query.paymentId); + if (!payment) { + throw new NotFoundException({ + code: ErrorCode.NOT_FOUND, + message: 'Không tìm thấy thanh toán', + }); + } + + if (payment.userId !== query.userId) { + throw new ForbiddenException({ + code: ErrorCode.FORBIDDEN, + message: 'Bạn không có quyền xem thanh toán này', + }); + } + + return { + id: payment.id, + provider: payment.provider, + type: payment.type, + amountVND: payment.amount.value.toString(), + status: payment.status, + providerTxId: payment.providerTxId, + createdAt: payment.createdAt, + updatedAt: payment.updatedAt, + }; + } +} diff --git a/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.query.ts b/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.query.ts new file mode 100644 index 0000000..11d95a6 --- /dev/null +++ b/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.query.ts @@ -0,0 +1,6 @@ +export class GetPaymentStatusQuery { + constructor( + public readonly paymentId: string, + public readonly userId: string, + ) {} +} diff --git a/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts b/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts new file mode 100644 index 0000000..b407f97 --- /dev/null +++ b/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts @@ -0,0 +1,58 @@ +import { Inject } from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { ListTransactionsQuery } from './list-transactions.query'; +import { + PAYMENT_REPOSITORY, + type IPaymentRepository, +} from '../../../domain/repositories/payment.repository'; + +export interface TransactionItemDto { + id: string; + provider: string; + type: string; + amountVND: string; + status: string; + providerTxId: string | null; + createdAt: Date; +} + +export interface TransactionListDto { + items: TransactionItemDto[]; + total: number; + limit: number; + offset: number; +} + +@QueryHandler(ListTransactionsQuery) +export class ListTransactionsHandler implements IQueryHandler { + constructor( + @Inject(PAYMENT_REPOSITORY) + private readonly paymentRepo: IPaymentRepository, + ) {} + + async execute(query: ListTransactionsQuery): Promise { + const limit = Math.min(query.limit ?? 20, 100); + const offset = query.offset ?? 0; + + const { items, total } = await this.paymentRepo.findByUserId(query.userId, { + status: query.status, + limit, + offset, + }); + + return { + items: items.map((payment) => ({ + id: payment.id, + provider: payment.provider, + type: payment.type, + amountVND: payment.amount.value.toString(), + status: payment.status, + providerTxId: payment.providerTxId, + createdAt: payment.createdAt, + })), + total, + limit, + offset, + }; + } +} diff --git a/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.query.ts b/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.query.ts new file mode 100644 index 0000000..97f65d3 --- /dev/null +++ b/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.query.ts @@ -0,0 +1,10 @@ +import { type PaymentStatus } from '@prisma/client'; + +export class ListTransactionsQuery { + constructor( + public readonly userId: string, + public readonly status?: PaymentStatus, + public readonly limit?: number, + public readonly offset?: number, + ) {} +} diff --git a/apps/api/src/modules/payments/domain/__tests__/money.vo.spec.ts b/apps/api/src/modules/payments/domain/__tests__/money.vo.spec.ts new file mode 100644 index 0000000..4e05958 --- /dev/null +++ b/apps/api/src/modules/payments/domain/__tests__/money.vo.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { Money } from '../value-objects/money.vo'; + +describe('Money Value Object', () => { + it('should create a valid money amount', () => { + const result = Money.create(100_000n); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(100_000n); + }); + + it('should reject zero amount', () => { + const result = Money.create(0n); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toContain('lớn hơn 0'); + }); + + it('should reject negative amount', () => { + const result = Money.create(-100n); + expect(result.isErr).toBe(true); + }); + + it('should reject amount exceeding limit', () => { + const result = Money.create(1_000_000_000_000n); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toContain('giới hạn'); + }); + + it('should accept max valid amount', () => { + const result = Money.create(999_999_999_999n); + expect(result.isOk).toBe(true); + }); +}); diff --git a/apps/api/src/modules/payments/domain/__tests__/payment.entity.spec.ts b/apps/api/src/modules/payments/domain/__tests__/payment.entity.spec.ts new file mode 100644 index 0000000..0b08770 --- /dev/null +++ b/apps/api/src/modules/payments/domain/__tests__/payment.entity.spec.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { PaymentEntity } from '../entities/payment.entity'; +import { Money } from '../value-objects/money.vo'; +import { PaymentCreatedEvent } from '../events/payment-created.event'; +import { PaymentCompletedEvent } from '../events/payment-completed.event'; +import { PaymentFailedEvent } from '../events/payment-failed.event'; + +describe('PaymentEntity', () => { + const createPayment = (status?: string) => { + const money = Money.create(500_000n).unwrap(); + const payment = PaymentEntity.createNew( + 'pay-1', + 'user-1', + 'VNPAY', + 'LISTING_FEE', + money, + 'txn-1', + 'idem-key-1', + ); + if (status === 'PROCESSING') { + payment.markProcessing('vnp-txn-123'); + } + if (status === 'COMPLETED') { + payment.markProcessing('vnp-txn-123'); + payment.clearDomainEvents(); + payment.markCompleted({ responseCode: '00' }); + } + return payment; + }; + + it('should create a new payment with domain events', () => { + const money = Money.create(500_000n).unwrap(); + const payment = PaymentEntity.createNew( + 'pay-1', + 'user-1', + 'VNPAY', + 'LISTING_FEE', + money, + 'txn-1', + ); + + expect(payment.id).toBe('pay-1'); + expect(payment.userId).toBe('user-1'); + expect(payment.provider).toBe('VNPAY'); + expect(payment.type).toBe('LISTING_FEE'); + expect(payment.amount.value).toBe(500_000n); + expect(payment.status).toBe('PENDING'); + expect(payment.transactionId).toBe('txn-1'); + expect(payment.providerTxId).toBeNull(); + + const events = payment.domainEvents; + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(PaymentCreatedEvent); + }); + + it('should mark payment as processing', () => { + const payment = createPayment(); + payment.markProcessing('vnp-txn-123'); + + expect(payment.status).toBe('PROCESSING'); + expect(payment.providerTxId).toBe('vnp-txn-123'); + }); + + it('should mark payment as completed from PENDING', () => { + const payment = createPayment(); + payment.clearDomainEvents(); + payment.markCompleted({ responseCode: '00' }); + + expect(payment.status).toBe('COMPLETED'); + expect(payment.callbackData).toEqual({ responseCode: '00' }); + + const events = payment.domainEvents; + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(PaymentCompletedEvent); + }); + + it('should mark payment as completed from PROCESSING', () => { + const payment = createPayment('PROCESSING'); + payment.clearDomainEvents(); + payment.markCompleted({ responseCode: '00' }); + + expect(payment.status).toBe('COMPLETED'); + }); + + it('should mark payment as failed', () => { + const payment = createPayment(); + payment.clearDomainEvents(); + payment.markFailed({ responseCode: '99' }); + + expect(payment.status).toBe('FAILED'); + const events = payment.domainEvents; + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(PaymentFailedEvent); + }); + + it('should not complete an already completed payment', () => { + const payment = createPayment('COMPLETED'); + expect(() => payment.markCompleted({})).toThrow('Cannot complete payment'); + }); + + it('should not fail an already completed payment', () => { + const payment = createPayment('COMPLETED'); + expect(() => payment.markFailed({})).toThrow('Cannot fail payment'); + }); + + it('should mark completed payment as refunded', () => { + const payment = createPayment('COMPLETED'); + payment.markRefunded(); + expect(payment.status).toBe('REFUNDED'); + }); + + it('should not refund a non-completed payment', () => { + const payment = createPayment(); + expect(() => payment.markRefunded()).toThrow('hoàn tiền'); + }); + + it('should store idempotency key', () => { + const money = Money.create(100_000n).unwrap(); + const payment = PaymentEntity.createNew( + 'pay-2', + 'user-1', + 'MOMO', + 'SUBSCRIPTION', + money, + undefined, + 'unique-key-123', + ); + expect(payment.idempotencyKey).toBe('unique-key-123'); + }); +}); diff --git a/apps/api/src/modules/payments/domain/entities/index.ts b/apps/api/src/modules/payments/domain/entities/index.ts new file mode 100644 index 0000000..066c0cf --- /dev/null +++ b/apps/api/src/modules/payments/domain/entities/index.ts @@ -0,0 +1 @@ +export { PaymentEntity, type PaymentProps } from './payment.entity'; diff --git a/apps/api/src/modules/payments/domain/entities/payment.entity.ts b/apps/api/src/modules/payments/domain/entities/payment.entity.ts new file mode 100644 index 0000000..7c924e3 --- /dev/null +++ b/apps/api/src/modules/payments/domain/entities/payment.entity.ts @@ -0,0 +1,125 @@ +import { AggregateRoot } from '@modules/shared/domain/aggregate-root'; +import { + type PaymentProvider, + type PaymentStatus, + type PaymentType, +} from '@prisma/client'; +import { type Money } from '../value-objects/money.vo'; +import { PaymentCreatedEvent } from '../events/payment-created.event'; +import { PaymentCompletedEvent } from '../events/payment-completed.event'; +import { PaymentFailedEvent } from '../events/payment-failed.event'; + +export interface PaymentProps { + userId: string; + transactionId: string | null; + provider: PaymentProvider; + type: PaymentType; + amount: Money; + status: PaymentStatus; + providerTxId: string | null; + callbackData: unknown; + idempotencyKey: string | null; +} + +export class PaymentEntity extends AggregateRoot { + private _userId: string; + private _transactionId: string | null; + private _provider: PaymentProvider; + private _type: PaymentType; + private _amount: Money; + private _status: PaymentStatus; + private _providerTxId: string | null; + private _callbackData: unknown; + private _idempotencyKey: string | null; + + constructor(id: string, props: PaymentProps, createdAt?: Date, updatedAt?: Date) { + super(id, createdAt, updatedAt); + this._userId = props.userId; + this._transactionId = props.transactionId; + this._provider = props.provider; + this._type = props.type; + this._amount = props.amount; + this._status = props.status; + this._providerTxId = props.providerTxId; + this._callbackData = props.callbackData; + this._idempotencyKey = props.idempotencyKey; + } + + get userId(): string { return this._userId; } + get transactionId(): string | null { return this._transactionId; } + get provider(): PaymentProvider { return this._provider; } + get type(): PaymentType { return this._type; } + get amount(): Money { return this._amount; } + get status(): PaymentStatus { return this._status; } + get providerTxId(): string | null { return this._providerTxId; } + get callbackData(): unknown { return this._callbackData; } + get idempotencyKey(): string | null { return this._idempotencyKey; } + + static createNew( + id: string, + userId: string, + provider: PaymentProvider, + type: PaymentType, + amount: Money, + transactionId?: string, + idempotencyKey?: string, + ): PaymentEntity { + const payment = new PaymentEntity(id, { + userId, + transactionId: transactionId ?? null, + provider, + type, + amount, + status: 'PENDING', + providerTxId: null, + callbackData: null, + idempotencyKey: idempotencyKey ?? null, + }); + + payment.addDomainEvent( + new PaymentCreatedEvent(id, userId, provider, type, amount.value), + ); + + return payment; + } + + markProcessing(providerTxId: string): void { + this._status = 'PROCESSING'; + this._providerTxId = providerTxId; + this.updatedAt = new Date(); + } + + markCompleted(callbackData: unknown): void { + if (this._status !== 'PENDING' && this._status !== 'PROCESSING') { + throw new Error(`Cannot complete payment in status ${this._status}`); + } + this._status = 'COMPLETED'; + this._callbackData = callbackData; + this.updatedAt = new Date(); + + this.addDomainEvent( + new PaymentCompletedEvent(this.id, this._userId, this._provider, this._amount.value), + ); + } + + markFailed(callbackData: unknown): void { + if (this._status !== 'PENDING' && this._status !== 'PROCESSING') { + throw new Error(`Cannot fail payment in status ${this._status}`); + } + this._status = 'FAILED'; + this._callbackData = callbackData; + this.updatedAt = new Date(); + + this.addDomainEvent( + new PaymentFailedEvent(this.id, this._userId, this._provider), + ); + } + + markRefunded(): void { + if (this._status !== 'COMPLETED') { + throw new Error('Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất'); + } + this._status = 'REFUNDED'; + this.updatedAt = new Date(); + } +} diff --git a/apps/api/src/modules/payments/domain/events/index.ts b/apps/api/src/modules/payments/domain/events/index.ts new file mode 100644 index 0000000..de72a4b --- /dev/null +++ b/apps/api/src/modules/payments/domain/events/index.ts @@ -0,0 +1,3 @@ +export { PaymentCreatedEvent } from './payment-created.event'; +export { PaymentCompletedEvent } from './payment-completed.event'; +export { PaymentFailedEvent } from './payment-failed.event'; diff --git a/apps/api/src/modules/payments/domain/events/payment-completed.event.ts b/apps/api/src/modules/payments/domain/events/payment-completed.event.ts new file mode 100644 index 0000000..e210ceb --- /dev/null +++ b/apps/api/src/modules/payments/domain/events/payment-completed.event.ts @@ -0,0 +1,14 @@ +import { type DomainEvent } from '@modules/shared/domain/domain-event'; +import { type PaymentProvider } from '@prisma/client'; + +export class PaymentCompletedEvent implements DomainEvent { + readonly eventName = 'payment.completed'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly userId: string, + public readonly provider: PaymentProvider, + public readonly amountVND: bigint, + ) {} +} diff --git a/apps/api/src/modules/payments/domain/events/payment-created.event.ts b/apps/api/src/modules/payments/domain/events/payment-created.event.ts new file mode 100644 index 0000000..544bb57 --- /dev/null +++ b/apps/api/src/modules/payments/domain/events/payment-created.event.ts @@ -0,0 +1,15 @@ +import { type DomainEvent } from '@modules/shared/domain/domain-event'; +import { type PaymentProvider, type PaymentType } from '@prisma/client'; + +export class PaymentCreatedEvent implements DomainEvent { + readonly eventName = 'payment.created'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly userId: string, + public readonly provider: PaymentProvider, + public readonly type: PaymentType, + public readonly amountVND: bigint, + ) {} +} diff --git a/apps/api/src/modules/payments/domain/events/payment-failed.event.ts b/apps/api/src/modules/payments/domain/events/payment-failed.event.ts new file mode 100644 index 0000000..7f1140a --- /dev/null +++ b/apps/api/src/modules/payments/domain/events/payment-failed.event.ts @@ -0,0 +1,13 @@ +import { type DomainEvent } from '@modules/shared/domain/domain-event'; +import { type PaymentProvider } from '@prisma/client'; + +export class PaymentFailedEvent implements DomainEvent { + readonly eventName = 'payment.failed'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly userId: string, + public readonly provider: PaymentProvider, + ) {} +} diff --git a/apps/api/src/modules/payments/domain/repositories/index.ts b/apps/api/src/modules/payments/domain/repositories/index.ts new file mode 100644 index 0000000..8e97d80 --- /dev/null +++ b/apps/api/src/modules/payments/domain/repositories/index.ts @@ -0,0 +1 @@ +export { PAYMENT_REPOSITORY, type IPaymentRepository } from './payment.repository'; diff --git a/apps/api/src/modules/payments/domain/repositories/payment.repository.ts b/apps/api/src/modules/payments/domain/repositories/payment.repository.ts new file mode 100644 index 0000000..9d53bfc --- /dev/null +++ b/apps/api/src/modules/payments/domain/repositories/payment.repository.ts @@ -0,0 +1,17 @@ +import { type PaymentEntity } from '../entities/payment.entity'; +import { type PaymentStatus } from '@prisma/client'; + +export const PAYMENT_REPOSITORY = Symbol('PAYMENT_REPOSITORY'); + +export interface IPaymentRepository { + findById(id: string): Promise; + findByProviderTxId(providerTxId: string): Promise; + findByIdempotencyKey(key: string): Promise; + findByUserId(userId: string, options?: { + status?: PaymentStatus; + limit?: number; + offset?: number; + }): Promise<{ items: PaymentEntity[]; total: number }>; + save(payment: PaymentEntity): Promise; + update(payment: PaymentEntity): Promise; +} diff --git a/apps/api/src/modules/payments/domain/value-objects/index.ts b/apps/api/src/modules/payments/domain/value-objects/index.ts new file mode 100644 index 0000000..2eae253 --- /dev/null +++ b/apps/api/src/modules/payments/domain/value-objects/index.ts @@ -0,0 +1 @@ +export { Money } from './money.vo'; diff --git a/apps/api/src/modules/payments/domain/value-objects/money.vo.ts b/apps/api/src/modules/payments/domain/value-objects/money.vo.ts new file mode 100644 index 0000000..f19304f --- /dev/null +++ b/apps/api/src/modules/payments/domain/value-objects/money.vo.ts @@ -0,0 +1,22 @@ +import { ValueObject } from '@modules/shared/domain/value-object'; +import { Result } from '@modules/shared/domain/result'; + +interface MoneyProps { + amountVND: bigint; +} + +export class Money extends ValueObject { + get value(): bigint { + return this.props.amountVND; + } + + static create(amountVND: bigint): Result { + if (amountVND <= 0n) { + return Result.err('Số tiền phải lớn hơn 0'); + } + if (amountVND > 999_999_999_999n) { + return Result.err('Số tiền vượt quá giới hạn cho phép'); + } + return Result.ok(new Money({ amountVND })); + } +} diff --git a/apps/api/src/modules/payments/index.ts b/apps/api/src/modules/payments/index.ts new file mode 100644 index 0000000..53e7458 --- /dev/null +++ b/apps/api/src/modules/payments/index.ts @@ -0,0 +1,3 @@ +export { PaymentsModule } from './payments.module'; +export { PAYMENT_REPOSITORY, type IPaymentRepository } from './domain/repositories/payment.repository'; +export { PAYMENT_GATEWAY_FACTORY, type IPaymentGatewayFactory } from './infrastructure/services/payment-gateway.interface'; diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts new file mode 100644 index 0000000..c8dc211 --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PaymentGatewayFactory } from '../services/payment-gateway.factory'; +import { VnpayService } from '../services/vnpay.service'; +import { MomoService } from '../services/momo.service'; +import { ZalopayService } from '../services/zalopay.service'; + +describe('PaymentGatewayFactory', () => { + const vnpay = new VnpayService(); + const momo = new MomoService(); + const zalopay = new ZalopayService(); + + const factory = new PaymentGatewayFactory(vnpay, momo, zalopay); + + it('should return VNPay gateway', () => { + const gateway = factory.getGateway('VNPAY'); + expect(gateway).toBe(vnpay); + expect(gateway.provider).toBe('VNPAY'); + }); + + it('should return MoMo gateway', () => { + const gateway = factory.getGateway('MOMO'); + expect(gateway).toBe(momo); + expect(gateway.provider).toBe('MOMO'); + }); + + it('should return ZaloPay gateway', () => { + const gateway = factory.getGateway('ZALOPAY'); + expect(gateway).toBe(zalopay); + expect(gateway.provider).toBe('ZALOPAY'); + }); + + it('should throw for unsupported provider', () => { + expect(() => factory.getGateway('BANK_TRANSFER')).toThrow( + 'Nhà cung cấp thanh toán không được hỗ trợ', + ); + }); +}); diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts new file mode 100644 index 0000000..0b61b8b --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { VnpayService } from '../services/vnpay.service'; + +describe('VnpayService', () => { + let service: VnpayService; + + beforeEach(() => { + vi.stubEnv('VNPAY_TMN_CODE', 'TESTCODE'); + vi.stubEnv('VNPAY_HASH_SECRET', 'TESTSECRET123456TESTSECRET123456'); + service = new VnpayService(); + }); + + it('should create a payment URL', async () => { + const result = await service.createPaymentUrl({ + orderId: 'order-123', + amountVND: 500_000n, + description: 'Test payment', + returnUrl: 'https://goodgo.vn/callback', + ipAddress: '127.0.0.1', + }); + + expect(result.paymentUrl).toContain('vnp_TmnCode=TESTCODE'); + expect(result.paymentUrl).toContain('vnp_Amount=50000000'); + expect(result.paymentUrl).toContain('vnp_TxnRef=order-123'); + expect(result.paymentUrl).toContain('vnp_SecureHash='); + expect(result.providerTxId).toBe('order-123'); + }); + + it('should verify a valid callback', () => { + // First create a payment URL to get the hash + const params: Record = { + vnp_TmnCode: 'TESTCODE', + vnp_Amount: '50000000', + vnp_TxnRef: 'order-123', + vnp_ResponseCode: '00', + vnp_TransactionNo: 'VNP123', + vnp_OrderInfo: 'Test', + }; + + // Generate valid hash + const crypto = require('crypto'); + const sorted = Object.keys(params) + .sort() + .reduce((acc: Record, key) => { + acc[key] = params[key]!; + return acc; + }, {}); + const signData = new URLSearchParams(sorted).toString(); + const hmac = crypto.createHmac('sha512', 'TESTSECRET123456TESTSECRET123456'); + const signed = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex'); + + params['vnp_SecureHash'] = signed; + + const result = service.verifyCallback(params); + + expect(result.isValid).toBe(true); + expect(result.isSuccess).toBe(true); + expect(result.orderId).toBe('order-123'); + expect(result.providerTxId).toBe('VNP123'); + }); + + it('should reject invalid callback signature', () => { + const params: Record = { + vnp_TmnCode: 'TESTCODE', + vnp_Amount: '50000000', + vnp_TxnRef: 'order-123', + vnp_ResponseCode: '00', + vnp_TransactionNo: 'VNP123', + vnp_SecureHash: 'invalid-hash', + }; + + const result = service.verifyCallback(params); + expect(result.isValid).toBe(false); + expect(result.isSuccess).toBe(false); + }); + + it('should detect failed payment in callback', () => { + const params: Record = { + vnp_TmnCode: 'TESTCODE', + vnp_Amount: '50000000', + vnp_TxnRef: 'order-123', + vnp_ResponseCode: '24', // User cancelled + vnp_TransactionNo: 'VNP123', + }; + + const crypto = require('crypto'); + const sorted = Object.keys(params) + .sort() + .reduce((acc: Record, key) => { + acc[key] = params[key]!; + return acc; + }, {}); + const signData = new URLSearchParams(sorted).toString(); + const hmac = crypto.createHmac('sha512', 'TESTSECRET123456TESTSECRET123456'); + const signed = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex'); + + params['vnp_SecureHash'] = signed; + + const result = service.verifyCallback(params); + expect(result.isValid).toBe(true); + expect(result.isSuccess).toBe(false); + }); +}); diff --git a/apps/api/src/modules/payments/infrastructure/repositories/index.ts b/apps/api/src/modules/payments/infrastructure/repositories/index.ts new file mode 100644 index 0000000..ff1252c --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/repositories/index.ts @@ -0,0 +1 @@ +export { PrismaPaymentRepository } from './prisma-payment.repository'; diff --git a/apps/api/src/modules/payments/infrastructure/repositories/prisma-payment.repository.ts b/apps/api/src/modules/payments/infrastructure/repositories/prisma-payment.repository.ts new file mode 100644 index 0000000..6fb2eac --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/repositories/prisma-payment.repository.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type Payment as PrismaPayment, type PaymentStatus } from '@prisma/client'; +import { type IPaymentRepository } from '../../domain/repositories/payment.repository'; +import { PaymentEntity, type PaymentProps } from '../../domain/entities/payment.entity'; +import { Money } from '../../domain/value-objects/money.vo'; + +@Injectable() +export class PrismaPaymentRepository implements IPaymentRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const payment = await this.prisma.payment.findUnique({ where: { id } }); + return payment ? this.toDomain(payment) : null; + } + + async findByProviderTxId(providerTxId: string): Promise { + const payment = await this.prisma.payment.findFirst({ + where: { providerTxId }, + }); + return payment ? this.toDomain(payment) : null; + } + + async findByIdempotencyKey(key: string): Promise { + const payment = await this.prisma.payment.findFirst({ + where: { + callbackData: { + path: ['idempotencyKey'], + equals: key, + }, + }, + }); + return payment ? this.toDomain(payment) : null; + } + + async findByUserId( + userId: string, + options?: { status?: PaymentStatus; limit?: number; offset?: number }, + ): Promise<{ items: PaymentEntity[]; total: number }> { + const where = { + userId, + ...(options?.status ? { status: options.status } : {}), + }; + + const [payments, total] = await Promise.all([ + this.prisma.payment.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: options?.limit ?? 20, + skip: options?.offset ?? 0, + }), + this.prisma.payment.count({ where }), + ]); + + return { + items: payments.map((p) => this.toDomain(p)), + total, + }; + } + + async save(entity: PaymentEntity): Promise { + await this.prisma.payment.create({ + data: { + id: entity.id, + userId: entity.userId, + transactionId: entity.transactionId, + provider: entity.provider, + type: entity.type, + amountVND: entity.amount.value, + status: entity.status, + providerTxId: entity.providerTxId, + callbackData: entity.callbackData as any, + }, + }); + } + + async update(entity: PaymentEntity): Promise { + await this.prisma.payment.update({ + where: { id: entity.id }, + data: { + status: entity.status, + providerTxId: entity.providerTxId, + callbackData: entity.callbackData as any, + }, + }); + } + + private toDomain(raw: PrismaPayment): PaymentEntity { + const amount = Money.create(raw.amountVND).unwrap(); + + const props: PaymentProps = { + userId: raw.userId, + transactionId: raw.transactionId, + provider: raw.provider, + type: raw.type, + amount, + status: raw.status, + providerTxId: raw.providerTxId, + callbackData: raw.callbackData, + idempotencyKey: (raw.callbackData as any)?.idempotencyKey ?? null, + }; + + return new PaymentEntity(raw.id, props, raw.createdAt, raw.updatedAt); + } +} diff --git a/apps/api/src/modules/payments/infrastructure/services/index.ts b/apps/api/src/modules/payments/infrastructure/services/index.ts new file mode 100644 index 0000000..a158a2f --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/services/index.ts @@ -0,0 +1,14 @@ +export { + PAYMENT_GATEWAY_FACTORY, + type IPaymentGateway, + type IPaymentGatewayFactory, + type CreatePaymentUrlParams, + type CreatePaymentUrlResult, + type CallbackVerifyResult, + type RefundParams, + type RefundResult, +} from './payment-gateway.interface'; +export { PaymentGatewayFactory } from './payment-gateway.factory'; +export { VnpayService } from './vnpay.service'; +export { MomoService } from './momo.service'; +export { ZalopayService } from './zalopay.service'; diff --git a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts new file mode 100644 index 0000000..7dffcda --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts @@ -0,0 +1,198 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { type PaymentProvider } from '@prisma/client'; +import * as crypto from 'crypto'; +import { + type IPaymentGateway, + type CreatePaymentUrlParams, + type CreatePaymentUrlResult, + type CallbackVerifyResult, + type RefundParams, + type RefundResult, +} from './payment-gateway.interface'; + +@Injectable() +export class MomoService implements IPaymentGateway { + private readonly logger = new Logger(MomoService.name); + readonly provider: PaymentProvider = 'MOMO'; + + private get partnerCode(): string { + return process.env['MOMO_PARTNER_CODE'] ?? ''; + } + + private get accessKey(): string { + return process.env['MOMO_ACCESS_KEY'] ?? ''; + } + + private get secretKey(): string { + return process.env['MOMO_SECRET_KEY'] ?? ''; + } + + private get endpoint(): string { + return process.env['MOMO_ENDPOINT'] ?? 'https://test-payment.momo.vn/v2/gateway/api'; + } + + async createPaymentUrl(params: CreatePaymentUrlParams): Promise { + const requestId = crypto.randomUUID(); + const requestType = 'payWithMethod'; + const extraData = ''; + const autoCapture = true; + const lang = 'vi'; + const amount = params.amountVND.toString(); + + const rawSignature = [ + `accessKey=${this.accessKey}`, + `amount=${amount}`, + `extraData=${extraData}`, + `ipnUrl=${params.returnUrl}`, + `orderId=${params.orderId}`, + `orderInfo=${params.description}`, + `partnerCode=${this.partnerCode}`, + `redirectUrl=${params.returnUrl}`, + `requestId=${requestId}`, + `requestType=${requestType}`, + ].join('&'); + + const signature = crypto + .createHmac('sha256', this.secretKey) + .update(rawSignature) + .digest('hex'); + + const body = { + partnerCode: this.partnerCode, + partnerName: 'GoodGo', + storeId: 'GoodGo', + requestId, + amount: Number(amount), + orderId: params.orderId, + orderInfo: params.description, + redirectUrl: params.returnUrl, + ipnUrl: params.returnUrl, + lang, + requestType, + autoCapture, + extraData, + signature, + }; + + try { + const response = await fetch(`${this.endpoint}/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const result = await response.json() as { resultCode: number; payUrl: string }; + + if (result.resultCode !== 0) { + throw new Error(`MoMo create payment failed: resultCode=${result.resultCode}`); + } + + this.logger.log(`MoMo payment URL created for order ${params.orderId}`); + + return { + paymentUrl: result.payUrl, + providerTxId: params.orderId, + }; + } catch (error) { + this.logger.error(`MoMo createPaymentUrl error: ${error}`); + throw error; + } + } + + verifyCallback(data: Record): CallbackVerifyResult { + const orderId = data['orderId'] ?? ''; + const providerTxId = data['transId'] ?? ''; + const resultCode = data['resultCode']; + const receivedSignature = data['signature'] ?? ''; + + const rawSignature = [ + `accessKey=${this.accessKey}`, + `amount=${data['amount']}`, + `extraData=${data['extraData'] ?? ''}`, + `message=${data['message'] ?? ''}`, + `orderId=${orderId}`, + `orderInfo=${data['orderInfo'] ?? ''}`, + `orderType=${data['orderType'] ?? ''}`, + `partnerCode=${this.partnerCode}`, + `payType=${data['payType'] ?? ''}`, + `requestId=${data['requestId'] ?? ''}`, + `responseTime=${data['responseTime'] ?? ''}`, + `resultCode=${resultCode}`, + `transId=${providerTxId}`, + ].join('&'); + + const expectedSignature = crypto + .createHmac('sha256', this.secretKey) + .update(rawSignature) + .digest('hex'); + + const isValid = receivedSignature === expectedSignature; + const isSuccess = isValid && resultCode === '0'; + + this.logger.log( + `MoMo callback verified: orderId=${orderId}, valid=${isValid}, success=${isSuccess}`, + ); + + return { + isValid, + orderId, + providerTxId, + isSuccess, + rawData: data, + }; + } + + async refund(params: RefundParams): Promise { + const requestId = crypto.randomUUID(); + const amount = params.amountVND.toString(); + + const rawSignature = [ + `accessKey=${this.accessKey}`, + `amount=${amount}`, + `description=${params.reason}`, + `orderId=${requestId}`, + `partnerCode=${this.partnerCode}`, + `requestId=${requestId}`, + `transId=${params.providerTxId}`, + ].join('&'); + + const signature = crypto + .createHmac('sha256', this.secretKey) + .update(rawSignature) + .digest('hex'); + + const body = { + partnerCode: this.partnerCode, + orderId: requestId, + requestId, + amount: Number(amount), + transId: Number(params.providerTxId), + lang: 'vi', + description: params.reason, + signature, + }; + + try { + const response = await fetch(`${this.endpoint}/refund`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const result = await response.json() as { resultCode: number }; + const success = result.resultCode === 0; + + this.logger.log( + `MoMo refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`, + ); + + return { + success, + refundTxId: success ? requestId : null, + }; + } catch (error) { + this.logger.error(`MoMo refund error: ${error}`); + return { success: false, refundTxId: null }; + } + } +} diff --git a/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts b/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts new file mode 100644 index 0000000..5be0898 --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts @@ -0,0 +1,34 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { type PaymentProvider } from '@prisma/client'; +import { + type IPaymentGateway, + type IPaymentGatewayFactory, +} from './payment-gateway.interface'; +import { VnpayService } from './vnpay.service'; +import { MomoService } from './momo.service'; +import { ZalopayService } from './zalopay.service'; + +@Injectable() +export class PaymentGatewayFactory implements IPaymentGatewayFactory { + private readonly gateways: Map; + + constructor( + private readonly vnpay: VnpayService, + private readonly momo: MomoService, + private readonly zalopay: ZalopayService, + ) { + this.gateways = new Map([ + ['VNPAY', vnpay], + ['MOMO', momo], + ['ZALOPAY', zalopay], + ]); + } + + getGateway(provider: PaymentProvider): IPaymentGateway { + const gateway = this.gateways.get(provider); + if (!gateway) { + throw new BadRequestException(`Nhà cung cấp thanh toán không được hỗ trợ: ${provider}`); + } + return gateway; + } +} diff --git a/apps/api/src/modules/payments/infrastructure/services/payment-gateway.interface.ts b/apps/api/src/modules/payments/infrastructure/services/payment-gateway.interface.ts new file mode 100644 index 0000000..39147c1 --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/services/payment-gateway.interface.ts @@ -0,0 +1,46 @@ +import { type PaymentProvider } from '@prisma/client'; + +export const PAYMENT_GATEWAY_FACTORY = Symbol('PAYMENT_GATEWAY_FACTORY'); + +export interface CreatePaymentUrlParams { + orderId: string; + amountVND: bigint; + description: string; + returnUrl: string; + ipAddress: string; +} + +export interface CreatePaymentUrlResult { + paymentUrl: string; + providerTxId: string; +} + +export interface CallbackVerifyResult { + isValid: boolean; + orderId: string; + providerTxId: string; + isSuccess: boolean; + rawData: Record; +} + +export interface RefundParams { + providerTxId: string; + amountVND: bigint; + reason: string; +} + +export interface RefundResult { + success: boolean; + refundTxId: string | null; +} + +export interface IPaymentGateway { + readonly provider: PaymentProvider; + createPaymentUrl(params: CreatePaymentUrlParams): Promise; + verifyCallback(data: Record): CallbackVerifyResult; + refund(params: RefundParams): Promise; +} + +export interface IPaymentGatewayFactory { + getGateway(provider: PaymentProvider): IPaymentGateway; +} diff --git a/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts b/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts new file mode 100644 index 0000000..7fdadd2 --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts @@ -0,0 +1,181 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { type PaymentProvider } from '@prisma/client'; +import * as crypto from 'crypto'; +import { + type IPaymentGateway, + type CreatePaymentUrlParams, + type CreatePaymentUrlResult, + type CallbackVerifyResult, + type RefundParams, + type RefundResult, +} from './payment-gateway.interface'; + +@Injectable() +export class VnpayService implements IPaymentGateway { + private readonly logger = new Logger(VnpayService.name); + readonly provider: PaymentProvider = 'VNPAY'; + + private get tmnCode(): string { + return process.env['VNPAY_TMN_CODE'] ?? ''; + } + + private get hashSecret(): string { + return process.env['VNPAY_HASH_SECRET'] ?? ''; + } + + private get baseUrl(): string { + return process.env['VNPAY_BASE_URL'] ?? 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html'; + } + + private get apiUrl(): string { + return process.env['VNPAY_API_URL'] ?? 'https://sandbox.vnpayment.vn/merchant_webapi/api/transaction'; + } + + async createPaymentUrl(params: CreatePaymentUrlParams): Promise { + const now = new Date(); + const createDate = this.formatDate(now); + const expireDate = this.formatDate(new Date(now.getTime() + 15 * 60 * 1000)); + + const vnpParams: Record = { + vnp_Version: '2.1.0', + vnp_Command: 'pay', + vnp_TmnCode: this.tmnCode, + vnp_Locale: 'vn', + vnp_CurrCode: 'VND', + vnp_TxnRef: params.orderId, + vnp_OrderInfo: params.description, + vnp_OrderType: 'other', + vnp_Amount: (params.amountVND * 100n).toString(), + vnp_ReturnUrl: params.returnUrl, + vnp_IpAddr: params.ipAddress, + vnp_CreateDate: createDate, + vnp_ExpireDate: expireDate, + }; + + const sortedParams = this.sortObject(vnpParams); + const signData = new URLSearchParams(sortedParams).toString(); + const hmac = crypto.createHmac('sha512', this.hashSecret); + const signed = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex'); + + sortedParams['vnp_SecureHash'] = signed; + + const paymentUrl = `${this.baseUrl}?${new URLSearchParams(sortedParams).toString()}`; + + this.logger.log(`VNPay payment URL created for order ${params.orderId}`); + + return { + paymentUrl, + providerTxId: params.orderId, + }; + } + + verifyCallback(data: Record): CallbackVerifyResult { + const secureHash = data['vnp_SecureHash']; + const orderId = data['vnp_TxnRef'] ?? ''; + const providerTxId = data['vnp_TransactionNo'] ?? ''; + + const verifyParams = { ...data }; + delete verifyParams['vnp_SecureHash']; + delete verifyParams['vnp_SecureHashType']; + + const sortedParams = this.sortObject(verifyParams); + const signData = new URLSearchParams(sortedParams).toString(); + const hmac = crypto.createHmac('sha512', this.hashSecret); + const checkSum = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex'); + + const isValid = secureHash === checkSum; + const responseCode = data['vnp_ResponseCode']; + const isSuccess = isValid && responseCode === '00'; + + this.logger.log( + `VNPay callback verified: orderId=${orderId}, valid=${isValid}, success=${isSuccess}`, + ); + + return { + isValid, + orderId, + providerTxId, + isSuccess, + rawData: data, + }; + } + + async refund(params: RefundParams): Promise { + const now = new Date(); + const requestId = crypto.randomUUID(); + + const refundData: Record = { + vnp_RequestId: requestId, + vnp_Version: '2.1.0', + vnp_Command: 'refund', + vnp_TmnCode: this.tmnCode, + vnp_TransactionType: '02', + vnp_TxnRef: params.providerTxId, + vnp_Amount: (params.amountVND * 100n).toString(), + vnp_OrderInfo: params.reason, + vnp_TransactionDate: this.formatDate(now), + vnp_CreateDate: this.formatDate(now), + vnp_CreateBy: 'system', + vnp_IpAddr: '127.0.0.1', + }; + + const signData = [ + refundData['vnp_RequestId'], + refundData['vnp_Version'], + refundData['vnp_Command'], + refundData['vnp_TmnCode'], + refundData['vnp_TransactionType'], + refundData['vnp_TxnRef'], + refundData['vnp_Amount'], + refundData['vnp_TransactionDate'], + refundData['vnp_CreateBy'], + refundData['vnp_CreateDate'], + refundData['vnp_IpAddr'], + refundData['vnp_OrderInfo'], + ].join('|'); + + const hmac = crypto.createHmac('sha512', this.hashSecret); + refundData['vnp_SecureHash'] = hmac + .update(Buffer.from(signData, 'utf-8')) + .digest('hex'); + + try { + const response = await fetch(this.apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(refundData), + }); + + const result = await response.json() as Record; + const success = result['vnp_ResponseCode'] === '00'; + + this.logger.log( + `VNPay refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`, + ); + + return { + success, + refundTxId: success ? requestId : null, + }; + } catch (error) { + this.logger.error(`VNPay refund error: ${error}`); + return { success: false, refundTxId: null }; + } + } + + private formatDate(date: Date): string { + return date + .toISOString() + .replace(/[-:T]/g, '') + .slice(0, 14); + } + + private sortObject(obj: Record): Record { + const sorted: Record = {}; + const keys = Object.keys(obj).sort(); + for (const key of keys) { + sorted[key] = obj[key]!; + } + return sorted; + } +} diff --git a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts new file mode 100644 index 0000000..575ac54 --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts @@ -0,0 +1,200 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { type PaymentProvider } from '@prisma/client'; +import * as crypto from 'crypto'; +import { + type IPaymentGateway, + type CreatePaymentUrlParams, + type CreatePaymentUrlResult, + type CallbackVerifyResult, + type RefundParams, + type RefundResult, +} from './payment-gateway.interface'; + +@Injectable() +export class ZalopayService implements IPaymentGateway { + private readonly logger = new Logger(ZalopayService.name); + readonly provider: PaymentProvider = 'ZALOPAY'; + + private get appId(): string { + return process.env['ZALOPAY_APP_ID'] ?? ''; + } + + private get key1(): string { + return process.env['ZALOPAY_KEY1'] ?? ''; + } + + private get key2(): string { + return process.env['ZALOPAY_KEY2'] ?? ''; + } + + private get endpoint(): string { + return process.env['ZALOPAY_ENDPOINT'] ?? 'https://sb-openapi.zalopay.vn/v2'; + } + + async createPaymentUrl(params: CreatePaymentUrlParams): Promise { + const now = new Date(); + const appTransId = `${this.formatYYMMDD(now)}_${params.orderId}`; + const appTime = now.getTime(); + const amount = Number(params.amountVND); + const embedData = JSON.stringify({ redirecturl: params.returnUrl }); + const items = JSON.stringify([]); + + const data = [ + this.appId, + appTransId, + appTime, + amount, + embedData, + items, + ].join('|'); + + const mac = crypto + .createHmac('sha256', this.key1) + .update(data) + .digest('hex'); + + const body = { + app_id: Number(this.appId), + app_trans_id: appTransId, + app_user: params.orderId, + app_time: appTime, + amount, + item: items, + description: params.description, + embed_data: embedData, + callback_url: params.returnUrl, + mac, + }; + + try { + const response = await fetch(`${this.endpoint}/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const result = await response.json() as { + return_code: number; + order_url: string; + zp_trans_token: string; + }; + + if (result.return_code !== 1) { + throw new Error(`ZaloPay create payment failed: return_code=${result.return_code}`); + } + + this.logger.log(`ZaloPay payment URL created for order ${params.orderId}`); + + return { + paymentUrl: result.order_url, + providerTxId: appTransId, + }; + } catch (error) { + this.logger.error(`ZaloPay createPaymentUrl error: ${error}`); + throw error; + } + } + + verifyCallback(data: Record): CallbackVerifyResult { + const dataStr = data['data'] ?? ''; + const reqMac = data['mac'] ?? ''; + + const mac = crypto + .createHmac('sha256', this.key2) + .update(dataStr) + .digest('hex'); + + const isValid = reqMac === mac; + + let parsedData: Record = {}; + let orderId = ''; + let providerTxId = ''; + + if (isValid) { + try { + parsedData = JSON.parse(dataStr); + orderId = String(parsedData['app_trans_id'] ?? ''); + providerTxId = String(parsedData['zp_trans_id'] ?? ''); + } catch { + return { + isValid: false, + orderId: '', + providerTxId: '', + isSuccess: false, + rawData: data, + }; + } + } + + this.logger.log( + `ZaloPay callback verified: orderId=${orderId}, valid=${isValid}`, + ); + + return { + isValid, + orderId, + providerTxId, + isSuccess: isValid, + rawData: { ...data, parsed: parsedData }, + }; + } + + async refund(params: RefundParams): Promise { + const now = Date.now(); + const mRefundId = `${this.formatYYMMDD(new Date())}_${this.appId}_${now}`; + const amount = Number(params.amountVND); + + const data = [ + this.appId, + params.providerTxId, + amount, + params.reason, + now, + ].join('|'); + + const mac = crypto + .createHmac('sha256', this.key1) + .update(data) + .digest('hex'); + + const body = { + app_id: Number(this.appId), + zp_trans_id: params.providerTxId, + m_refund_id: mRefundId, + amount, + timestamp: now, + description: params.reason, + mac, + }; + + try { + const response = await fetch(`${this.endpoint}/refund`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const result = await response.json() as { return_code: number }; + const success = result.return_code === 1; + + this.logger.log( + `ZaloPay refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`, + ); + + return { + success, + refundTxId: success ? mRefundId : null, + }; + } catch (error) { + this.logger.error(`ZaloPay refund error: ${error}`); + return { success: false, refundTxId: null }; + } + } + + private formatYYMMDD(date: Date): string { + const yy = date.getFullYear().toString().slice(-2); + const mm = (date.getMonth() + 1).toString().padStart(2, '0'); + const dd = date.getDate().toString().padStart(2, '0'); + return `${yy}${mm}${dd}`; + } +} diff --git a/apps/api/src/modules/payments/payments.module.ts b/apps/api/src/modules/payments/payments.module.ts new file mode 100644 index 0000000..4e7a3df --- /dev/null +++ b/apps/api/src/modules/payments/payments.module.ts @@ -0,0 +1,54 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +// Domain +import { PAYMENT_REPOSITORY } from './domain/repositories/payment.repository'; + +// Infrastructure +import { PrismaPaymentRepository } from './infrastructure/repositories/prisma-payment.repository'; +import { PAYMENT_GATEWAY_FACTORY } from './infrastructure/services/payment-gateway.interface'; +import { PaymentGatewayFactory } from './infrastructure/services/payment-gateway.factory'; +import { VnpayService } from './infrastructure/services/vnpay.service'; +import { MomoService } from './infrastructure/services/momo.service'; +import { ZalopayService } from './infrastructure/services/zalopay.service'; + +// Application — Commands +import { CreatePaymentHandler } from './application/commands/create-payment/create-payment.handler'; +import { HandleCallbackHandler } from './application/commands/handle-callback/handle-callback.handler'; +import { RefundPaymentHandler } from './application/commands/refund-payment/refund-payment.handler'; + +// Application — Queries +import { GetPaymentStatusHandler } from './application/queries/get-payment-status/get-payment-status.handler'; +import { ListTransactionsHandler } from './application/queries/list-transactions/list-transactions.handler'; + +// Presentation +import { PaymentsController } from './presentation/controllers/payments.controller'; + +const CommandHandlers = [ + CreatePaymentHandler, + HandleCallbackHandler, + RefundPaymentHandler, +]; + +const QueryHandlers = [GetPaymentStatusHandler, ListTransactionsHandler]; + +@Module({ + imports: [CqrsModule], + controllers: [PaymentsController], + providers: [ + // Repositories + { provide: PAYMENT_REPOSITORY, useClass: PrismaPaymentRepository }, + + // Gateway Services + VnpayService, + MomoService, + ZalopayService, + { provide: PAYMENT_GATEWAY_FACTORY, useClass: PaymentGatewayFactory }, + + // CQRS + ...CommandHandlers, + ...QueryHandlers, + ], + exports: [PAYMENT_REPOSITORY, PAYMENT_GATEWAY_FACTORY], +}) +export class PaymentsModule {} diff --git a/apps/api/src/modules/payments/presentation/controllers/index.ts b/apps/api/src/modules/payments/presentation/controllers/index.ts new file mode 100644 index 0000000..f43f008 --- /dev/null +++ b/apps/api/src/modules/payments/presentation/controllers/index.ts @@ -0,0 +1 @@ +export { PaymentsController } from './payments.controller'; diff --git a/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts b/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts new file mode 100644 index 0000000..b3a9f56 --- /dev/null +++ b/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Get, + Ip, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard'; +import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard'; +import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator'; +import { Roles } from '@modules/auth/presentation/decorators/roles.decorator'; +import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service'; +import { CreatePaymentCommand } from '../../application/commands/create-payment/create-payment.command'; +import { HandleCallbackCommand } from '../../application/commands/handle-callback/handle-callback.command'; +import { RefundPaymentCommand } from '../../application/commands/refund-payment/refund-payment.command'; +import { GetPaymentStatusQuery } from '../../application/queries/get-payment-status/get-payment-status.query'; +import { ListTransactionsQuery } from '../../application/queries/list-transactions/list-transactions.query'; +import { type CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler'; +import { type HandleCallbackResult } from '../../application/commands/handle-callback/handle-callback.handler'; +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 { type TransactionListDto } from '../../application/queries/list-transactions/list-transactions.handler'; +import { CreatePaymentDto } from '../dto/create-payment.dto'; +import { RefundPaymentDto } from '../dto/refund-payment.dto'; +import { ListTransactionsDto } from '../dto/list-transactions.dto'; +import { type PaymentProvider } from '@prisma/client'; + +@Controller('payments') +export class PaymentsController { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + @UseGuards(JwtAuthGuard) + @Post() + async createPayment( + @Body() dto: CreatePaymentDto, + @CurrentUser() user: JwtPayload, + @Ip() ip: string, + ): Promise { + return this.commandBus.execute( + new CreatePaymentCommand( + user.sub, + dto.provider, + dto.type, + dto.amountVND, + dto.description, + dto.returnUrl, + ip || '127.0.0.1', + dto.transactionId, + dto.idempotencyKey, + ), + ); + } + + @Post('callback/:provider') + async handleCallback( + @Param('provider') provider: string, + @Body() callbackData: Record, + @Query() queryData: Record, + ): Promise { + 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), + ); + } + + @UseGuards(JwtAuthGuard) + @Get(':id') + async getPaymentStatus( + @Param('id') id: string, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.queryBus.execute(new GetPaymentStatusQuery(id, user.sub)); + } + + @UseGuards(JwtAuthGuard) + @Get() + async listTransactions( + @CurrentUser() user: JwtPayload, + @Query() dto: ListTransactionsDto, + ): Promise { + return this.queryBus.execute( + new ListTransactionsQuery(user.sub, dto.status, dto.limit, dto.offset), + ); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN') + @Post(':id/refund') + async refundPayment( + @Param('id') id: string, + @Body() dto: RefundPaymentDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new RefundPaymentCommand(id, dto.reason, user.sub), + ); + } +} diff --git a/apps/api/src/modules/payments/presentation/dto/create-payment.dto.ts b/apps/api/src/modules/payments/presentation/dto/create-payment.dto.ts new file mode 100644 index 0000000..ba5e471 --- /dev/null +++ b/apps/api/src/modules/payments/presentation/dto/create-payment.dto.ts @@ -0,0 +1,35 @@ +import { + IsEnum, + IsOptional, + IsString, + IsUrl, + MinLength, +} from 'class-validator'; +import { Transform } from 'class-transformer'; +import { PaymentProvider, PaymentType } from '@prisma/client'; + +export class CreatePaymentDto { + @IsEnum(PaymentProvider) + provider!: PaymentProvider; + + @IsEnum(PaymentType) + type!: PaymentType; + + @Transform(({ value }) => BigInt(value)) + amountVND!: bigint; + + @IsString() + @MinLength(1) + description!: string; + + @IsUrl() + returnUrl!: string; + + @IsOptional() + @IsString() + transactionId?: string; + + @IsOptional() + @IsString() + idempotencyKey?: string; +} diff --git a/apps/api/src/modules/payments/presentation/dto/index.ts b/apps/api/src/modules/payments/presentation/dto/index.ts new file mode 100644 index 0000000..38f4cb4 --- /dev/null +++ b/apps/api/src/modules/payments/presentation/dto/index.ts @@ -0,0 +1,3 @@ +export { CreatePaymentDto } from './create-payment.dto'; +export { RefundPaymentDto } from './refund-payment.dto'; +export { ListTransactionsDto } from './list-transactions.dto'; diff --git a/apps/api/src/modules/payments/presentation/dto/list-transactions.dto.ts b/apps/api/src/modules/payments/presentation/dto/list-transactions.dto.ts new file mode 100644 index 0000000..0eff483 --- /dev/null +++ b/apps/api/src/modules/payments/presentation/dto/list-transactions.dto.ts @@ -0,0 +1,19 @@ +import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { PaymentStatus } from '@prisma/client'; + +export class ListTransactionsDto { + @IsOptional() + @IsEnum(PaymentStatus) + status?: PaymentStatus; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; +} diff --git a/apps/api/src/modules/payments/presentation/dto/refund-payment.dto.ts b/apps/api/src/modules/payments/presentation/dto/refund-payment.dto.ts new file mode 100644 index 0000000..c0330d1 --- /dev/null +++ b/apps/api/src/modules/payments/presentation/dto/refund-payment.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class RefundPaymentDto { + @IsString() + @MinLength(1) + reason!: string; +}