Skip to content

Accounts Domain

The Accounts domain manages financial accounts, balances, withdrawals, refunds, and financial transactions. It tracks money flow between the platform, sellers, and customers.

Overview

The Accounts domain encompasses:

  • Accounts – Financial accounts with balance tracking
  • Withdrawals – Seller payout requests
  • Refunds – Customer money returns
  • Account Events – Audit trail of balance changes

Entities

Account Entity

Represents a financial account with balance.

// domain/accounts/account.entity.ts
export class Account {
  id: string;
  userId: string;
  balance: Decimal;
  currency: string;
  createdAt: Date;
  updatedAt: Date;

  static create(
    id: string,
    userId: string,
    balance: Decimal,
    currency: string
  ): Account

  credit(amount: Decimal): void
  debit(amount: Decimal): void
  hasBalance(amount: Decimal): boolean
}

Key features: - One account per user per currency - Balance in smallest currency unit (kobo, cents) - Credit/debit operations with validation - Balance checks before debits

Account operations:

// Credit account
account.credit(new Decimal(5000)); // Add 5000 to balance

// Debit account
if (account.hasBalance(new Decimal(3000))) {
  account.debit(new Decimal(3000)); // Subtract 3000 from balance
}

Withdraw Entity

Withdrawal request from seller.

// domain/accounts/withdraw.entity.ts
export class Withdraw {
  id: string;
  userId: string;
  amount: Decimal;
  currency: string;
  status: WithdrawStatus;
  reason: string | null;
  bankAccount: BankAccountDetails;
  requestedAt: Date;
  processedAt: Date | null;

  static create(
    id: string,
    userId: string,
    amount: Decimal,
    currency: string,
    bankAccount: BankAccountDetails
  ): Withdraw

  approve(): void
  reject(reason: string): void
  complete(): void
}

export type WithdrawStatus =
  | 'pending'     // Awaiting admin approval
  | 'approved'    // Admin approved, processing transfer
  | 'completed'   // Transfer successful
  | 'rejected'    // Admin rejected

export type BankAccountDetails = {
  accountName: string;
  accountNumber: string;
  bankCode: string;
  bankName: string;
}

Withdrawal lifecycle: 1. User requests withdrawal 2. Admin reviews and approves/rejects 3. If approved, transfer initiated 4. Transfer completed/failed

Refund Entity

Refund to customer.

// domain/accounts/refund.entity.ts
export class Refund {
  id: string;
  userId: string; // Customer
  orderId: string;
  paymentId: string;
  amount: Decimal;
  currency: string;
  status: RefundStatus;
  reason: string;
  requestedAt: Date;
  processedAt: Date | null;

  static create(
    id: string,
    userId: string,
    orderId: string,
    paymentId: string,
    amount: Decimal,
    currency: string,
    reason: string
  ): Refund

  process(): void
  fail(error: string): void
}

export type RefundStatus =
  | 'pending'      // Awaiting processing
  | 'processing'   // Refund in progress
  | 'completed'    // Refund successful
  | 'failed'       // Refund failed

Refund triggers: - Order rejected by seller - Order cancelled (seller accepted cancellation) - Admin resolved dispute in favor of customer

AccountEvent Entity

Audit trail of account balance changes.

// domain/accounts/account_event.entity.ts
export class AccountEvent {
  id: string;
  accountId: string;
  type: EventType;
  amount: Decimal;
  balanceBefore: Decimal;
  balanceAfter: Decimal;
  reference: string; // Order ID, Payment ID, etc.
  description: string;
  createdAt: Date;

  static create(
    id: string,
    accountId: string,
    type: EventType,
    amount: Decimal,
    balanceBefore: Decimal,
    balanceAfter: Decimal,
    reference: string,
    description: string
  ): AccountEvent
}

export type EventType =
  | 'payment_received'    // Customer paid for order
  | 'payment_released'    // Seller received payout
  | 'payment_refunded'    // Customer received refund
  | 'commission_earned'   // Platform earned commission
  | 'withdraw_completed'  // Withdrawal processed
  | 'withdraw_failed'     // Withdrawal failed
  | 'balance_adjustment'  // Manual admin adjustment

