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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
1
apps/api/src/modules/payments/domain/entities/index.ts
Normal file
1
apps/api/src/modules/payments/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PaymentEntity, type PaymentProps } from './payment.entity';
|
||||
125
apps/api/src/modules/payments/domain/entities/payment.entity.ts
Normal file
125
apps/api/src/modules/payments/domain/entities/payment.entity.ts
Normal file
@@ -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<string> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
3
apps/api/src/modules/payments/domain/events/index.ts
Normal file
3
apps/api/src/modules/payments/domain/events/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PaymentCreatedEvent } from './payment-created.event';
|
||||
export { PaymentCompletedEvent } from './payment-completed.event';
|
||||
export { PaymentFailedEvent } from './payment-failed.event';
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PAYMENT_REPOSITORY, type IPaymentRepository } from './payment.repository';
|
||||
@@ -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<PaymentEntity | null>;
|
||||
findByProviderTxId(providerTxId: string): Promise<PaymentEntity | null>;
|
||||
findByIdempotencyKey(key: string): Promise<PaymentEntity | null>;
|
||||
findByUserId(userId: string, options?: {
|
||||
status?: PaymentStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ items: PaymentEntity[]; total: number }>;
|
||||
save(payment: PaymentEntity): Promise<void>;
|
||||
update(payment: PaymentEntity): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Money } from './money.vo';
|
||||
@@ -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<MoneyProps> {
|
||||
get value(): bigint {
|
||||
return this.props.amountVND;
|
||||
}
|
||||
|
||||
static create(amountVND: bigint): Result<Money, string> {
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
3
apps/api/src/modules/payments/index.ts
Normal file
3
apps/api/src/modules/payments/index.ts
Normal file
@@ -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';
|
||||
@@ -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ợ',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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<string, string>, 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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string>, 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { PrismaPaymentRepository } from './prisma-payment.repository';
|
||||
@@ -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<PaymentEntity | null> {
|
||||
const payment = await this.prisma.payment.findUnique({ where: { id } });
|
||||
return payment ? this.toDomain(payment) : null;
|
||||
}
|
||||
|
||||
async findByProviderTxId(providerTxId: string): Promise<PaymentEntity | null> {
|
||||
const payment = await this.prisma.payment.findFirst({
|
||||
where: { providerTxId },
|
||||
});
|
||||
return payment ? this.toDomain(payment) : null;
|
||||
}
|
||||
|
||||
async findByIdempotencyKey(key: string): Promise<PaymentEntity | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<CreatePaymentUrlResult> {
|
||||
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<string, string>): 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<RefundResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PaymentProvider, IPaymentGateway>;
|
||||
|
||||
constructor(
|
||||
private readonly vnpay: VnpayService,
|
||||
private readonly momo: MomoService,
|
||||
private readonly zalopay: ZalopayService,
|
||||
) {
|
||||
this.gateways = new Map<PaymentProvider, IPaymentGateway>([
|
||||
['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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<CreatePaymentUrlResult>;
|
||||
verifyCallback(data: Record<string, string>): CallbackVerifyResult;
|
||||
refund(params: RefundParams): Promise<RefundResult>;
|
||||
}
|
||||
|
||||
export interface IPaymentGatewayFactory {
|
||||
getGateway(provider: PaymentProvider): IPaymentGateway;
|
||||
}
|
||||
@@ -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<CreatePaymentUrlResult> {
|
||||
const now = new Date();
|
||||
const createDate = this.formatDate(now);
|
||||
const expireDate = this.formatDate(new Date(now.getTime() + 15 * 60 * 1000));
|
||||
|
||||
const vnpParams: Record<string, string> = {
|
||||
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<string, string>): 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<RefundResult> {
|
||||
const now = new Date();
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const refundData: Record<string, string> = {
|
||||
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<string, string>;
|
||||
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<string, string>): Record<string, string> {
|
||||
const sorted: Record<string, string> = {};
|
||||
const keys = Object.keys(obj).sort();
|
||||
for (const key of keys) {
|
||||
sorted[key] = obj[key]!;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
@@ -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<CreatePaymentUrlResult> {
|
||||
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<string, string>): 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<string, unknown> = {};
|
||||
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<RefundResult> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
54
apps/api/src/modules/payments/payments.module.ts
Normal file
54
apps/api/src/modules/payments/payments.module.ts
Normal file
@@ -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 {}
|
||||
@@ -0,0 +1 @@
|
||||
export { PaymentsController } from './payments.controller';
|
||||
@@ -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<CreatePaymentResult> {
|
||||
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<string, string>,
|
||||
@Query() queryData: Record<string, string>,
|
||||
): Promise<HandleCallbackResult> {
|
||||
const providerUpper = provider.toUpperCase() as PaymentProvider;
|
||||
// Merge query params and body (VNPay sends via query, MoMo/ZaloPay via body)
|
||||
const mergedData = { ...queryData, ...callbackData };
|
||||
return this.commandBus.execute(
|
||||
new HandleCallbackCommand(providerUpper, mergedData),
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get(':id')
|
||||
async getPaymentStatus(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<PaymentStatusDto> {
|
||||
return this.queryBus.execute(new GetPaymentStatusQuery(id, user.sub));
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
async listTransactions(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query() dto: ListTransactionsDto,
|
||||
): Promise<TransactionListDto> {
|
||||
return this.queryBus.execute(
|
||||
new ListTransactionsQuery(user.sub, dto.status, dto.limit, dto.offset),
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@Post(':id/refund')
|
||||
async refundPayment(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: RefundPaymentDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<RefundPaymentResult> {
|
||||
return this.commandBus.execute(
|
||||
new RefundPaymentCommand(id, dto.reason, user.sub),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
3
apps/api/src/modules/payments/presentation/dto/index.ts
Normal file
3
apps/api/src/modules/payments/presentation/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CreatePaymentDto } from './create-payment.dto';
|
||||
export { RefundPaymentDto } from './refund-payment.dto';
|
||||
export { ListTransactionsDto } from './list-transactions.dto';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RefundPaymentDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
Reference in New Issue
Block a user