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:
Ho Ngoc Hai
2026-04-08 01:57:23 +07:00
parent 207a2013f3
commit ad7713968a
42 changed files with 1985 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import { AuthModule } from '@modules/auth';
import { ListingsModule } from '@modules/listings'; import { ListingsModule } from '@modules/listings';
import { SearchModule } from '@modules/search'; import { SearchModule } from '@modules/search';
import { NotificationsModule } from '@modules/notifications'; import { NotificationsModule } from '@modules/notifications';
import { PaymentsModule } from '@modules/payments';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
@@ -18,6 +19,7 @@ import { AppController } from './app.controller';
ListingsModule, ListingsModule,
SearchModule, SearchModule,
NotificationsModule, NotificationsModule,
PaymentsModule,
// ── Rate Limiting ── // ── Rate Limiting ──
// Default: 60 requests per 60 seconds per IP // Default: 60 requests per 60 seconds per IP

View File

@@ -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,
) {}
}

View File

@@ -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 };
}
}

View File

@@ -0,0 +1,8 @@
import { type PaymentProvider } from '@prisma/client';
export class HandleCallbackCommand {
constructor(
public readonly provider: PaymentProvider,
public readonly callbackData: Record<string, string>,
) {}
}

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,7 @@
export class RefundPaymentCommand {
constructor(
public readonly paymentId: string,
public readonly reason: string,
public readonly requestedBy: string,
) {}
}

View File

@@ -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,
};
}
}

View 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';

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,6 @@
export class GetPaymentStatusQuery {
constructor(
public readonly paymentId: string,
public readonly userId: string,
) {}
}

View File

@@ -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,
};
}
}

View File

@@ -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,
) {}
}

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -0,0 +1 @@
export { PaymentEntity, type PaymentProps } from './payment.entity';

View 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();
}
}

View File

@@ -0,0 +1,3 @@
export { PaymentCreatedEvent } from './payment-created.event';
export { PaymentCompletedEvent } from './payment-completed.event';
export { PaymentFailedEvent } from './payment-failed.event';

View File

@@ -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,
) {}
}

View File

@@ -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,
) {}
}

View File

@@ -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,
) {}
}

View File

@@ -0,0 +1 @@
export { PAYMENT_REPOSITORY, type IPaymentRepository } from './payment.repository';

View File

@@ -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>;
}

View File

@@ -0,0 +1 @@
export { Money } from './money.vo';

View File

@@ -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 }));
}
}

View 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';

View File

@@ -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ợ',
);
});
});

View File

@@ -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);
});
});

View File

@@ -0,0 +1 @@
export { PrismaPaymentRepository } from './prisma-payment.repository';

View File

@@ -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);
}
}

View File

@@ -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';

View File

@@ -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 };
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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}`;
}
}

View 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 {}

View File

@@ -0,0 +1 @@
export { PaymentsController } from './payments.controller';

View File

@@ -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),
);
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
export { CreatePaymentDto } from './create-payment.dto';
export { RefundPaymentDto } from './refund-payment.dto';
export { ListTransactionsDto } from './list-transactions.dto';

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefundPaymentDto {
@IsString()
@MinLength(1)
reason!: string;
}