feat(payments): add Order & Escrow entities with CQRS commands, Prisma schema

- Add Order entity with lifecycle (pending → paid → completed/cancelled/refunded)
- Add Escrow entity with hold/release/dispute flow for secure transactions
- Add PlatformFee value object with tiered commission calculation
- Implement CQRS: CreateOrder, CancelOrder, HoldEscrow, ReleaseEscrow commands
- Add GetOrderStatus query handler
- Add OrdersController with REST endpoints and DTOs
- Add Prisma models for Order, Escrow, EscrowStatusHistory
- Add domain event classes for order and escrow state changes
- Add unit tests for Order, Escrow entities and PlatformFee VO
- Update PROJECT_TRACKER to Wave 14 status

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-12 23:40:00 +07:00
parent 836499c1cf
commit 2c97f99214
42 changed files with 1786 additions and 34 deletions

View File

@@ -0,0 +1,7 @@
export class CancelOrderCommand {
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly reason: string,
) {}
}

View File

@@ -0,0 +1,65 @@
import { HttpStatus, Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, ErrorCode, type LoggerService } from '@modules/shared';
import { ORDER_REPOSITORY, type IOrderRepository } from '../../../domain/repositories/order.repository';
import { CancelOrderCommand } from './cancel-order.command';
export interface CancelOrderResult {
orderId: string;
status: string;
}
@CommandHandler(CancelOrderCommand)
export class CancelOrderHandler implements ICommandHandler<CancelOrderCommand> {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orderRepo: IOrderRepository,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: CancelOrderCommand): Promise<CancelOrderResult> {
try {
const order = await this.orderRepo.findById(command.orderId);
if (!order) {
throw new DomainException(
ErrorCode.ORDER_NOT_FOUND,
'Đơn hàng không tồn tại',
HttpStatus.NOT_FOUND,
);
}
// Only buyer or seller can cancel
if (order.buyerId !== command.userId && order.sellerId !== command.userId) {
throw new DomainException(
ErrorCode.FORBIDDEN,
'Bạn không có quyền hủy đơn hàng này',
HttpStatus.FORBIDDEN,
);
}
const result = order.markCancelled();
if (result.isErr) throw result.unwrapErr();
await this.orderRepo.update(order);
for (const event of order.clearDomainEvents()) {
this.eventBus.publish(event);
}
this.logger.log(
`Order cancelled: id=${command.orderId}, by=${command.userId}, reason=${command.reason}`,
'CancelOrderHandler',
);
return { orderId: order.id, status: order.status };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to cancel order: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể hủy đơn hàng. Vui lòng thử lại sau');
}
}
}

View File

@@ -0,0 +1,9 @@
export class CreateOrderCommand {
constructor(
public readonly buyerId: string,
public readonly sellerId: string,
public readonly listingId: string,
public readonly amountVND: bigint,
public readonly idempotencyKey?: string,
) {}
}

View File

@@ -0,0 +1,112 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { ConflictException, DomainException, ValidationException, type LoggerService } from '@modules/shared';
import { EscrowEntity } from '../../../domain/entities/escrow.entity';
import { OrderEntity } from '../../../domain/entities/order.entity';
import { ESCROW_REPOSITORY, type IEscrowRepository } from '../../../domain/repositories/escrow.repository';
import { ORDER_REPOSITORY, type IOrderRepository } from '../../../domain/repositories/order.repository';
import { Money } from '../../../domain/value-objects/money.vo';
import { PlatformFee } from '../../../domain/value-objects/platform-fee.vo';
import { CreateOrderCommand } from './create-order.command';
export interface CreateOrderResult {
orderId: string;
escrowId: string;
amountVND: string;
platformFeeVND: string;
sellerPayoutVND: string;
}
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orderRepo: IOrderRepository,
@Inject(ESCROW_REPOSITORY) private readonly escrowRepo: IEscrowRepository,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: CreateOrderCommand): Promise<CreateOrderResult> {
try {
// Idempotency check
if (command.idempotencyKey) {
const existing = await this.orderRepo.findByIdempotencyKey(command.idempotencyKey);
if (existing) {
throw new ConflictException('Đơn hàng với idempotency key này đã tồn tại');
}
}
// Validate amount
const amountResult = Money.create(command.amountVND);
if (amountResult.isErr) {
throw new ValidationException(amountResult.unwrapErr());
}
const amount = amountResult.unwrap();
// Calculate platform fee (5%)
const feeResult = PlatformFee.fromOrderAmount(command.amountVND);
if (feeResult.isErr) {
throw new ValidationException(feeResult.unwrapErr());
}
const platformFee = feeResult.unwrap();
// Calculate seller payout
const sellerPayoutVND = command.amountVND - platformFee.value;
const payoutResult = Money.create(sellerPayoutVND);
if (payoutResult.isErr) {
throw new ValidationException(payoutResult.unwrapErr());
}
const sellerPayout = payoutResult.unwrap();
const orderId = createId();
const escrowId = createId();
// Create order aggregate
const order = OrderEntity.createNew(
orderId,
command.buyerId,
command.sellerId,
command.listingId,
amount,
platformFee,
sellerPayout,
command.idempotencyKey,
);
// Create escrow (pending until payment confirmed)
const feeAsMoney = Money.create(platformFee.value).unwrap();
const escrow = EscrowEntity.createNew(escrowId, orderId, amount, feeAsMoney);
// Persist both
await this.orderRepo.save(order);
await this.escrowRepo.save(escrow);
// Publish domain events
for (const event of order.clearDomainEvents()) {
this.eventBus.publish(event);
}
this.logger.log(
`Order created: id=${orderId}, buyer=${command.buyerId}, amount=${command.amountVND}`,
'CreateOrderHandler',
);
return {
orderId,
escrowId,
amountVND: command.amountVND.toString(),
platformFeeVND: platformFee.value.toString(),
sellerPayoutVND: sellerPayout.value.toString(),
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to create order: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể tạo đơn hàng. Vui lòng thử lại sau');
}
}
}

View File

@@ -0,0 +1,5 @@
export class HoldEscrowCommand {
constructor(
public readonly orderId: string,
) {}
}

View File

