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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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<CreatePaymentCommand> {
|
||||
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<CreatePaymentResult> {
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type PaymentProvider } from '@prisma/client';
|
||||
|
||||
export class HandleCallbackCommand {
|
||||
constructor(
|
||||
public readonly provider: PaymentProvider,
|
||||
public readonly callbackData: Record<string, string>,
|
||||
) {}
|
||||
}
|
||||
@@ -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<HandleCallbackCommand> {
|
||||
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<HandleCallbackResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class RefundPaymentCommand {
|
||||
constructor(
|
||||
public readonly paymentId: string,
|
||||
public readonly reason: string,
|
||||
public readonly requestedBy: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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<RefundPaymentCommand> {
|
||||
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<RefundPaymentResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
11
apps/api/src/modules/payments/application/index.ts
Normal file
11
apps/api/src/modules/payments/application/index.ts
Normal file
@@ -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';
|
||||
@@ -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<GetPaymentStatusQuery> {
|
||||
constructor(
|
||||
@Inject(PAYMENT_REPOSITORY)
|
||||
private readonly paymentRepo: IPaymentRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetPaymentStatusQuery): Promise<PaymentStatusDto> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetPaymentStatusQuery {
|
||||
constructor(
|
||||
public readonly paymentId: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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<ListTransactionsQuery> {
|
||||
constructor(
|
||||
@Inject(PAYMENT_REPOSITORY)
|
||||
private readonly paymentRepo: IPaymentRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: ListTransactionsQuery): Promise<TransactionListDto> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user