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,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
149
apps/api/src/modules/payments/domain/entities/escrow.entity.ts
Normal file
149
apps/api/src/modules/payments/domain/entities/escrow.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export { EscrowEntity, type EscrowProps } from './escrow.entity';
|
||||
export { OrderEntity, type OrderProps } from './order.entity';
|
||||
export { PaymentEntity, type PaymentProps } from './payment.entity';
|
||||
|
||||
165
apps/api/src/modules/payments/domain/entities/order.entity.ts
Normal file
165
apps/api/src/modules/payments/domain/entities/order.entity.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { Money } from './money.vo';
|
||||
export { PlatformFee } from './platform-fee.vo';
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { PrismaEscrowRepository } from './prisma-escrow.repository';
|
||||
export { PrismaOrderRepository } from './prisma-order.repository';
|
||||
export { PrismaPaymentRepository } from './prisma-payment.repository';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { OrdersController } from './orders.controller';
|
||||
export { PaymentsController } from './payments.controller';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user