@@ -0,0 +1,67 @@
import { HttpStatus, Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, ErrorCode, type LoggerService } from '@modules/shared';
import { ESCROW_REPOSITORY, type IEscrowRepository } from '../../../domain/repositories/escrow.repository';
import { ORDER_REPOSITORY, type IOrderRepository } from '../../../domain/repositories/order.repository';
import { HoldEscrowCommand } from './hold-escrow.command';
export interface HoldEscrowResult {
escrowId: string;
status: string;
heldAt: string;
}
@CommandHandler(HoldEscrowCommand)
export class HoldEscrowHandler implements ICommandHandler<HoldEscrowCommand> {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orderRepo: IOrderRepository,
@Inject(ESCROW_REPOSITORY) private readonly escrowRepo: IEscrowRepository,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: HoldEscrowCommand): Promise<HoldEscrowResult> {
try {
const order = await this.orderRepo.findById(command.orderId);
if (!order) {
throw new DomainException(ErrorCode.ORDER_NOT_FOUND, 'Đơn hàng không tồn tại', HttpStatus.NOT_FOUND);
}
const escrow = await this.escrowRepo.findByOrderId(command.orderId);
if (!escrow) {
throw new DomainException(ErrorCode.ESCROW_NOT_FOUND, 'Ký quỹ không tồn tại', HttpStatus.NOT_FOUND);
}
// Hold escrow
const holdResult = escrow.hold();
if (holdResult.isErr) throw holdResult.unwrapErr();
// Transition order
const orderResult = order.markEscrowHeld();
if (orderResult.isErr) throw orderResult.unwrapErr();
await this.escrowRepo.update(escrow);
await this.orderRepo.update(order);
for (const event of escrow.clearDomainEvents()) {
this.eventBus.publish(event);
}
this.logger.log(`Escrow held: id=${escrow.id}, order=${command.orderId}`, 'HoldEscrowHandler');
return {
escrowId: escrow.id,
status: escrow.status,
heldAt: escrow.heldAt?.toISOString() ?? '',
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to hold escrow: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể giữ ký quỹ. Vui lòng thử lại sau');
}
}
}

View File

@@ -0,0 +1,5 @@
export class ReleaseEscrowCommand {
constructor(
public readonly orderId: string,
) {}
}

View File

@@ -0,0 +1,72 @@
import { HttpStatus, Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, ErrorCode, type LoggerService } from '@modules/shared';
import { ESCROW_REPOSITORY, type IEscrowRepository } from '../../../domain/repositories/escrow.repository';
import { ORDER_REPOSITORY, type IOrderRepository } from '../../../domain/repositories/order.repository';
import { ReleaseEscrowCommand } from './release-escrow.command';
export interface ReleaseEscrowResult {
escrowId: string;
status: string;
payoutVND: string;
releasedAt: string;
}
@CommandHandler(ReleaseEscrowCommand)
export class ReleaseEscrowHandler implements ICommandHandler<ReleaseEscrowCommand> {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orderRepo: IOrderRepository,
@Inject(ESCROW_REPOSITORY) private readonly escrowRepo: IEscrowRepository,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: ReleaseEscrowCommand): Promise<ReleaseEscrowResult> {
try {
const order = await this.orderRepo.findById(command.orderId);
if (!order) {
throw new DomainException(ErrorCode.ORDER_NOT_FOUND, 'Đơn hàng không tồn tại', HttpStatus.NOT_FOUND);
}
const escrow = await this.escrowRepo.findByOrderId(command.orderId);
if (!escrow) {
throw new DomainException(ErrorCode.ESCROW_NOT_FOUND, 'Ký quỹ không tồn tại', HttpStatus.NOT_FOUND);
}
// Release escrow
const releaseResult = escrow.release();
if (releaseResult.isErr) throw releaseResult.unwrapErr();
// Transition order
const orderResult = order.markEscrowReleased();
if (orderResult.isErr) throw orderResult.unwrapErr();
await this.escrowRepo.update(escrow);
await this.orderRepo.update(order);
for (const event of escrow.clearDomainEvents()) {
this.eventBus.publish(event);
}
this.logger.log(
`Escrow released: id=${escrow.id}, order=${command.orderId}, payout=${escrow.netPayout}`,
'ReleaseEscrowHandler',
);
return {
escrowId: escrow.id,
status: escrow.status,
payoutVND: escrow.netPayout.toString(),
releasedAt: escrow.releasedAt?.toISOString() ?? '',
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to release escrow: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể giải phóng ký quỹ. Vui lòng thử lại sau');
}
}
}

View File

@@ -1,10 +1,20 @@
export { CancelOrderCommand } from './commands/cancel-order/cancel-order.command';
export { CancelOrderHandler, type CancelOrderResult } from './commands/cancel-order/cancel-order.handler';
export { CreateOrderCommand } from './commands/create-order/create-order.command';
export { CreateOrderHandler, type CreateOrderResult } from './commands/create-order/create-order.handler';
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 { HoldEscrowCommand } from './commands/hold-escrow/hold-escrow.command';
export { HoldEscrowHandler, type HoldEscrowResult } from './commands/hold-escrow/hold-escrow.handler';
export { RefundPaymentCommand } from './commands/refund-payment/refund-payment.command';
export { RefundPaymentHandler, type RefundPaymentResult } from './commands/refund-payment/refund-payment.handler';
export { ReleaseEscrowCommand } from './commands/release-escrow/release-escrow.command';
export { ReleaseEscrowHandler, type ReleaseEscrowResult } from './commands/release-escrow/release-escrow.handler';
export { GetOrderStatusQuery } from './queries/get-order-status/get-order-status.query';
export { GetOrderStatusHandler, type OrderStatusDto } from './queries/get-order-status/get-order-status.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';

View File

@@ -0,0 +1,72 @@
import { HttpStatus, Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { DomainException, ErrorCode } from '@modules/shared';
import { ESCROW_REPOSITORY, type IEscrowRepository } from '../../../domain/repositories/escrow.repository';
import { ORDER_REPOSITORY, type IOrderRepository } from '../../../domain/repositories/order.repository';
import { GetOrderStatusQuery } from './get-order-status.query';
export interface OrderStatusDto {
id: string;
buyerId: string;
sellerId: string;
listingId: string;
status: string;
amountVND: string;
platformFeeVND: string;
sellerPayoutVND: string;
escrow: {
id: string;
status: string;
heldAt: string | null;
releasedAt: string | null;
} | null;
createdAt: string;
updatedAt: string;
}
@QueryHandler(GetOrderStatusQuery)
export class GetOrderStatusHandler implements IQueryHandler<GetOrderStatusQuery> {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orderRepo: IOrderRepository,
@Inject(ESCROW_REPOSITORY) private readonly escrowRepo: IEscrowRepository,
) {}
async execute(query: GetOrderStatusQuery): Promise<OrderStatusDto> {
const order = await this.orderRepo.findById(query.orderId);
if (!order) {
throw new DomainException(ErrorCode.ORDER_NOT_FOUND, 'Đơn hàng không tồn tại', HttpStatus.NOT_FOUND);
}
// Only buyer or seller can view the order
if (order.buyerId !== query.userId && order.sellerId !== query.userId) {
throw new DomainException(
ErrorCode.FORBIDDEN,
'Bạn không có quyền xem đơn hàng này',
HttpStatus.FORBIDDEN,
);
}
const escrow = await this.escrowRepo.findByOrderId(order.id);
return {
id: order.id,
buyerId: order.buyerId,
sellerId: order.sellerId,
listingId: order.listingId,
status: order.status,
amountVND: order.amount.value.toString(),
platformFeeVND: order.platformFee.value.toString(),
sellerPayoutVND: order.sellerPayout.value.toString(),
escrow: escrow
? {
id: escrow.id,
status: escrow.status,
heldAt: escrow.heldAt?.toISOString() ?? null,
releasedAt: escrow.releasedAt?.toISOString() ?? null,
}
: null,
createdAt: order.createdAt.toISOString(),
updatedAt: order.updatedAt.toISOString(),
};
}
}

