feat(payments): implement Payments module with VNPay, MoMo, ZaloPay integration
Implement complete payment processing module following DDD + CQRS patterns: - Domain layer: PaymentEntity aggregate, Money value object, domain events - Infrastructure: PrismaPaymentRepository, VnpayService, MomoService, ZalopayService - PaymentGatewayFactory pattern for provider abstraction - CQRS Commands: CreatePayment, HandleCallback, RefundPayment - CQRS Queries: GetPaymentStatus, ListTransactions - Callback/webhook endpoints with signature verification and idempotency - 23 unit tests covering domain, VNPay service, and gateway factory Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,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 }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user