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
- One account per user per currency – Each user has one NGN account, one USD account, etc.
- Initial balance is zero – Accounts start with 0 balance
- Currency validation – Must be supported currency (NGN, USD)
- Automatic creation – Account created on first transaction
Balance Management
- No negative balance – Debit operations must check balance first
- Atomic operations – Credit/debit operations must be transactional
- Decimal precision – Use
Decimal.jsfor accurate calculations - Event logging – Every balance change creates AccountEvent
Withdrawal Rules
- Minimum amount – Enforce minimum withdrawal (e.g., 5000 NGN)
- Maximum amount – Cannot exceed account balance
- Pending limit – Only one pending withdrawal per user
- Admin approval required – All withdrawals require approval
- Bank account verified – Must have verified bank details
- Processing fee – Optional platform fee deducted
Refund Rules
- Full refund only – No partial refunds (refund entire payment)
- One refund per order – Cannot refund same order twice
- Automatic processing – Refunds processed automatically
- Status tracking – Track refund from pending to completed
- 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
numberfor money calculations - Log all events – Create AccountEvent for every balance change
- Check balance before debit – Use
hasBalance()beforedebit() - 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.