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,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