View File

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

View File

@@ -0,0 +1,138 @@
import { describe, it, expect } from 'vitest';
import { EscrowEntity } from '../entities/escrow.entity';
import { EscrowDisputedEvent } from '../events/escrow-disputed.event';
import { EscrowHeldEvent } from '../events/escrow-held.event';
import { EscrowReleasedEvent } from '../events/escrow-released.event';
import { Money } from '../value-objects/money.vo';
describe('EscrowEntity', () => {
const createEscrow = () => {
const amount = Money.create(5_000_000_000n).unwrap();
const fee = Money.create(250_000_000n).unwrap();
return EscrowEntity.createNew('esc-1', 'ord-1', amount, fee);
};
it('should create a new escrow with PENDING status', () => {
const escrow = createEscrow();
expect(escrow.id).toBe('esc-1');
expect(escrow.orderId).toBe('ord-1');
expect(escrow.amount.value).toBe(5_000_000_000n);
expect(escrow.fee.value).toBe(250_000_000n);
expect(escrow.status).toBe('PENDING');
expect(escrow.heldAt).toBeNull();
expect(escrow.releasedAt).toBeNull();
expect(escrow.netPayout).toBe(4_750_000_000n);
});
it('should hold escrow from PENDING and emit event', () => {
const escrow = createEscrow();
const result = escrow.hold();
expect(result.isOk).toBe(true);
expect(escrow.status).toBe('HELD');
expect(escrow.heldAt).toBeInstanceOf(Date);
const events = escrow.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(EscrowHeldEvent);
});
it('should release escrow from HELD and emit event', () => {
const escrow = createEscrow();
escrow.hold();
escrow.clearDomainEvents();
const result = escrow.release();
expect(result.isOk).toBe(true);
expect(escrow.status).toBe('RELEASED');
expect(escrow.releasedAt).toBeInstanceOf(Date);
const events = escrow.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(EscrowReleasedEvent);
expect((events[0] as EscrowReleasedEvent).payoutVND).toBe(4_750_000_000n);
});
it('should dispute escrow from HELD and emit event', () => {
const escrow = createEscrow();
escrow.hold();
escrow.clearDomainEvents();
const result = escrow.dispute('Hàng không đúng mô tả');
expect(result.isOk).toBe(true);
expect(escrow.status).toBe('DISPUTED');
expect(escrow.disputeReason).toBe('Hàng không đúng mô tả');
expect(escrow.disputedAt).toBeInstanceOf(Date);
const events = escrow.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(EscrowDisputedEvent);
});
it('should release escrow from DISPUTED state', () => {
const escrow = createEscrow();
escrow.hold();
escrow.dispute('Test dispute');
escrow.clearDomainEvents();
const result = escrow.release();
expect(result.isOk).toBe(true);
expect(escrow.status).toBe('RELEASED');
});
it('should refund escrow from HELD', () => {
const escrow = createEscrow();
escrow.hold();
const result = escrow.refund();
expect(result.isOk).toBe(true);
expect(escrow.status).toBe('REFUNDED');
});
it('should refund escrow from DISPUTED', () => {
const escrow = createEscrow();
escrow.hold();
escrow.dispute('Test dispute');
const result = escrow.refund();
expect(result.isOk).toBe(true);
expect(escrow.status).toBe('REFUNDED');
});
it('should not hold escrow if already HELD', () => {
const escrow = createEscrow();
escrow.hold();
const result = escrow.hold();
expect(result.isErr).toBe(true);
expect(result.unwrapErr().message).toContain('HELD');
});
it('should not release escrow from PENDING', () => {
const escrow = createEscrow();
const result = escrow.release();
expect(result.isErr).toBe(true);
expect(result.unwrapErr().message).toContain('PENDING');
});
it('should not dispute escrow from PENDING', () => {
const escrow = createEscrow();
const result = escrow.dispute('test');
expect(result.isErr).toBe(true);
expect(result.unwrapErr().message).toContain('PENDING');
});
it('should not refund escrow from PENDING', () => {
const escrow = createEscrow();
const result = escrow.refund();
expect(result.isErr).toBe(true);
expect(result.unwrapErr().message).toContain('PENDING');
});
it('should not refund escrow from RELEASED', () => {
const escrow = createEscrow();
escrow.hold();
escrow.release();
const result = escrow.refund();
expect(result.isErr).toBe(true);
expect(result.unwrapErr().message).toContain('RELEASED');
});
});

View File

