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