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:
@@ -0,0 +1,7 @@
|
||||
export class CancelOrderCommand {
|
||||
constructor(
|
||||
public readonly orderId: string,
|
||||
public readonly userId: string,
|
||||
public readonly reason: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class HoldEscrowCommand {
|
||||
constructor(
|
||||
public readonly orderId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class ReleaseEscrowCommand {
|
||||
constructor(
|
||||
public readonly orderId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetOrderStatusQuery {
|
||||
constructor(
|
||||
public readonly orderId: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user