@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest';
import { OrderEntity } from '../entities/order.entity';
import { OrderCancelledEvent } from '../events/order-cancelled.event';
import { OrderCreatedEvent } from '../events/order-created.event';
import { OrderPaidEvent } from '../events/order-paid.event';
import { Money } from '../value-objects/money.vo';
import { PlatformFee } from '../value-objects/platform-fee.vo';
describe('OrderEntity', () => {
const createOrder = () => {
const amount = Money.create(5_000_000_000n).unwrap();
const fee = PlatformFee.fromOrderAmount(5_000_000_000n).unwrap();
const payout = Money.create(5_000_000_000n - fee.value).unwrap();
return OrderEntity.createNew(
'ord-1',
'buyer-1',
'seller-1',
'listing-1',
amount,
fee,
payout,
'idem-key-1',
);
};
it('should create a new order with CREATED status and emit event', () => {
const order = createOrder();
expect(order.id).toBe('ord-1');
expect(order.buyerId).toBe('buyer-1');
expect(order.sellerId).toBe('seller-1');
expect(order.listingId).toBe('listing-1');
expect(order.status).toBe('CREATED');
expect(order.amount.value).toBe(5_000_000_000n);
expect(order.platformFee.value).toBe(250_000_000n); // 5%
expect(order.sellerPayout.value).toBe(4_750_000_000n);
expect(order.idempotencyKey).toBe('idem-key-1');
const events = order.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(OrderCreatedEvent);
});
it('should transition CREATED → PAYMENT_PENDING', () => {
const order = createOrder();
const result = order.markPaymentPending();
expect(result.isOk).toBe(true);
expect(order.status).toBe('PAYMENT_PENDING');
});
it('should transition PAYMENT_PENDING → PAYMENT_CONFIRMED with event', () => {
const order = createOrder();
order.markPaymentPending();
order.clearDomainEvents();
const result = order.markPaymentConfirmed();
expect(result.isOk).toBe(true);
expect(order.status).toBe('PAYMENT_CONFIRMED');
const events = order.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(OrderPaidEvent);
});
it('should transition PAYMENT_CONFIRMED → ESCROW_HELD', () => {
const order = createOrder();
order.markPaymentPending();
order.markPaymentConfirmed();
const result = order.markEscrowHeld();
expect(result.isOk).toBe(true);
expect(order.status).toBe('ESCROW_HELD');
});
it('should follow full settlement flow', () => {
const order = createOrder();
expect(order.markPaymentPending().isOk).toBe(true);
expect(order.markPaymentConfirmed().isOk).toBe(true);
expect(order.markEscrowHeld().isOk).toBe(true);
expect(order.markShipped().isOk).toBe(true);
expect(order.markDelivered().isOk).toBe(true);
expect(order.markEscrowReleased().isOk).toBe(true);
expect(order.markCompleted().isOk).toBe(true);
expect(order.status).toBe('COMPLETED');
});
it('should allow cancellation from CREATED', () => {
const order = createOrder();
order.clearDomainEvents();
const result = order.markCancelled();
expect(result.isOk).toBe(true);
expect(order.status).toBe('CANCELLED');
const events = order.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(OrderCancelledEvent);
});
it('should allow cancellation from PAYMENT_PENDING', () => {
const order = createOrder();
order.markPaymentPending();
const result = order.markCancelled();
expect(result.isOk).toBe(true);
expect(order.status).toBe('CANCELLED');
});
it('should reject invalid status transition', () => {
const order = createOrder();
// Cannot go directly from CREATED to COMPLETED
const result = order.markCompleted();
expect(result.isErr).toBe(true);
expect(result.unwrapErr().message).toContain('CREATED');
});
it('should reject cancellation from COMPLETED', () => {
const order = createOrder();
order.markPaymentPending();
order.markPaymentConfirmed();
order.markEscrowHeld();
order.markShipped();
order.markDelivered();
order.markEscrowReleased();
order.markCompleted();
const result = order.markCancelled();
expect(result.isErr).toBe(true);
});
it('should transition to dispute from ESCROW_HELD', () => {
const order = createOrder();
order.markPaymentPending();
order.markPaymentConfirmed();
order.markEscrowHeld();
const result = order.markDispute();
expect(result.isOk).toBe(true);
expect(order.status).toBe('DISPUTE');
});
it('should allow refund from DISPUTE', () => {
const order = createOrder();
order.markPaymentPending();
order.markPaymentConfirmed();
order.markEscrowHeld();
order.markDispute();
const result = order.markRefunded();
expect(result.isOk).toBe(true);
expect(order.status).toBe('REFUNDED');
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { PlatformFee } from '../value-objects/platform-fee.vo';
describe('PlatformFee', () => {
it('should calculate 5% fee from order amount', () => {
const result = PlatformFee.fromOrderAmount(10_000_000n);
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe(500_000n);
});
it('should calculate fee for large amounts', () => {
const result = PlatformFee.fromOrderAmount(5_000_000_000n);
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe(250_000_000n);
});
it('should reject zero order amount', () => {
const result = PlatformFee.fromOrderAmount(0n);
expect(result.isErr).toBe(true);
});
it('should reject negative order amount', () => {
const result = PlatformFee.fromOrderAmount(-100n);
expect(result.isErr).toBe(true);
});
it('should create explicit fee', () => {
const result = PlatformFee.create(100_000n);
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe(100_000n);
});
it('should allow zero explicit fee', () => {
const result = PlatformFee.create(0n);
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe(0n);
});
it('should reject negative explicit fee', () => {
const result = PlatformFee.create(-1n);
expect(result.isErr).toBe(true);
});
});

View File

@@ -0,0 +1,149 @@
import { HttpStatus } from '@nestjs/common';
import { type EscrowStatus } from '@prisma/client';
import { AggregateRoot, DomainException, ErrorCode, Result } from '@modules/shared';
import { EscrowDisputedEvent } from '../events/escrow-disputed.event';
import { EscrowHeldEvent } from '../events/escrow-held.event';
import { EscrowReleasedEvent } from '../events/escrow-released.event';
import { type Money } from '../value-objects/money.vo';
export interface EscrowProps {
orderId: string;
amount: Money;
fee: Money;
status: EscrowStatus;
heldAt: Date | null;
releasedAt: Date | null;
disputeReason: string | null;
disputedAt: Date | null;
}
export class EscrowEntity extends AggregateRoot<string> {
private _orderId: string;
private _amount: Money;
private _fee: Money;
private _status: EscrowStatus;
private _heldAt: Date | null;
private _releasedAt: Date | null;
private _disputeReason: string | null;
private _disputedAt: Date | null;
constructor(id: string, props: EscrowProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt, updatedAt);
this._orderId = props.orderId;
this._amount = props.amount;
this._fee = props.fee;
this._status = props.status;
this._heldAt = props.heldAt;
this._releasedAt = props.releasedAt;
this._disputeReason = props.disputeReason;
this._disputedAt = props.disputedAt;
}
get orderId(): string { return this._orderId; }
get amount(): Money { return this._amount; }
get fee(): Money { return this._fee; }
get status(): EscrowStatus { return this._status; }
get heldAt(): Date | null { return this._heldAt; }
get releasedAt(): Date | null { return this._releasedAt; }
get disputeReason(): string | null { return this._disputeReason; }
get disputedAt(): Date | null { return this._disputedAt; }
/** Net amount paid to seller (amount - fee). */
get netPayout(): bigint {
return this._amount.value - this._fee.value;
}
static createNew(
id: string,
orderId: string,
amount: Money,
fee: Money,
): EscrowEntity {
return new EscrowEntity(id, {
orderId,
amount,
fee,
status: 'PENDING',
heldAt: null,
releasedAt: null,
disputeReason: null,
disputedAt: null,
});
}
hold(): Result<void, DomainException> {
if (this._status !== 'PENDING') {
return Result.err(
new DomainException(
ErrorCode.ESCROW_INVALID_STATE,
`Không thể giữ ký quỹ ở trạng thái ${this._status}`,
HttpStatus.CONFLICT,
),
);
}
this._status = 'HELD';
this._heldAt = new Date();
this.updatedAt = new Date();
this.addDomainEvent(
new EscrowHeldEvent(this.id, this._orderId, this._amount.value),
);
return Result.ok(undefined);
}
release(): Result<void, DomainException> {
if (this._status !== 'HELD' && this._status !== 'DISPUTED') {
return Result.err(
new DomainException(
ErrorCode.ESCROW_INVALID_STATE,
`Không thể giải phóng ký quỹ ở trạng thái ${this._status}`,
HttpStatus.CONFLICT,
),
);
}
this._status = 'RELEASED';
this._releasedAt = new Date();
this.updatedAt = new Date();
this.addDomainEvent(
new EscrowReleasedEvent(this.id, this._orderId, this.netPayout),
);
return Result.ok(undefined);
}
dispute(reason: string): Result<void, DomainException> {
if (this._status !== 'HELD') {
return Result.err(
new DomainException(
ErrorCode.ESCROW_INVALID_STATE,
`Không thể tranh chấp ký quỹ ở trạng thái ${this._status}`,
HttpStatus.CONFLICT,
),
);
}
this._status = 'DISPUTED';
this._disputeReason = reason;
this._disputedAt = new Date();
this.updatedAt = new Date();
this.addDomainEvent(
new EscrowDisputedEvent(this.id, this._orderId, reason),
);
return Result.ok(undefined);
}
refund(): Result<void, DomainException> {
if (this._status !== 'HELD' && this._status !== 'DISPUTED') {
return Result.err(
new DomainException(
ErrorCode.ESCROW_INVALID_STATE,
`Không thể hoàn tiền ký quỹ ở trạng thái ${this._status}`,
HttpStatus.CONFLICT,
),
);
}
this._status = 'REFUNDED';
this.updatedAt = new Date();
return Result.ok(undefined);
}
}

View File

@@ -1 +1,3 @@
export { EscrowEntity, type EscrowProps } from './escrow.entity';
export { OrderEntity, type OrderProps } from './order.entity';
export { PaymentEntity, type PaymentProps } from './payment.entity';

View File

@@ -0,0 +1,165 @@
import { HttpStatus } from '@nestjs/common';
import { type OrderStatus } from '@prisma/client';
import { AggregateRoot, DomainException, ErrorCode, Result } from '@modules/shared';
import { OrderCancelledEvent } from '../events/order-cancelled.event';
import { OrderCreatedEvent } from '../events/order-created.event';
import { OrderPaidEvent } from '../events/order-paid.event';
import { type Money } from '../value-objects/money.vo';
import { type PlatformFee } from '../value-objects/platform-fee.vo';
export interface OrderProps {
buyerId: string;
sellerId: string;
listingId: string;
status: OrderStatus;
amount: Money;
platformFee: PlatformFee;
sellerPayout: Money;
idempotencyKey: string | null;
metadata: unknown;
}
/** Allowed status transitions for the order state machine. */
const VALID_TRANSITIONS: Partial<Record<OrderStatus, OrderStatus[]>> = {
CREATED: ['PAYMENT_PENDING', 'CANCELLED'],
PAYMENT_PENDING: ['PAYMENT_CONFIRMED', 'CANCELLED'],
PAYMENT_CONFIRMED: ['ESCROW_HELD', 'REFUNDED'],
ESCROW_HELD: ['SHIPPED', 'DISPUTE', 'REFUNDED'],
SHIPPED: ['DELIVERED', 'DISPUTE'],
DELIVERED: ['ESCROW_RELEASED', 'DISPUTE'],
DISPUTE: ['ESCROW_RELEASED', 'REFUNDED'],
ESCROW_RELEASED: ['COMPLETED'],
};
export class OrderEntity extends AggregateRoot<string> {
private _buyerId: string;
private _sellerId: string;
private _listingId: string;
private _status: OrderStatus;
private _amount: Money;
private _platformFee: PlatformFee;
private _sellerPayout: Money;
private _idempotencyKey: string | null;
private _metadata: unknown;
constructor(id: string, props: OrderProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt, updatedAt);
this._buyerId = props.buyerId;
this._sellerId = props.sellerId;
this._listingId = props.listingId;
this._status = props.status;
this._amount = props.amount;
this._platformFee = props.platformFee;
this._sellerPayout = props.sellerPayout;
this._idempotencyKey = props.idempotencyKey;
this._metadata = props.metadata;
}
get buyerId(): string { return this._buyerId; }
get sellerId(): string { return this._sellerId; }
get listingId(): string { return this._listingId; }
get status(): OrderStatus { return this._status; }
get amount(): Money { return this._amount; }
get platformFee(): PlatformFee { return this._platformFee; }
get sellerPayout(): Money { return this._sellerPayout; }
get idempotencyKey(): string | null { return this._idempotencyKey; }
get metadata(): unknown { return this._metadata; }
static createNew(
id: string,
buyerId: string,
sellerId: string,
listingId: string,
amount: Money,
platformFee: PlatformFee,
sellerPayout: Money,
idempotencyKey?: string,
): OrderEntity {
const order = new OrderEntity(id, {
buyerId,
sellerId,
listingId,
status: 'CREATED',
amount,
platformFee,
sellerPayout,
idempotencyKey: idempotencyKey ?? null,
metadata: null,
});
order.addDomainEvent(
new OrderCreatedEvent(id, buyerId, sellerId, listingId, amount.value),
);
return order;
}
/** Transition the order to a new status, enforcing the state machine. */
private transitionTo(newStatus: OrderStatus): Result<void, DomainException> {
const allowed = VALID_TRANSITIONS[this._status];
if (!allowed || !allowed.includes(newStatus)) {
return Result.err(
new DomainException(
ErrorCode.ORDER_INVALID_STATUS_TRANSITION,
`Không thể chuyển đơn hàng từ ${this._status} sang ${newStatus}`,
HttpStatus.CONFLICT,
),
);
}
this._status = newStatus;
this.updatedAt = new Date();
return Result.ok(undefined);
}
markPaymentPending(): Result<void, DomainException> {
return this.transitionTo('PAYMENT_PENDING');
}
markPaymentConfirmed(): Result<void, DomainException> {
const result = this.transitionTo('PAYMENT_CONFIRMED');
if (result.isOk) {
this.addDomainEvent(
new OrderPaidEvent(this.id, this._buyerId, this._amount.value),
);
}
return result;
}
markEscrowHeld(): Result<void, DomainException> {
return this.transitionTo('ESCROW_HELD');
}
markShipped(): Result<void, DomainException> {
return this.transitionTo('SHIPPED');
}
markDelivered(): Result<void, DomainException> {
return this.transitionTo('DELIVERED');
}
markEscrowReleased(): Result<void, DomainException> {
return this.transitionTo('ESCROW_RELEASED');
}
markCompleted(): Result<void, DomainException> {
return this.transitionTo('COMPLETED');
}
markDispute(): Result<void, DomainException> {
return this.transitionTo('DISPUTE');
}
markCancelled(): Result<void, DomainException> {
const result = this.transitionTo('CANCELLED');
if (result.isOk) {
this.addDomainEvent(
new OrderCancelledEvent(this.id, this._buyerId, this._sellerId),
);
}
return result;
}
markRefunded(): Result<void, DomainException> {
return this.transitionTo('REFUNDED');
}
}

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class EscrowDisputedEvent implements DomainEvent {
readonly eventName = 'escrow.disputed';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly orderId: string,
public readonly reason: string,
) {}
}

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class EscrowHeldEvent implements DomainEvent {
readonly eventName = 'escrow.held';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly orderId: string,
public readonly amountVND: bigint,
) {}
}

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class EscrowReleasedEvent implements DomainEvent {
readonly eventName = 'escrow.released';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly orderId: string,
public readonly payoutVND: bigint,
) {}
}