Repositories

AccountRepository

// adapters/account/account.repository.ts
export abstract class AccountRepository extends AbstractRepository<Account, string> {
  abstract findByUserId(userId: string, currency: string): Promise<Account | null>;
  abstract credit(accountId: string, amount: Decimal, transaction?: any): Promise<void>;
  abstract debit(accountId: string, amount: Decimal, transaction?: any): Promise<void>;
  abstract getBalance(accountId: string): Promise<Decimal>;
}

Key operations: - findByUserId() – Get account by user and currency - credit() – Add to balance (with transaction support) - debit() – Subtract from balance (with transaction support) - getBalance() – Get current balance

WithdrawRepository

// adapters/account/withdraw.repository.ts
export abstract class WithdrawRepository extends AbstractRepository<Withdraw, string, WithdrawQueryPolicy> {
  abstract updateStatus(
    id: string,
    status: WithdrawStatus,
    transaction?: any
  ): Promise<void>;
  abstract getPendingWithdrawals(): Promise<Withdraw[]>;
  abstract getTotalWithdrawn(userId: string): Promise<Decimal>;
}

export class WithdrawQueryPolicy {
  userId?: string;
  status?: WithdrawStatus;
  fromDate?: Date;
  toDate?: Date;
}

RefundRepository

// adapters/account/refund.repository.ts
export abstract class RefundRepository extends AbstractRepository<Refund, string> {
  abstract findByOrderId(orderId: string): Promise<Refund | null>;
  abstract updateStatus(
    id: string,
    status: RefundStatus,
    transaction?: any
  ): Promise<void>;
  abstract getPendingRefunds(): Promise<Refund[]>;
}

AccountEventRepository

// adapters/account/account_event.repository.ts
export abstract class AccountEventRepository extends AbstractRepository<AccountEvent, string> {
  abstract getByAccountId(
    accountId: string,
    pagination: Pagination
  ): Promise<Paginated<AccountEvent>>;
  abstract getByReference(reference: string): Promise<AccountEvent[]>;
}

Business Rules

Account Creation

  1. One account per user per currency – Each user has one NGN account, one USD account, etc.
  2. Initial balance is zero – Accounts start with 0 balance
  3. Currency validation – Must be supported currency (NGN, USD)
  4. Automatic creation – Account created on first transaction

Balance Management

  1. No negative balance – Debit operations must check balance first
  2. Atomic operations – Credit/debit operations must be transactional
  3. Decimal precision – Use Decimal.js for accurate calculations
  4. Event logging – Every balance change creates AccountEvent

Withdrawal Rules

  1. Minimum amount – Enforce minimum withdrawal (e.g., 5000 NGN)
  2. Maximum amount – Cannot exceed account balance
  3. Pending limit – Only one pending withdrawal per user
  4. Admin approval required – All withdrawals require approval
  5. Bank account verified – Must have verified bank details
  6. Processing fee – Optional platform fee deducted

Refund Rules

  1. Full refund only – No partial refunds (refund entire payment)
  2. One refund per order – Cannot refund same order twice
  3. Automatic processing – Refunds processed automatically
  4. Status tracking – Track refund from pending to completed
  5. Failure handling – Retry failed refunds

Use Cases

Account Management

  • Create account – Initialize user account
  • Get balance – Check current balance
  • View statement – List account events (transactions)
  • Credit account – Add funds (after order completion)
  • Debit account – Deduct funds (for withdrawals)

Withdrawal Processing

  • Request withdrawal – Seller requests payout
  • Approve withdrawal – Admin approves request
  • Reject withdrawal – Admin rejects with reason
  • Process withdrawal – Transfer funds to bank account
  • Complete withdrawal – Mark as completed
  • Handle failure – Mark as failed and restore balance

Refund Processing

  • Create refund – Initiate refund for order
  • Process refund – Transfer funds back to customer
  • Complete refund – Mark as completed
  • Handle failure – Retry or escalate

Integration Points

With Orders Domain

  • Order completed → Credit seller account (totalAmount - commission)
  • Order completed → Credit platform account (commission)
  • Order rejected/cancelled → Create refund for customer
  • AccountEvent.reference stores Order.id

