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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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