View File

@@ -1,3 +1,9 @@
export { PaymentCreatedEvent } from './payment-created.event';
export { EscrowDisputedEvent } from './escrow-disputed.event';
export { EscrowHeldEvent } from './escrow-held.event';
export { EscrowReleasedEvent } from './escrow-released.event';
export { OrderCancelledEvent } from './order-cancelled.event';
export { OrderCreatedEvent } from './order-created.event';
export { OrderPaidEvent } from './order-paid.event';
export { PaymentCompletedEvent } from './payment-completed.event';
export { PaymentCreatedEvent } from './payment-created.event';
export { PaymentFailedEvent } from './payment-failed.event';

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class OrderCancelledEvent implements DomainEvent {
readonly eventName = 'order.cancelled';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly buyerId: string,
public readonly sellerId: string,
) {}
}

View File

@@ -0,0 +1,14 @@
import { type DomainEvent } from '@modules/shared';
export class OrderCreatedEvent implements DomainEvent {
readonly eventName = 'order.created';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly buyerId: string,
public readonly sellerId: string,
public readonly listingId: string,
public readonly amountVND: bigint,
) {}
}

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class OrderPaidEvent implements DomainEvent {
readonly eventName = 'order.paid';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly buyerId: string,
public readonly amountVND: bigint,
) {}
}