With Payments Domain

  • Payment received → Credit platform account (temporarily)
  • Payment released → Debit platform, credit seller
  • Payment refunded → Debit platform, credit customer
  • AccountEvent.reference stores Payment.id

With Users Domain

  • Account.userId references User.id
  • Withdrawal requires User.bankAccount
  • Authorization: only user/admin can view account

Key Workflows

Order Completion Payout

1. Order reaches 'completed' state
2. Calculate seller payout: totalAmount - commission
3. Get seller account (create if not exists)
4. Get platform account
5. Start transaction:
   a. Credit seller account (payout amount)
   b. Record AccountEvent (payment_released)
   c. Credit platform account (commission)
   d. Record AccountEvent (commission_earned)
   e. Update Payment status to 'released'
6. Commit transaction
7. Notify seller of payout

Withdrawal Flow

1. Seller requests withdrawal
2. Validate:
   - Amount >= minimum
   - Amount <= balance
   - No pending withdrawals
   - Bank account verified
3. Create Withdraw entity (status = 'pending')
4. Admin reviews withdrawal
5a. Admin approves:
    Update status to 'approved'
    Initialize transfer via PaymentGateway
    Debit user account
    Record AccountEvent (withdraw_completed)
    Update status to 'completed'
    Notify user
OR
5b. Admin rejects:
    Update status to 'rejected'
    Store rejection reason
    Notify user

Refund Flow

1. Order rejected or cancelled
2. Create Refund entity (status = 'pending')
3. Update status to 'processing'
4. Process refund via PaymentGateway
5. Start transaction:
   a. Debit platform account
   b. Record AccountEvent (payment_refunded)
   c. Update Payment status to 'refunded'
   d. Update Refund status to 'completed'
6. Commit transaction
7. Notify customer

Balance Adjustment (Admin)

1. Admin initiates adjustment
2. Verify admin permissions
3. Start transaction:
   a. Credit/debit account
   b. Record AccountEvent (balance_adjustment)
   c. Store reason/reference
4. Commit transaction
5. Notify user if significant amount

Data Model

┌──────────────┐
│   Account    │
├──────────────┤
│ id           │
│ userId       │────► User
│ balance      │
│ currency     │
└──────────────┘
       │ 1:N
┌──────────────┐
│ AccountEvent │
├──────────────┤
│ id           │
│ accountId    │
│ type         │
│ amount       │
│ balanceBefore│
│ balanceAfter │
│ reference    │
│ description  │
└──────────────┘

┌──────────────┐
│   Withdraw   │
├──────────────┤
│ id           │
│ userId       │────► User
│ amount       │
│ status       │
│ bankAccount  │
└──────────────┘

┌──────────────┐
│    Refund    │
├──────────────┤
│ id           │
│ userId       │────► User
│ orderId      │────► Order
│ paymentId    │────► Payment
│ amount       │
│ status       │
└──────────────┘

Analytics

Account Metrics

  • Total balance – Sum of all user balances
  • Platform balance – Platform account balance
  • Pending withdrawals – Total amount pending
  • Monthly payouts – Total paid to sellers per month

User Metrics

  • Earnings – Total credited to account
  • Withdrawals – Total withdrawn
  • Available balance – Current balance
  • Pending balance – In processing

Platform Metrics

  • Total commission – Sum of commission_earned events
  • Total refunds – Sum of payment_refunded events
  • Total payouts – Sum of payment_released events

Transaction Safety

Using Database Transactions

