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,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 }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user