View File

@@ -0,0 +1,10 @@
import { type EscrowEntity } from '../entities/escrow.entity';
export const ESCROW_REPOSITORY = Symbol('ESCROW_REPOSITORY');
export interface IEscrowRepository {
findById(id: string): Promise<EscrowEntity | null>;
findByOrderId(orderId: string): Promise<EscrowEntity | null>;
save(escrow: EscrowEntity): Promise<void>;
update(escrow: EscrowEntity): Promise<void>;
}

View File

@@ -1 +1,3 @@
export { ESCROW_REPOSITORY, type IEscrowRepository } from './escrow.repository';
export { ORDER_REPOSITORY, type IOrderRepository } from './order.repository';
export { PAYMENT_REPOSITORY, type IPaymentRepository } from './payment.repository';

View File

@@ -0,0 +1,21 @@
import { type OrderStatus } from '@prisma/client';
import { type OrderEntity } from '../entities/order.entity';
export const ORDER_REPOSITORY = Symbol('ORDER_REPOSITORY');
export interface IOrderRepository {
findById(id: string): Promise<OrderEntity | null>;
findByIdempotencyKey(key: string): Promise<OrderEntity | null>;
findByBuyerId(buyerId: string, options?: {
status?: OrderStatus;
limit?: number;
offset?: number;
}): Promise<{ items: OrderEntity[]; total: number }>;
findBySellerId(sellerId: string, options?: {
status?: OrderStatus;
limit?: number;
offset?: number;
}): Promise<{ items: OrderEntity[]; total: number }>;
save(order: OrderEntity): Promise<void>;
update(order: OrderEntity): Promise<void>;
}

View File

@@ -1 +1,2 @@
export { Money } from './money.vo';
export { PlatformFee } from './platform-fee.vo';

View File

@@ -0,0 +1,31 @@
import { Result, ValueObject } from '@modules/shared';
interface PlatformFeeProps {
amountVND: bigint;
}
/** Platform fee charged on an order (typically 5% of order amount). */
export class PlatformFee extends ValueObject<PlatformFeeProps> {
static readonly DEFAULT_RATE = 5n; // 5%
get value(): bigint {
return this.props.amountVND;
}
/** Calculate platform fee at the default rate (5%). */
static fromOrderAmount(orderAmountVND: bigint): Result<PlatformFee, string> {
if (orderAmountVND <= 0n) {
return Result.err('Số tiền đơn hàng phải lớn hơn 0');
}
const fee = (orderAmountVND * PlatformFee.DEFAULT_RATE) / 100n;
return Result.ok(new PlatformFee({ amountVND: fee }));
}
/** Create with explicit fee amount. */
static create(amountVND: bigint): Result<PlatformFee, string> {
if (amountVND < 0n) {
return Result.err('Phí nền tảng không được âm');
}
return Result.ok(new PlatformFee({ amountVND }));
}
}

View File

@@ -1,6 +1,24 @@
export { PaymentsModule } from './payments.module';
// Repositories
export { ESCROW_REPOSITORY, type IEscrowRepository } from './domain/repositories/escrow.repository';
export { ORDER_REPOSITORY, type IOrderRepository } from './domain/repositories/order.repository';
export { PAYMENT_REPOSITORY, type IPaymentRepository } from './domain/repositories/payment.repository';
// Gateway
export { PAYMENT_GATEWAY_FACTORY, type IPaymentGatewayFactory } from './infrastructure/services/payment-gateway.interface';
// Domain Events — Payment
export { PaymentCompletedEvent } from './domain/events/payment-completed.event';
export { PaymentFailedEvent } from './domain/events/payment-failed.event';
export { PaymentRefundedEvent } from './domain/events/payment-refunded.event';
// Domain Events — Order
export { OrderCreatedEvent } from './domain/events/order-created.event';
export { OrderCancelledEvent } from './domain/events/order-cancelled.event';
export { OrderPaidEvent } from './domain/events/order-paid.event';
// Domain Events — Escrow
export { EscrowDisputedEvent } from './domain/events/escrow-disputed.event';
export { EscrowHeldEvent } from './domain/events/escrow-held.event';
export { EscrowReleasedEvent } from './domain/events/escrow-released.event';

View File

@@ -1 +1,3 @@
export { PrismaEscrowRepository } from './prisma-escrow.repository';
export { PrismaOrderRepository } from './prisma-order.repository';
export { PrismaPaymentRepository } from './prisma-payment.repository';

View File