// app/accounts/account.usecases.ts
async creditSellerForOrder(orderId: string): Promise<void> {
  const order = await this.orderRepository.findById(orderId);
  if (!order) throw new OrderNotFound();

  // Start transaction
  const transaction = await this.db.startTransaction();

  try {
    // Get accounts
    const sellerAccount = await this.getOrCreateAccount(
      order.sellerId,
      'NGN',
      transaction
    );
    const platformAccount = await this.getOrCreateAccount(
      'platform',
      'NGN',
      transaction
    );

    // Calculate amounts
    const sellerAmount = order.totalAmount.minus(order.commissionAmount);
    const platformAmount = order.commissionAmount;

    // Credit seller
    await this.accountRepository.credit(
      sellerAccount.id,
      sellerAmount,
      transaction
    );
    await this.accountEventRepository.create(
      AccountEvent.create(
        generateId(),
        sellerAccount.id,
        'payment_released',
        sellerAmount,
        sellerAccount.balance,
        sellerAccount.balance.plus(sellerAmount),
        orderId,
        `Payout for order ${order.getNumber()}`
      ),
      transaction
    );

    // Credit platform
    await this.accountRepository.credit(
      platformAccount.id,
      platformAmount,
      transaction
    );
    await this.accountEventRepository.create(
      AccountEvent.create(
        generateId(),
        platformAccount.id,
        'commission_earned',
        platformAmount,
        platformAccount.balance,
        platformAccount.balance.plus(platformAmount),
        orderId,
        `Commission from order ${order.getNumber()}`
      ),
      transaction
    );

    // Update payment
    await this.paymentRepository.updateStatus(
      order.paymentId,
      'released',
      transaction
    );

    // Commit transaction
    await transaction.commit();
  } catch (error) {
    await transaction.rollback();
    throw error;
  }
}

Error Handling

Insufficient Balance

export class InsufficientBalance extends DomainException {
  constructor(available: Decimal, required: Decimal) {
    super(
      'INSUFFICIENT_BALANCE',
      `Insufficient balance. Available: ${available}, Required: ${required}`
    );
  }
}

Duplicate Withdrawal

export class PendingWithdrawalExists extends DomainException {
  constructor() {
    super(
      'PENDING_WITHDRAWAL_EXISTS',
      'You already have a pending withdrawal request'
    );
  }
}

Minimum Withdrawal Not Met

export class MinimumWithdrawalNotMet extends DomainException {
  constructor(minimum: Decimal) {
    super(
      'MINIMUM_WITHDRAWAL_NOT_MET',
      `Minimum withdrawal amount is ${minimum}`
    );
  }
}

Best Practices

✅ Do

  • Use transactions – Wrap balance changes in database transactions
  • Use Decimal – Never use number for money calculations
  • Log all events – Create AccountEvent for every balance change
  • Check balance before debit – Use hasBalance() before debit()
  • Validate amounts – Ensure amounts are positive
  • Verify bank accounts – Validate bank details before processing withdrawals
  • Retry failed operations – Implement retry logic for transient failures

❌ Don't

  • Don't use floating point – Always use Decimal for money
  • Don't skip event logging – Every change must be auditable
  • Don't allow negative balance – Always check before debit
  • Don't process without transaction – Ensure atomicity
  • Don't hardcode fees – Make fees configurable
  • Don't expose raw balance – Format for display (NGN 5,000.00)
  • Don't skip notifications – Notify users of balance changes

Validation Rules

Withdrawal Validation

// app/accounts/withdraw.validators.ts
export const createWithdrawalSchema = z.object({
  amount: z
    .number()
    .positive('Amount must be positive')
    .min(5000, 'Minimum withdrawal is NGN 5,000'),
  currency: z.enum(['NGN', 'USD']),
  bankAccount: z.object({
    accountName: z.string().min(3),
    accountNumber: z.string().length(10),
    bankCode: z.string().length(3),
    bankName: z.string()
  })
});

Refund Validation

// app/accounts/refund.validators.ts
export const createRefundSchema = z.object({
  orderId: z.string().uuid(),
  paymentId: z.string().uuid(),
  amount: z.number().positive(),
  currency: z.enum(['NGN', 'USD']),
  reason: z.string().min(10, 'Please provide a detailed reason')
});

Summary

The Accounts domain provides:

  • Balance management with credit/debit operations
  • Withdrawal processing with admin approval workflow
  • Refund processing for order cancellations
  • Account events for complete audit trail
  • Transaction safety with database transactions
  • Decimal precision for accurate financial calculations
  • Multi-currency support (NGN, USD, etc.)

This domain ensures accurate tracking of all financial transactions and maintains platform financial integrity.