@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { type Escrow as PrismaEscrow } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { EscrowEntity, type EscrowProps } from '../../domain/entities/escrow.entity';
import { type IEscrowRepository } from '../../domain/repositories/escrow.repository';
import { Money } from '../../domain/value-objects/money.vo';
@Injectable()
export class PrismaEscrowRepository implements IEscrowRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<EscrowEntity | null> {
const escrow = await this.prisma.escrow.findUnique({ where: { id } });
return escrow ? this.toDomain(escrow) : null;
}
async findByOrderId(orderId: string): Promise<EscrowEntity | null> {
const escrow = await this.prisma.escrow.findUnique({
where: { orderId },
});
return escrow ? this.toDomain(escrow) : null;
}
async save(entity: EscrowEntity): Promise<void> {
await this.prisma.escrow.create({
data: {
id: entity.id,
orderId: entity.orderId,
amountVND: entity.amount.value,
feeVND: entity.fee.value,
status: entity.status,
heldAt: entity.heldAt,
releasedAt: entity.releasedAt,
disputeReason: entity.disputeReason,
disputedAt: entity.disputedAt,
},
});
}
async update(entity: EscrowEntity): Promise<void> {
await this.prisma.escrow.update({
where: { id: entity.id },
data: {
status: entity.status,
heldAt: entity.heldAt,
releasedAt: entity.releasedAt,
disputeReason: entity.disputeReason,
disputedAt: entity.disputedAt,
},
});
}
private toDomain(raw: PrismaEscrow): EscrowEntity {
const amount = Money.create(raw.amountVND).unwrap();
const fee = Money.create(raw.feeVND).unwrap();
const props: EscrowProps = {
orderId: raw.orderId,
amount,
fee,
status: raw.status,
heldAt: raw.heldAt,
releasedAt: raw.releasedAt,
disputeReason: raw.disputeReason,
disputedAt: raw.disputedAt,
};
return new EscrowEntity(raw.id, props, raw.createdAt, raw.updatedAt);
}
}

View File

@@ -0,0 +1,115 @@
import { Injectable } from '@nestjs/common';
import { type Prisma, type Order as PrismaOrder, type OrderStatus } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { OrderEntity, type OrderProps } from '../../domain/entities/order.entity';
import { type IOrderRepository } from '../../domain/repositories/order.repository';
import { Money } from '../../domain/value-objects/money.vo';
import { PlatformFee } from '../../domain/value-objects/platform-fee.vo';
@Injectable()
export class PrismaOrderRepository implements IOrderRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<OrderEntity | null> {
const order = await this.prisma.order.findUnique({ where: { id } });
return order ? this.toDomain(order) : null;
}
async findByIdempotencyKey(key: string): Promise<OrderEntity | null> {
const order = await this.prisma.order.findUnique({
where: { idempotencyKey: key },
});
return order ? this.toDomain(order) : null;
}
async findByBuyerId(
buyerId: string,
options?: { status?: OrderStatus; limit?: number; offset?: number },
): Promise<{ items: OrderEntity[]; total: number }> {
const where = {
buyerId,
...(options?.status ? { status: options.status } : {}),
};
const [orders, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options?.limit ?? 20,
skip: options?.offset ?? 0,
}),
this.prisma.order.count({ where }),
]);
return { items: orders.map((o) => this.toDomain(o)), total };
}
async findBySellerId(
sellerId: string,
options?: { status?: OrderStatus; limit?: number; offset?: number },
): Promise<{ items: OrderEntity[]; total: number }> {
const where = {
sellerId,
...(options?.status ? { status: options.status } : {}),
};
const [orders, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options?.limit ?? 20,
skip: options?.offset ?? 0,
}),
this.prisma.order.count({ where }),
]);
return { items: orders.map((o) => this.toDomain(o)), total };
}
async save(entity: OrderEntity): Promise<void> {
await this.prisma.order.create({
data: {
id: entity.id,
buyerId: entity.buyerId,
sellerId: entity.sellerId,
listingId: entity.listingId,
status: entity.status,
amountVND: entity.amount.value,
platformFeeVND: entity.platformFee.value,
sellerPayoutVND: entity.sellerPayout.value,
idempotencyKey: entity.idempotencyKey,
metadata: entity.metadata as Prisma.InputJsonValue,
},
});
}
async update(entity: OrderEntity): Promise<void> {
await this.prisma.order.update({
where: { id: entity.id },
data: {
status: entity.status,
metadata: entity.metadata as Prisma.InputJsonValue,
},
});
}
private toDomain(raw: PrismaOrder): OrderEntity {
const amount = Money.create(raw.amountVND).unwrap();
const fee = PlatformFee.create(raw.platformFeeVND).unwrap();
const payout = Money.create(raw.sellerPayoutVND).unwrap();
const props: OrderProps = {
buyerId: raw.buyerId,
sellerId: raw.sellerId,
listingId: raw.listingId,
status: raw.status,
amount,
platformFee: fee,
sellerPayout: payout,
idempotencyKey: raw.idempotencyKey,
metadata: raw.metadata,
};
return new OrderEntity(raw.id, props, raw.createdAt, raw.updatedAt);
}
}

View File

@@ -1,32 +1,52 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { CancelOrderHandler } from './application/commands/cancel-order/cancel-order.handler';
import { CreateOrderHandler } from './application/commands/create-order/create-order.handler';
import { CreatePaymentHandler } from './application/commands/create-payment/create-payment.handler';
import { HandleCallbackHandler } from './application/commands/handle-callback/handle-callback.handler';
import { HoldEscrowHandler } from './application/commands/hold-escrow/hold-escrow.handler';
import { RefundPaymentHandler } from './application/commands/refund-payment/refund-payment.handler';
import { ReleaseEscrowHandler } from './application/commands/release-escrow/release-escrow.handler';
import { GetOrderStatusHandler } from './application/queries/get-order-status/get-order-status.handler';
import { GetPaymentStatusHandler } from './application/queries/get-payment-status/get-payment-status.handler';
import { ListTransactionsHandler } from './application/queries/list-transactions/list-transactions.handler';
import { ESCROW_REPOSITORY } from './domain/repositories/escrow.repository';
import { ORDER_REPOSITORY } from './domain/repositories/order.repository';
import { PAYMENT_REPOSITORY } from './domain/repositories/payment.repository';
import { PrismaEscrowRepository } from './infrastructure/repositories/prisma-escrow.repository';
import { PrismaOrderRepository } from './infrastructure/repositories/prisma-order.repository';
import { PrismaPaymentRepository } from './infrastructure/repositories/prisma-payment.repository';
import { MomoService } from './infrastructure/services/momo.service';
import { PaymentGatewayFactory } from './infrastructure/services/payment-gateway.factory';
import { PAYMENT_GATEWAY_FACTORY } from './infrastructure/services/payment-gateway.interface';
import { VnpayService } from './infrastructure/services/vnpay.service';
import { ZalopayService } from './infrastructure/services/zalopay.service';
import { OrdersController } from './presentation/controllers/orders.controller';
import { PaymentsController } from './presentation/controllers/payments.controller';
const CommandHandlers = [
CancelOrderHandler,
CreateOrderHandler,
CreatePaymentHandler,
HandleCallbackHandler,
HoldEscrowHandler,
RefundPaymentHandler,
ReleaseEscrowHandler,
];
const QueryHandlers = [GetPaymentStatusHandler, ListTransactionsHandler];
const QueryHandlers = [
GetOrderStatusHandler,
GetPaymentStatusHandler,
ListTransactionsHandler,
];
@Module({
imports: [CqrsModule],
controllers: [PaymentsController],
controllers: [OrdersController, PaymentsController],
providers: [
// Repositories
{ provide: ESCROW_REPOSITORY, useClass: PrismaEscrowRepository },
{ provide: ORDER_REPOSITORY, useClass: PrismaOrderRepository },
{ provide: PAYMENT_REPOSITORY, useClass: PrismaPaymentRepository },
// Gateway Services
@@ -39,6 +59,6 @@ const QueryHandlers = [GetPaymentStatusHandler, ListTransactionsHandler];
...CommandHandlers,
...QueryHandlers,
],
exports: [PAYMENT_REPOSITORY, PAYMENT_GATEWAY_FACTORY],
exports: [ESCROW_REPOSITORY, ORDER_REPOSITORY, PAYMENT_REPOSITORY, PAYMENT_GATEWAY_FACTORY],
})
export class PaymentsModule {}

View File

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

View File

@@ -0,0 +1,116 @@
import {
Body,
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { CancelOrderCommand } from '../../application/commands/cancel-order/cancel-order.command';
import { type CancelOrderResult } from '../../application/commands/cancel-order/cancel-order.handler';
import { CreateOrderCommand } from '../../application/commands/create-order/create-order.command';
import { type CreateOrderResult } from '../../application/commands/create-order/create-order.handler';
import { HoldEscrowCommand } from '../../application/commands/hold-escrow/hold-escrow.command';
import { type HoldEscrowResult } from '../../application/commands/hold-escrow/hold-escrow.handler';
import { ReleaseEscrowCommand } from '../../application/commands/release-escrow/release-escrow.command';
import { type ReleaseEscrowResult } from '../../application/commands/release-escrow/release-escrow.handler';
import { type OrderStatusDto } from '../../application/queries/get-order-status/get-order-status.handler';
import { GetOrderStatusQuery } from '../../application/queries/get-order-status/get-order-status.query';
import { type CancelOrderDto } from '../dto/cancel-order.dto';
import { type CreateOrderDto } from '../dto/create-order.dto';
@ApiTags('orders')
@Controller('orders')
export class OrdersController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Create a new order from auction' })
@ApiResponse({ status: 201, description: 'Order created successfully' })
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 409, description: 'Duplicate order (idempotency)' })
@UseGuards(JwtAuthGuard)
@Post()
async createOrder(
@Body() dto: CreateOrderDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateOrderResult> {
return this.commandBus.execute(
new CreateOrderCommand(
user.sub,
dto.sellerId,
dto.listingId,
BigInt(dto.amountVND),
dto.idempotencyKey,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Get order status by ID' })
@ApiResponse({ status: 200, description: 'Order status retrieved' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Order not found' })
@UseGuards(JwtAuthGuard)
@Get(':id')
async getOrderStatus(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<OrderStatusDto> {
return this.queryBus.execute(new GetOrderStatusQuery(id, user.sub));
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Cancel an order' })
@ApiResponse({ status: 201, description: 'Order cancelled successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Order not found' })
@ApiResponse({ status: 409, description: 'Invalid status transition' })
@UseGuards(JwtAuthGuard)
@Post(':id/cancel')
async cancelOrder(
@Param('id') id: string,
@Body() dto: CancelOrderDto,
@CurrentUser() user: JwtPayload,
): Promise<CancelOrderResult> {
return this.commandBus.execute(
new CancelOrderCommand(id, user.sub, dto.reason),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Hold escrow funds for an order (admin only)' })
@ApiResponse({ status: 201, description: 'Escrow held successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Post(':id/escrow/hold')
async holdEscrow(@Param('id') orderId: string): Promise<HoldEscrowResult> {
return this.commandBus.execute(new HoldEscrowCommand(orderId));
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Release escrow funds for an order (admin only)' })
@ApiResponse({ status: 201, description: 'Escrow released successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Post(':id/escrow/release')
async releaseEscrow(@Param('id') orderId: string): Promise<ReleaseEscrowResult> {
return this.commandBus.execute(new ReleaseEscrowCommand(orderId));
}
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class CancelOrderDto {
@ApiProperty({ description: 'Reason for cancellation' })
@IsString()
@IsNotEmpty()
@MinLength(5, { message: 'Lý do hủy phải ít nhất 5 ký tự' })
reason!: string;
}

View File

@@ -0,0 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class CreateOrderDto {
@ApiProperty({ description: 'Seller user ID' })
@IsString()
@IsNotEmpty()
sellerId!: string;
@ApiProperty({ description: 'Listing ID for the order' })
@IsString()
@IsNotEmpty()
listingId!: string;
@ApiProperty({
type: Number,
description: 'Amount in VND (1 100,000,000,000)',
example: 5_000_000_000,
})
@IsNotEmpty()
@IsNumber()
@Min(1, { message: 'Số tiền phải lớn hơn 0' })
@Max(100_000_000_000, { message: 'Số tiền vượt quá giới hạn (100 tỷ VND)' })
@Transform(({ value }) => {
const num = Number(value);
if (!Number.isFinite(num) || !Number.isInteger(num)) return value;
return num;
}, { toClassOnly: true })
amountVND!: number;
@ApiPropertyOptional({ description: 'Idempotency key to prevent duplicate orders' })
@IsOptional()
@IsString()
idempotencyKey?: string;
}

View File

@@ -1,3 +1,5 @@
export { CancelOrderDto } from './cancel-order.dto';
export { CreateOrderDto } from './create-order.dto';
export { CreatePaymentDto } from './create-payment.dto';
export { RefundPaymentDto } from './refund-payment.dto';
export { ListTransactionsDto } from './list-transactions.dto';
export { RefundPaymentDto } from './refund-payment.dto';

View File

@@ -47,6 +47,15 @@ export enum ErrorCode {
PAYMENT_ALREADY_PROCESSED = 'PAYMENT_ALREADY_PROCESSED',
PAYMENT_INVALID_AMOUNT = 'PAYMENT_INVALID_AMOUNT',
// Order
ORDER_NOT_FOUND = 'ORDER_NOT_FOUND',
ORDER_INVALID_STATUS_TRANSITION = 'ORDER_INVALID_STATUS_TRANSITION',
ORDER_ALREADY_EXISTS = 'ORDER_ALREADY_EXISTS',
// Escrow
ESCROW_NOT_FOUND = 'ESCROW_NOT_FOUND',
ESCROW_INVALID_STATE = 'ESCROW_INVALID_STATE',
// Subscription
SUBSCRIPTION_NOT_FOUND = 'SUBSCRIPTION_NOT_FOUND',
SUBSCRIPTION_ALREADY_ACTIVE = 'SUBSCRIPTION_ALREADY_ACTIVE',