Finance Module Implementation Guide
A detailed guide covering the implementation of the Finance module within the Laravel-DDD Enterprise CRM/ERP system.
📋 Table of Contents
- Module Overview
- Architecture & Design Decisions
- Domain Layer Implementation
- Application Layer Implementation
- Infrastructure Layer Implementation
- Presentation Layer Implementation
- Event-Driven Integration
- Database Schema & Relationships
- Business Logic & Rules
- Performance Considerations
- Testing Strategy
Module Overview
Business Purpose
The Finance module provides comprehensive financial management capabilities including double-entry bookkeeping, chart of accounts management, journal entry processing, and financial reporting. It serves as the central financial record-keeping system that integrates with all other modules to maintain accurate financial data and ensure regulatory compliance.
Core Entities
- ChartOfAccount - Account structure defining assets, liabilities, equity, income, and expenses
- Transaction - Financial transactions with double-entry journal entries
- JournalEntry - Individual entries that make up transactions (debits and credits)
- Period - Accounting periods for financial reporting and closing procedures
- Budget - Financial planning and variance analysis
- TaxRate - Tax calculation and compliance management
Key Features
- Double-entry bookkeeping system
- Flexible chart of accounts structure
- Automated journal entry creation from business events
- Financial statement generation (Balance Sheet, P&L, Cash Flow)
- Period-end closing procedures
- Multi-currency support
- Tax calculation and reporting
- Budget management and variance analysis
Architecture & Design Decisions
1. Double-Entry Bookkeeping Implementation
Decision: Implement strict double-entry bookkeeping with automated validation and balancing.
Rationale:
- Ensures financial data integrity and accuracy
- Meets accounting standards and regulatory requirements
- Provides complete audit trail for all financial transactions
- Enables accurate financial reporting and analysis
- Supports complex business scenarios and multi-entity structures
// Every transaction must balance (debits = credits)
class Transaction extends Model
{
protected static function boot()
{
parent::boot();
static::saving(function ($transaction) {
if (!$transaction->isBalanced()) {
throw new UnbalancedTransactionException(
'Transaction must balance: debits must equal credits'
);
}
});
}
public function isBalanced(): bool
{
$debits = $this->journalEntries()->sum('debit_amount');
$credits = $this->journalEntries()->sum('credit_amount');
return abs($debits - $credits) < 0.01; // Account for floating point precision
}
}2. Event-Driven Financial Integration
Decision: Use domain events from other modules to automatically create financial entries.
Rationale:
- Maintains loose coupling between modules
- Ensures all business transactions are properly recorded
- Provides consistent financial treatment across modules
- Supports complex integration scenarios
- Enables audit compliance and traceability
3. Flexible Account Hierarchy
Decision: Implement hierarchical chart of accounts with configurable account codes and categories.
Rationale:
- Supports different accounting frameworks (GAAP, IFRS)
- Enables multi-company and multi-entity structures
- Provides flexibility for different business models
- Supports detailed financial analysis and reporting
- Allows for account consolidation and rollup
4. Period-Based Financial Management
Decision: Implement accounting periods with opening/closing procedures and period-specific reporting.
Rationale:
- Supports standard accounting practices
- Enables accurate period-over-period comparisons
- Provides framework for financial controls
- Supports regulatory reporting requirements
- Enables budget vs. actual analysis
Domain Layer Implementation
Core Models
ChartOfAccount Model
Location: app/Modules/Finance/Domain/Models/ChartOfAccount.php
class ChartOfAccount extends Model
{
use HasFactory, SoftDeletes, Auditable;
protected $table = 'finance_chart_of_accounts';
protected $fillable = [
'account_code', 'account_name', 'account_type', 'account_category',
'parent_account_id', 'is_active', 'normal_balance', 'description',
'tax_account', 'system_account', 'allow_manual_entries'
];
protected $casts = [
'account_type' => AccountType::class,
'normal_balance' => BalanceType::class,
'is_active' => 'boolean',
'tax_account' => 'boolean',
'system_account' => 'boolean',
'allow_manual_entries' => 'boolean',
];
// Boot method for validation and events
protected static function boot()
{
parent::boot();
static::creating(function ($account) {
// Auto-generate account code if not provided
if (!$account->account_code) {
$account->account_code = static::generateAccountCode($account->account_type);
}
// Validate account code uniqueness
if (static::where('account_code', $account->account_code)->exists()) {
throw new DuplicateAccountCodeException(
"Account code {$account->account_code} already exists"
);
}
});
static::created(function ($account) {
event(new ChartOfAccountCreated($account));
});
static::updating(function ($account) {
// Prevent changes to system accounts
if ($account->system_account && $account->isDirty(['account_code', 'account_type'])) {
throw new SystemAccountModificationException(
'System accounts cannot be modified'
);
}
});
}
// Business Methods
public function getBalance(\DateTimeInterface $asOfDate = null): float
{
$asOfDate = $asOfDate ?? now();
$balance = $this->journalEntries()
->whereHas('transaction', function ($query) use ($asOfDate) {
$query->where('transaction_date', '<=', $asOfDate)
->where('status', TransactionStatus::POSTED);
})
->selectRaw('
SUM(CASE WHEN ? = ? THEN debit_amount - credit_amount
ELSE credit_amount - debit_amount END) as balance
', [$this->normal_balance->value, BalanceType::DEBIT->value])
->value('balance') ?? 0;
return round($balance, 2);
}
public function getBalanceForPeriod(Period $period): float
{
return $this->journalEntries()
->whereHas('transaction', function ($query) use ($period) {
$query->whereBetween('transaction_date', [
$period->start_date,
$period->end_date
])->where('status', TransactionStatus::POSTED);
})
->selectRaw('
SUM(CASE WHEN ? = ? THEN debit_amount - credit_amount
ELSE credit_amount - debit_amount END) as balance
', [$this->normal_balance->value, BalanceType::DEBIT->value])
->value('balance') ?? 0;
}
public function getTrial Balance(\DateTimeInterface $asOfDate = null): array
{
$balance = $this->getBalance($asOfDate);
return [
'account_code' => $this->account_code,
'account_name' => $this->account_name,
'account_type' => $this->account_type,
'balance' => $balance,
'debit_balance' => $this->normal_balance === BalanceType::DEBIT ? max($balance, 0) : 0,
'credit_balance' => $this->normal_balance === BalanceType::CREDIT ? max($balance, 0) : 0,
];
}
public function canBeDeleted(): bool
{
// Cannot delete accounts with transactions
if ($this->journalEntries()->exists()) {
return false;
}
// Cannot delete system accounts
if ($this->system_account) {
return false;
}
// Cannot delete accounts with sub-accounts
if ($this->subAccounts()->exists()) {
return false;
}
return true;
}
public function getAccountPath(): string
{
$path = [$this->account_name];
$parent = $this->parentAccount;
while ($parent) {
array_unshift($path, $parent->account_name);
$parent = $parent->parentAccount;
}
return implode(' > ', $path);
}
// Helper Methods
public static function generateAccountCode(AccountType $accountType): string
{
$prefix = match ($accountType) {
AccountType::ASSET => '1',
AccountType::LIABILITY => '2',
AccountType::EQUITY => '3',
AccountType::REVENUE => '4',
AccountType::EXPENSE => '5',
AccountType::OTHER_INCOME => '6',
AccountType::OTHER_EXPENSE => '7',
};
$lastAccount = static::where('account_code', 'like', $prefix . '%')
->orderBy('account_code', 'desc')
->first();
if ($lastAccount) {
$lastNumber = intval(substr($lastAccount->account_code, 1));
$newNumber = $lastNumber + 10; // Leave gaps for manual accounts
} else {
$newNumber = intval($prefix) * 1000;
}
return (string) $newNumber;
}
// Relationships
public function parentAccount()
{
return $this->belongsTo(ChartOfAccount::class, 'parent_account_id');
}
public function subAccounts()
{
return $this->hasMany(ChartOfAccount::class, 'parent_account_id');
}
public function journalEntries()
{
return $this->hasMany(JournalEntry::class, 'account_id');
}
public function budgets()
{
return $this->hasMany(Budget::class, 'account_id');
}
// Query Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeByType($query, AccountType $type)
{
return $query->where('account_type', $type);
}
public function scopeMainAccounts($query)
{
return $query->whereNull('parent_account_id');
}
public function scopeManualEntryAllowed($query)
{
return $query->where('allow_manual_entries', true);
}
}Transaction Model
Location: app/Modules/Finance/Domain/Models/Transaction.php
class Transaction extends Model
{
use HasFactory, SoftDeletes, Auditable;
protected $table = 'finance_transactions';
protected $fillable = [
'transaction_number', 'transaction_date', 'reference_type', 'reference_id',
'reference_number', 'description', 'total_amount', 'status', 'period_id',
'currency', 'exchange_rate', 'created_by', 'approved_by', 'posted_by'
];
protected $casts = [
'status' => TransactionStatus::class,
'transaction_date' => 'date',
'total_amount' => 'decimal:2',
'exchange_rate' => 'decimal:6',
'approved_at' => 'datetime',
'posted_at' => 'datetime',
];
// Boot method for validation and events
protected static function boot()
{
parent::boot();
static::creating(function ($transaction) {
$transaction->transaction_number = $transaction->transaction_number
?? static::generateTransactionNumber();
$transaction->status = $transaction->status ?? TransactionStatus::DRAFT;
$transaction->currency = $transaction->currency ?? config('app.default_currency', 'USD');
$transaction->exchange_rate = $transaction->exchange_rate ?? 1.0;
});
static::created(function ($transaction) {
event(new TransactionCreated($transaction));
});
static::updating(function ($transaction) {
// Validate balance before saving
if ($transaction->status !== TransactionStatus::DRAFT && !$transaction->isBalanced()) {
throw new UnbalancedTransactionException(
'Transaction must be balanced before posting'
);
}
// Prevent modification of posted transactions
if ($transaction->getOriginal('status') === TransactionStatus::POSTED->value
&& $transaction->isDirty(['transaction_date', 'total_amount'])) {
throw new PostedTransactionModificationException(
'Posted transactions cannot be modified'
);
}
});
}
// Business Methods
public function addJournalEntry(
int $accountId,
float $debitAmount = 0,
float $creditAmount = 0,
string $description = null
): JournalEntry {
if ($this->status === TransactionStatus::POSTED) {
throw new PostedTransactionModificationException(
'Cannot add entries to posted transaction'
);
}
if ($debitAmount < 0 || $creditAmount < 0) {
throw new InvalidJournalEntryException(
'Debit and credit amounts must be positive'
);
}
if ($debitAmount > 0 && $creditAmount > 0) {
throw new InvalidJournalEntryException(
'Entry cannot have both debit and credit amounts'
);
}
if ($debitAmount == 0 && $creditAmount == 0) {
throw new InvalidJournalEntryException(
'Entry must have either debit or credit amount'
);
}
$entry = $this->journalEntries()->create([
'account_id' => $accountId,
'debit_amount' => $debitAmount,
'credit_amount' => $creditAmount,
'description' => $description ?? $this->description,
]);
$this->updateTotalAmount();
return $entry;
}
public function approve(int $approvedBy = null): self
{
if ($this->status !== TransactionStatus::DRAFT) {
throw new InvalidTransactionStateException(
'Only draft transactions can be approved'
);
}
if (!$this->isBalanced()) {
throw new UnbalancedTransactionException(
'Transaction must be balanced before approval'
);
}
$this->status = TransactionStatus::APPROVED;
$this->approved_by = $approvedBy ?? auth()->id();
$this->approved_at = now();
$this->save();
event(new TransactionApproved($this));
return $this;
}
public function post(int $postedBy = null): self
{
if (!in_array($this->status, [TransactionStatus::APPROVED, TransactionStatus::DRAFT])) {
throw new InvalidTransactionStateException(
'Only approved or draft transactions can be posted'
);
}
if (!$this->isBalanced()) {
throw new UnbalancedTransactionException(
'Transaction must be balanced before posting'
);
}
// Check if period is open
if ($this->period && $this->period->status === PeriodStatus::CLOSED) {
throw new ClosedPeriodException(
'Cannot post to closed period'
);
}
DB::transaction(function () use ($postedBy) {
$this->status = TransactionStatus::POSTED;
$this->posted_by = $postedBy ?? auth()->id();
$this->posted_at = now();
$this->save();
// Update account balances
foreach ($this->journalEntries as $entry) {
$this->updateAccountBalance($entry);
}
});
event(new TransactionPosted($this));
return $this;
}
public function reverse(string $reason): Transaction
{
if ($this->status !== TransactionStatus::POSTED) {
throw new InvalidTransactionStateException(
'Only posted transactions can be reversed'
);
}
return DB::transaction(function () use ($reason) {
$reversingTransaction = static::create([
'transaction_date' => now()->toDateString(),
'reference_type' => 'transaction_reversal',
'reference_id' => $this->id,
'reference_number' => $this->transaction_number,
'description' => "Reversal of {$this->transaction_number}: {$reason}",
'total_amount' => $this->total_amount,
'period_id' => Period::current()->id,
]);
// Create reversed journal entries
foreach ($this->journalEntries as $entry) {
$reversingTransaction->addJournalEntry(
$entry->account_id,
$entry->credit_amount, // Swap debit and credit
$entry->debit_amount,
"Reversal: {$entry->description}"
);
}
$reversingTransaction->post();
// Mark original transaction as reversed
$this->status = TransactionStatus::REVERSED;
$this->reversed_at = now();
$this->save();
event(new TransactionReversed($this, $reversingTransaction));
return $reversingTransaction;
});
}
public function isBalanced(): bool
{
$debits = $this->journalEntries()->sum('debit_amount');
$credits = $this->journalEntries()->sum('credit_amount');
return abs($debits - $credits) < 0.01;
}
public function getTotalDebits(): float
{
return $this->journalEntries()->sum('debit_amount');
}
public function getTotalCredits(): float
{
return $this->journalEntries()->sum('credit_amount');
}
// Helper Methods
public static function generateTransactionNumber(): string
{
$prefix = 'TXN';
$year = now()->year;
$sequence = static::whereYear('created_at', $year)->count() + 1;
return sprintf('%s-%d-%08d', $prefix, $year, $sequence);
}
private function updateTotalAmount(): void
{
$this->total_amount = max(
$this->getTotalDebits(),
$this->getTotalCredits()
);
$this->saveQuietly(); // Don't trigger events
}
private function updateAccountBalance(JournalEntry $entry): void
{
// This could update a cached balance table or trigger balance recalculation
event(new AccountBalanceUpdateRequired($entry->account_id, $this->transaction_date));
}
// Relationships
public function journalEntries()
{
return $this->hasMany(JournalEntry::class)->orderBy('id');
}
public function period()
{
return $this->belongsTo(Period::class);
}
public function createdBy()
{
return $this->belongsTo(User::class, 'created_by');
}
public function approvedBy()
{
return $this->belongsTo(User::class, 'approved_by');
}
public function postedBy()
{
return $this->belongsTo(User::class, 'posted_by');
}
// Query Scopes
public function scopePosted($query)
{
return $query->where('status', TransactionStatus::POSTED);
}
public function scopeForPeriod($query, Period $period)
{
return $query->where('period_id', $period->id);
}
public function scopeByReference($query, string $type, int $id)
{
return $query->where('reference_type', $type)
->where('reference_id', $id);
}
}JournalEntry Model
Location: app/Modules/Finance/Domain/Models/JournalEntry.php
class JournalEntry extends Model
{
use HasFactory, Auditable;
protected $table = 'finance_journal_entries';
protected $fillable = [
'transaction_id', 'account_id', 'debit_amount', 'credit_amount',
'description', 'line_number'
];
protected $casts = [
'debit_amount' => 'decimal:2',
'credit_amount' => 'decimal:2',
];
// Boot method for validation
protected static function boot()
{
parent::boot();
static::creating(function ($entry) {
// Auto-assign line number
if (!$entry->line_number) {
$entry->line_number = JournalEntry::where('transaction_id', $entry->transaction_id)
->max('line_number') + 1;
}
});
static::created(function ($entry) {
event(new JournalEntryCreated($entry));
});
}
// Business Methods
public function getAmount(): float
{
return max($this->debit_amount, $this->credit_amount);
}
public function isDebit(): bool
{
return $this->debit_amount > 0;
}
public function isCredit(): bool
{
return $this->credit_amount > 0;
}
public function getFormattedAmount(): string
{
return number_format($this->getAmount(), 2);
}
// Relationships
public function transaction()
{
return $this->belongsTo(Transaction::class);
}
public function account()
{
return $this->belongsTo(ChartOfAccount::class, 'account_id');
}
// Query Scopes
public function scopeDebits($query)
{
return $query->where('debit_amount', '>', 0);
}
public function scopeCredits($query)
{
return $query->where('credit_amount', '>', 0);
}
public function scopeForAccount($query, int $accountId)
{
return $query->where('account_id', $accountId);
}
}Domain Services
DoubleEntryBookkeepingService
Location: app/Modules/Finance/Domain/Services/DoubleEntryBookkeepingService.php
class DoubleEntryBookkeepingService
{
public function __construct(
private ChartOfAccountRepository $accountRepository,
private TransactionRepository $transactionRepository
) {}
public function createTransaction(CreateTransactionCommand $command): Transaction
{
return DB::transaction(function () use ($command) {
// Create transaction
$transaction = Transaction::create([
'transaction_date' => $command->transactionDate,
'reference_type' => $command->referenceType,
'reference_id' => $command->referenceId,
'reference_number' => $command->referenceNumber,
'description' => $command->description,
'period_id' => $this->getCurrentPeriod()->id,
]);
// Add journal entries
foreach ($command->entries as $entryData) {
$account = $this->accountRepository->findOrFail($entryData['account_id']);
if (!$account->is_active) {
throw new InactiveAccountException(
"Cannot post to inactive account: {$account->account_name}"
);
}
$transaction->addJournalEntry(
$entryData['account_id'],
$entryData['debit_amount'] ?? 0,
$entryData['credit_amount'] ?? 0,
$entryData['description'] ?? null
);
}
// Validate and post if requested
if ($command->autoPost && $transaction->isBalanced()) {
$transaction->post();
}
return $transaction;
});
}
public function createSalesTransaction(
float $amount,
int $customerId,
string $referenceNumber,
\DateTimeInterface $date = null
): Transaction {
$date = $date ?? now();
// Get required accounts
$accountsReceivable = $this->getAccountByCode('1200'); // AR
$salesRevenue = $this->getAccountByCode('4100'); // Revenue
return $this->createTransaction(new CreateTransactionCommand(
transactionDate: $date,
referenceType: 'sales_invoice',
referenceId: null,
referenceNumber: $referenceNumber,
description: "Sales to customer #{$customerId}",
entries: [
[
'account_id' => $accountsReceivable->id,
'debit_amount' => $amount,
'description' => 'Accounts Receivable'
],
[
'account_id' => $salesRevenue->id,
'credit_amount' => $amount,
'description' => 'Sales Revenue'
]
],
autoPost: true
));
}
public function createPaymentTransaction(
float $amount,
int $customerId,
string $referenceNumber,
string $paymentMethod = 'cash'
): Transaction {
// Get required accounts
$bankAccount = $this->getBankAccount($paymentMethod);
$accountsReceivable = $this->getAccountByCode('1200'); // AR
return $this->createTransaction(new CreateTransactionCommand(
transactionDate: now(),
referenceType: 'customer_payment',
referenceId: $customerId,
referenceNumber: $referenceNumber,
description: "Payment from customer #{$customerId}",
entries: [
[
'account_id' => $bankAccount->id,
'debit_amount' => $amount,
'description' => 'Cash/Bank'
],
[
'account_id' => $accountsReceivable->id,
'credit_amount' => $amount,
'description' => 'Accounts Receivable'
]
],
autoPost: true
));
}
public function createExpenseTransaction(
float $amount,
string $expenseAccountCode,
string $description,
string $paymentMethod = 'cash'
): Transaction {
$expenseAccount = $this->getAccountByCode($expenseAccountCode);
$bankAccount = $this->getBankAccount($paymentMethod);
return $this->createTransaction(new CreateTransactionCommand(
transactionDate: now(),
referenceType: 'expense',
referenceId: null,
referenceNumber: null,
description: $description,
entries: [
[
'account_id' => $expenseAccount->id,
'debit_amount' => $amount,
'description' => $description
],
[
'account_id' => $bankAccount->id,
'credit_amount' => $amount,
'description' => 'Cash/Bank Payment'
]
],
autoPost: true
));
}
public function createInventoryTransaction(
float $costAmount,
int $productId,
string $type = 'purchase'
): Transaction {
$inventoryAccount = $this->getAccountByCode('1300'); // Inventory
if ($type === 'purchase') {
$accountsPayable = $this->getAccountByCode('2100'); // AP
return $this->createTransaction(new CreateTransactionCommand(
transactionDate: now(),
referenceType: 'inventory_purchase',
referenceId: $productId,
referenceNumber: null,
description: "Inventory purchase - Product #{$productId}",
entries: [
[
'account_id' => $inventoryAccount->id,
'debit_amount' => $costAmount,
'description' => 'Inventory'
],
[
'account_id' => $accountsPayable->id,
'credit_amount' => $costAmount,
'description' => 'Accounts Payable'
]
],
autoPost: true
));
} else { // sale/cost of goods sold
$cogsAccount = $this->getAccountByCode('5100'); // COGS
return $this->createTransaction(new CreateTransactionCommand(
transactionDate: now(),
referenceType: 'cost_of_goods_sold',
referenceId: $productId,
referenceNumber: null,
description: "COGS - Product #{$productId}",
entries: [
[
'account_id' => $cogsAccount->id,
'debit_amount' => $costAmount,
'description' => 'Cost of Goods Sold'
],
[
'account_id' => $inventoryAccount->id,
'credit_amount' => $costAmount,
'description' => 'Inventory'
]
],
autoPost: true
));
}
}
private function getAccountByCode(string $code): ChartOfAccount
{
$account = $this->accountRepository->findByCode($code);
if (!$account) {
throw new AccountNotFoundException("Account with code {$code} not found");
}
return $account;
}
private function getBankAccount(string $paymentMethod): ChartOfAccount
{
$accountCode = match ($paymentMethod) {
'cash' => '1100',
'check', 'bank_transfer' => '1110',
'credit_card' => '1115',
default => '1100'
};
return $this->getAccountByCode($accountCode);
}
private function getCurrentPeriod(): Period
{
return Period::current() ?? Period::createCurrent();
}
}FinancialReportingService
Location: app/Modules/Finance/Domain/Services/FinancialReportingService.php
class FinancialReportingService
{
public function __construct(
private ChartOfAccountRepository $accountRepository,
private TransactionRepository $transactionRepository
) {}
public function generateBalanceSheet(\DateTimeInterface $asOfDate = null): BalanceSheetReport
{
$asOfDate = $asOfDate ?? now();
$assets = $this->getAccountBalancesByType(AccountType::ASSET, $asOfDate);
$liabilities = $this->getAccountBalancesByType(AccountType::LIABILITY, $asOfDate);
$equity = $this->getAccountBalancesByType(AccountType::EQUITY, $asOfDate);
$totalAssets = array_sum(array_column($assets, 'balance'));
$totalLiabilities = array_sum(array_column($liabilities, 'balance'));
$totalEquity = array_sum(array_column($equity, 'balance'));
// Calculate retained earnings
$retainedEarnings = $this->calculateRetainedEarnings($asOfDate);
$equity[] = [
'account_code' => 'RE',
'account_name' => 'Retained Earnings',
'balance' => $retainedEarnings
];
$totalEquity += $retainedEarnings;
return new BalanceSheetReport(
asOfDate: $asOfDate,
assets: $assets,
liabilities: $liabilities,
equity: $equity,
totalAssets: $totalAssets,
totalLiabilities: $totalLiabilities,
totalEquity: $totalEquity,
isBalanced: abs($totalAssets - ($totalLiabilities + $totalEquity)) < 0.01
);
}
public function generateIncomeStatement(
\DateTimeInterface $startDate,
\DateTimeInterface $endDate
): IncomeStatementReport {
$revenue = $this->getAccountBalancesByTypeForPeriod(
AccountType::REVENUE,
$startDate,
$endDate
);
$expenses = $this->getAccountBalancesByTypeForPeriod(
AccountType::EXPENSE,
$startDate,
$endDate
);
$otherIncome = $this->getAccountBalancesByTypeForPeriod(
AccountType::OTHER_INCOME,
$startDate,
$endDate
);
$otherExpenses = $this->getAccountBalancesByTypeForPeriod(
AccountType::OTHER_EXPENSE,
$startDate,
$endDate
);
$totalRevenue = array_sum(array_column($revenue, 'balance'));
$totalExpenses = array_sum(array_column($expenses, 'balance'));
$totalOtherIncome = array_sum(array_column($otherIncome, 'balance'));
$totalOtherExpenses = array_sum(array_column($otherExpenses, 'balance'));
$grossProfit = $totalRevenue - $this->getCostOfGoodsSold($startDate, $endDate);
$operatingIncome = $grossProfit - $totalExpenses;
$netIncome = $operatingIncome + $totalOtherIncome - $totalOtherExpenses;
return new IncomeStatementReport(
startDate: $startDate,
endDate: $endDate,
revenue: $revenue,
expenses: $expenses,
otherIncome: $otherIncome,
otherExpenses: $otherExpenses,
totalRevenue: $totalRevenue,
costOfGoodsSold: $this->getCostOfGoodsSold($startDate, $endDate),
grossProfit: $grossProfit,
totalExpenses: $totalExpenses,
operatingIncome: $operatingIncome,
totalOtherIncome: $totalOtherIncome,
totalOtherExpenses: $totalOtherExpenses,
netIncome: $netIncome
);
}
public function generateTrialBalance(\DateTimeInterface $asOfDate = null): TrialBalanceReport
{
$asOfDate = $asOfDate ?? now();
$accounts = $this->accountRepository->findActive();
$balances = [];
$totalDebits = 0;
$totalCredits = 0;
foreach ($accounts as $account) {
$balance = $account->getBalance($asOfDate);
if ($balance != 0) {
$trialBalance = $account->getTrialBalance($asOfDate);
$balances[] = $trialBalance;
$totalDebits += $trialBalance['debit_balance'];
$totalCredits += $trialBalance['credit_balance'];
}
}
// Sort by account code
usort($balances, fn($a, $b) => strcmp($a['account_code'], $b['account_code']));
return new TrialBalanceReport(
asOfDate: $asOfDate,
accounts: $balances,
totalDebits: $totalDebits,
totalCredits: $totalCredits,
isBalanced: abs($totalDebits - $totalCredits) < 0.01
);
}
public function generateCashFlowStatement(
\DateTimeInterface $startDate,
\DateTimeInterface $endDate
): CashFlowReport {
$operatingActivities = $this->getCashFlowFromOperations($startDate, $endDate);
$investingActivities = $this->getCashFlowFromInvesting($startDate, $endDate);
$financingActivities = $this->getCashFlowFromFinancing($startDate, $endDate);
$netCashFlow = $operatingActivities + $investingActivities + $financingActivities;
$beginningCash = $this->getCashBalance($startDate->modify('-1 day'));
$endingCash = $beginningCash + $netCashFlow;
return new CashFlowReport(
startDate: $startDate,
endDate: $endDate,
operatingActivities: $operatingActivities,
investingActivities: $investingActivities,
financingActivities: $financingActivities,
netCashFlow: $netCashFlow,
beginningCash: $beginningCash,
endingCash: $endingCash
);
}
private function getAccountBalancesByType(AccountType $type, \DateTimeInterface $asOfDate): array
{
$accounts = $this->accountRepository->findByType($type);
$balances = [];
foreach ($accounts as $account) {
$balance = $account->getBalance($asOfDate);
if ($balance != 0) {
$balances[] = [
'account_code' => $account->account_code,
'account_name' => $account->account_name,
'balance' => $balance
];
}
}
return $balances;
}
private function getAccountBalancesByTypeForPeriod(
AccountType $type,
\DateTimeInterface $startDate,
\DateTimeInterface $endDate
): array {
$accounts = $this->accountRepository->findByType($type);
$balances = [];
foreach ($accounts as $account) {
$balance = $this->getAccountBalanceForPeriod($account, $startDate, $endDate);
if ($balance != 0) {
$balances[] = [
'account_code' => $account->account_code,
'account_name' => $account->account_name,
'balance' => $balance
];
}
}
return $balances;
}
private function getAccountBalanceForPeriod(
ChartOfAccount $account,
\DateTimeInterface $startDate,
\DateTimeInterface $endDate
): float {
return $account->journalEntries()
->whereHas('transaction', function ($query) use ($startDate, $endDate) {
$query->whereBetween('transaction_date', [$startDate, $endDate])
->where('status', TransactionStatus::POSTED);
})
->selectRaw('
SUM(CASE WHEN ? = ? THEN debit_amount - credit_amount
ELSE credit_amount - debit_amount END) as balance
', [$account->normal_balance->value, BalanceType::DEBIT->value])
->value('balance') ?? 0;
}
private function calculateRetainedEarnings(\DateTimeInterface $asOfDate): float
{
// Calculate cumulative net income up to the date
$currentYearStart = $asOfDate->format('Y') . '-01-01';
// Get net income for current year
$currentYearIncome = $this->getNetIncomeForPeriod(
new \DateTime($currentYearStart),
$asOfDate
);
// Get retained earnings from previous periods (this would be more complex in real implementation)
$priorRetainedEarnings = 0; // Simplified for example
return $priorRetainedEarnings + $currentYearIncome;
}
private function getNetIncomeForPeriod(
\DateTimeInterface $startDate,
\DateTimeInterface $endDate
): float {
$revenue = $this->getTotalByAccountType(AccountType::REVENUE, $startDate, $endDate);
$expenses = $this->getTotalByAccountType(AccountType::EXPENSE, $startDate, $endDate);
$otherIncome = $this->getTotalByAccountType(AccountType::OTHER_INCOME, $startDate, $endDate);
$otherExpenses = $this->getTotalByAccountType(AccountType::OTHER_EXPENSE, $startDate, $endDate);
return $revenue + $otherIncome - $expenses - $otherExpenses;
}
private function getTotalByAccountType(
AccountType $type,
\DateTimeInterface $startDate,
\DateTimeInterface $endDate
): float {
$accounts = $this->getAccountBalancesByTypeForPeriod($type, $startDate, $endDate);
return array_sum(array_column($accounts, 'balance'));
}
}Application Layer Implementation
Application Services
FinanceService
Location: app/Modules/Finance/Application/Services/FinanceService.php
class FinanceService
{
public function __construct(
private DoubleEntryBookkeepingService $bookkeepingService,
private FinancialReportingService $reportingService,
private ChartOfAccountRepository $accountRepository,
private TransactionRepository $transactionRepository
) {}
public function setupChartOfAccounts(SetupChartOfAccountsDTO $dto): array
{
return DB::transaction(function () use ($dto) {
$createdAccounts = [];
foreach ($dto->accounts as $accountData) {
$account = ChartOfAccount::create([
'account_code' => $accountData['code'],
'account_name' => $accountData['name'],
'account_type' => AccountType::from($accountData['type']),
'normal_balance' => BalanceType::from($accountData['normal_balance']),
'parent_account_id' => $accountData['parent_id'] ?? null,
'description' => $accountData['description'] ?? null,
'is_active' => true,
'allow_manual_entries' => $accountData['allow_manual_entries'] ?? true,
]);
$createdAccounts[] = $account;
}
Log::info('Chart of accounts setup completed', [
'accounts_created' => count($createdAccounts)
]);
return $createdAccounts;
});
}
public function recordSalesTransaction(RecordSalesTransactionDTO $dto): Transaction
{
return $this->bookkeepingService->createSalesTransaction(
$dto->amount,
$dto->customerId,
$dto->referenceNumber,
$dto->transactionDate
);
}
public function recordPaymentTransaction(RecordPaymentTransactionDTO $dto): Transaction
{
return $this->bookkeepingService->createPaymentTransaction(
$dto->amount,
$dto->customerId,
$dto->referenceNumber,
$dto->paymentMethod
);
}
public function recordExpenseTransaction(RecordExpenseTransactionDTO $dto): Transaction
{
return $this->bookkeepingService->createExpenseTransaction(
$dto->amount,
$dto->expenseAccountCode,
$dto->description,
$dto->paymentMethod
);
}
public function createManualJournalEntry(CreateManualJournalEntryDTO $dto): Transaction
{
return DB::transaction(function () use ($dto) {
$transaction = Transaction::create([
'transaction_date' => $dto->transactionDate,
'reference_type' => 'manual_journal_entry',
'description' => $dto->description,
]);
foreach ($dto->entries as $entryData) {
$account = $this->accountRepository->findOrFail($entryData['account_id']);
if (!$account->allow_manual_entries) {
throw new ManualEntryNotAllowedException(
"Manual entries not allowed for account: {$account->account_name}"
);
}
$transaction->addJournalEntry(
$entryData['account_id'],
$entryData['debit_amount'] ?? 0,
$entryData['credit_amount'] ?? 0,
$entryData['description'] ?? null
);
}
if ($dto->autoPost && $transaction->isBalanced()) {
$transaction->post();
}
return $transaction;
});
}
public function getFinancialReports(GenerateFinancialReportsDTO $dto): FinancialReportsBundle
{
$balanceSheet = null;
$incomeStatement = null;
$trialBalance = null;
$cashFlow = null;
if (in_array('balance_sheet', $dto->reportTypes)) {
$balanceSheet = $this->reportingService->generateBalanceSheet($dto->asOfDate);
}
if (in_array('income_statement', $dto->reportTypes)) {
$incomeStatement = $this->reportingService->generateIncomeStatement(
$dto->startDate,
$dto->endDate
);
}
if (in_array('trial_balance', $dto->reportTypes)) {
$trialBalance = $this->reportingService->generateTrialBalance($dto->asOfDate);
}
if (in_array('cash_flow', $dto->reportTypes)) {
$cashFlow = $this->reportingService->generateCashFlowStatement(
$dto->startDate,
$dto->endDate
);
}
return new FinancialReportsBundle(
balanceSheet: $balanceSheet,
incomeStatement: $incomeStatement,
trialBalance: $trialBalance,
cashFlow: $cashFlow,
generatedAt: now()
);
}
public function getAccountBalances(array $accountIds = null, \DateTimeInterface $asOfDate = null): array
{
$query = $this->accountRepository->query();
if ($accountIds) {
$query->whereIn('id', $accountIds);
}
$accounts = $query->get();
$balances = [];
foreach ($accounts as $account) {
$balances[] = [
'account_id' => $account->id,
'account_code' => $account->account_code,
'account_name' => $account->account_name,
'account_type' => $account->account_type,
'balance' => $account->getBalance($asOfDate),
'as_of_date' => ($asOfDate ?? now())->toDateString(),
];
}
return $balances;
}
public function closeAccountingPeriod(int $periodId): Period
{
$period = Period::findOrFail($periodId);
if ($period->status === PeriodStatus::CLOSED) {
throw new PeriodAlreadyClosedException(
"Period {$period->name} is already closed"
);
}
return DB::transaction(function () use ($period) {
// Validate all transactions are posted
$unpostedCount = Transaction::where('period_id', $period->id)
->where('status', '!=', TransactionStatus::POSTED)
->count();
if ($unpostedCount > 0) {
throw new UnpostedTransactionsException(
"Cannot close period with {$unpostedCount} unposted transactions"
);
}
// Create closing entries for income and expense accounts
$this->createClosingEntries($period);
// Mark period as closed
$period->status = PeriodStatus::CLOSED;
$period->closed_at = now();
$period->save();
event(new PeriodClosed($period));
Log::info('Accounting period closed', [
'period_id' => $period->id,
'period_name' => $period->name,
]);
return $period;
});
}
private function createClosingEntries(Period $period): void
{
// Get income and expense account balances for the period
$incomeAccounts = $this->accountRepository->findByType(AccountType::REVENUE);
$expenseAccounts = $this->accountRepository->findByType(AccountType::EXPENSE);
$totalIncome = 0;
$totalExpenses = 0;
$closingTransaction = Transaction::create([
'transaction_date' => $period->end_date,
'reference_type' => 'period_closing',
'description' => "Period closing entries for {$period->name}",
'period_id' => $period->id,
]);
// Close income accounts (credit balance accounts - debit to close)
foreach ($incomeAccounts as $account) {
$balance = $account->getBalanceForPeriod($period);
if ($balance > 0) {
$closingTransaction->addJournalEntry(
$account->id,
$balance, // Debit to zero out credit balance
0,
"Close {$account->account_name}"
);
$totalIncome += $balance;
}
}
// Close expense accounts (debit balance accounts - credit to close)
foreach ($expenseAccounts as $account) {
$balance = $account->getBalanceForPeriod($period);
if ($balance > 0) {
$closingTransaction->addJournalEntry(
$account->id,
0,
$balance, // Credit to zero out debit balance
"Close {$account->account_name}"
);
$totalExpenses += $balance;
}
}
// Transfer net income to retained earnings
$retainedEarningsAccount = $this->accountRepository->findByCode('3200');
$netIncome = $totalIncome - $totalExpenses;
if ($netIncome != 0) {
$closingTransaction->addJournalEntry(
$retainedEarningsAccount->id,
$netIncome > 0 ? 0 : abs($netIncome), // Debit if loss
$netIncome > 0 ? $netIncome : 0, // Credit if profit
"Net income transfer"
);
}
$closingTransaction->post();
}
}Event-Driven Integration
Cross-Module Event Listeners
CreateSalesJournalEntry
Location: app/Modules/Finance/Infrastructure/Listeners/CreateSalesJournalEntry.php
class CreateSalesJournalEntry
{
public function __construct(
private DoubleEntryBookkeepingService $bookkeepingService
) {}
public function handle(InvoiceGenerated $event): void
{
try {
$this->bookkeepingService->createSalesTransaction(
$event->invoice->total_amount,
$event->invoice->customer_id,
$event->invoice->invoice_number,
$event->invoice->invoice_date
);
Log::info('Sales journal entry created', [
'invoice_id' => $event->invoice->id,
'amount' => $event->invoice->total_amount,
]);
} catch (\Exception $e) {
Log::error('Failed to create sales journal entry', [
'invoice_id' => $event->invoice->id,
'error' => $e->getMessage(),
]);
// Could trigger a manual review process
event(new FinancialEntryFailed(
'sales_transaction',
$event->invoice->id,
$e->getMessage()
));
}
}
}CreatePaymentJournalEntry
Location: app/Modules/Finance/Infrastructure/Listeners/CreatePaymentJournalEntry.php
class CreatePaymentJournalEntry
{
public function __construct(
private DoubleEntryBookkeepingService $bookkeepingService
) {}
public function handle(PaymentReceived $event): void
{
try {
$this->bookkeepingService->createPaymentTransaction(
$event->payment->amount,
$event->payment->invoice->customer_id,
$event->payment->reference_number,
$event->payment->method
);
Log::info('Payment journal entry created', [
'payment_id' => $event->payment->id,
'amount' => $event->payment->amount,
'method' => $event->payment->method,
]);
} catch (\Exception $e) {
Log::error('Failed to create payment journal entry', [
'payment_id' => $event->payment->id,
'error' => $e->getMessage(),
]);
}
}
}CreateInventoryJournalEntry
Location: app/Modules/Finance/Infrastructure/Listeners/CreateInventoryJournalEntry.php
class CreateInventoryJournalEntry
{
public function __construct(
private DoubleEntryBookkeepingService $bookkeepingService
) {}
public function handle(PurchaseOrderReceived $event): void
{
foreach ($event->purchaseOrder->items as $item) {
$totalCost = $item->quantity_received * $item->cost_per_unit;
if ($totalCost > 0) {
try {
$this->bookkeepingService->createInventoryTransaction(
$totalCost,
$item->product_id,
'purchase'
);
} catch (\Exception $e) {
Log::error('Failed to create inventory journal entry', [
'purchase_order_id' => $event->purchaseOrder->id,
'product_id' => $item->product_id,
'error' => $e->getMessage(),
]);
}
}
}
}
public function handleCostOfGoodsSold(CostOfGoodsSoldCalculated $event): void
{
try {
$this->bookkeepingService->createInventoryTransaction(
$event->totalCost,
null, // Multiple products
'sale'
);
Log::info('COGS journal entry created', [
'order_id' => $event->order->id,
'cogs_amount' => $event->totalCost,
]);
} catch (\Exception $e) {
Log::error('Failed to create COGS journal entry', [
'order_id' => $event->order->id,
'error' => $e->getMessage(),
]);
}
}
}Database Schema & Relationships
Core Tables Schema
-- Chart of Accounts
CREATE TABLE finance_chart_of_accounts (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
-- Account Identification
account_code VARCHAR(20) UNIQUE NOT NULL,
account_name VARCHAR(255) NOT NULL,
-- Account Classification
account_type ENUM('asset', 'liability', 'equity', 'revenue', 'expense', 'other_income', 'other_expense') NOT NULL,
account_category VARCHAR(100) NULL,
normal_balance ENUM('debit', 'credit') NOT NULL,
-- Account Hierarchy
parent_account_id BIGINT UNSIGNED NULL,
-- Account Properties
is_active BOOLEAN DEFAULT TRUE,
tax_account BOOLEAN DEFAULT FALSE,
system_account BOOLEAN DEFAULT FALSE,
allow_manual_entries BOOLEAN DEFAULT TRUE,
-- Additional Information
description TEXT NULL,
-- Audit Fields
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
-- Indexes
INDEX idx_accounts_code (account_code),
INDEX idx_accounts_type (account_type),
INDEX idx_accounts_parent (parent_account_id),
INDEX idx_accounts_active (is_active),
-- Foreign Keys
FOREIGN KEY (parent_account_id) REFERENCES finance_chart_of_accounts(id)
);
-- Transactions
CREATE TABLE finance_transactions (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
-- Transaction Identification
transaction_number VARCHAR(50) UNIQUE NOT NULL,
transaction_date DATE NOT NULL,
-- Reference Information
reference_type VARCHAR(50) NULL,
reference_id BIGINT UNSIGNED NULL,
reference_number VARCHAR(100) NULL,
-- Transaction Details
description TEXT NOT NULL,
total_amount DECIMAL(15,2) NOT NULL,
currency CHAR(3) DEFAULT 'USD',
exchange_rate DECIMAL(10,6) DEFAULT 1.000000,
-- Status and Workflow
status ENUM('draft', 'approved', 'posted', 'reversed') DEFAULT 'draft',
period_id BIGINT UNSIGNED NULL,
-- User Tracking
created_by BIGINT UNSIGNED NULL,
approved_by BIGINT UNSIGNED NULL,
posted_by BIGINT UNSIGNED NULL,
-- Timestamps
approved_at TIMESTAMP NULL,
posted_at TIMESTAMP NULL,
reversed_at TIMESTAMP NULL,
-- Audit Fields
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
-- Indexes
INDEX idx_transactions_number (transaction_number),
INDEX idx_transactions_date_status (transaction_date, status),
INDEX idx_transactions_reference (reference_type, reference_id),
INDEX idx_transactions_period (period_id),
INDEX idx_transactions_created_by (created_by),
-- Foreign Keys
FOREIGN KEY (period_id) REFERENCES finance_periods(id),
FOREIGN KEY (created_by) REFERENCES users(id),
FOREIGN KEY (approved_by) REFERENCES users(id),
FOREIGN KEY (posted_by) REFERENCES users(id)
);
-- Journal Entries
CREATE TABLE finance_journal_entries (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
-- Entry Identification
transaction_id BIGINT UNSIGNED NOT NULL,
account_id BIGINT UNSIGNED NOT NULL,
line_number INT NOT NULL,
-- Entry Amounts
debit_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00,
credit_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00,
-- Entry Description
description TEXT NULL,
-- Audit Fields
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Constraints
CONSTRAINT chk_journal_entry_amounts CHECK (
(debit_amount > 0 AND credit_amount = 0) OR
(debit_amount = 0 AND credit_amount > 0)
),
-- Indexes
INDEX idx_journal_entries_transaction (transaction_id),
INDEX idx_journal_entries_account (account_id),
INDEX idx_journal_entries_account_date (account_id, created_at),
-- Foreign Keys
FOREIGN KEY (transaction_id) REFERENCES finance_transactions(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES finance_chart_of_accounts(id)
);
-- Accounting Periods
CREATE TABLE finance_periods (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
-- Period Identification
name VARCHAR(100) NOT NULL,
fiscal_year INT NOT NULL,
period_number INT NOT NULL,
-- Period Dates
start_date DATE NOT NULL,
end_date DATE NOT NULL,
-- Period Status
status ENUM('open', 'closed') DEFAULT 'open',
closed_at TIMESTAMP NULL,
-- Audit Fields
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Unique Constraints
UNIQUE KEY uk_periods_year_number (fiscal_year, period_number),
-- Indexes
INDEX idx_periods_dates (start_date, end_date),
INDEX idx_periods_status (status),
INDEX idx_periods_fiscal_year (fiscal_year)
);Performance Considerations
Database Optimization
-- Partitioning for large transaction tables
CREATE TABLE finance_transactions_2024 LIKE finance_transactions;
ALTER TABLE finance_transactions_2024
ADD CONSTRAINT chk_year_2024 CHECK (YEAR(transaction_date) = 2024);
-- Materialized view for account balances
CREATE VIEW account_balances_current AS
SELECT
a.id,
a.account_code,
a.account_name,
a.account_type,
COALESCE(
SUM(CASE
WHEN a.normal_balance = 'debit' THEN je.debit_amount - je.credit_amount
ELSE je.credit_amount - je.debit_amount
END), 0
) as current_balance
FROM finance_chart_of_accounts a
LEFT JOIN finance_journal_entries je ON a.id = je.account_id
LEFT JOIN finance_transactions t ON je.transaction_id = t.id
WHERE t.status = 'posted' OR t.status IS NULL
GROUP BY a.id, a.account_code, a.account_name, a.account_type;Caching Strategy
// Cache frequently accessed financial data
public function getCachedAccountBalance(int $accountId, \DateTimeInterface $date = null): float
{
$cacheKey = "account_balance_{$accountId}_" . ($date ? $date->format('Y-m-d') : 'current');
return Cache::remember($cacheKey, 3600, function () use ($accountId, $date) {
$account = ChartOfAccount::findOrFail($accountId);
return $account->getBalance($date);
});
}
// Invalidate cache when transactions are posted
protected static function boot()
{
parent::boot();
static::updated(function ($transaction) {
if ($transaction->status === TransactionStatus::POSTED) {
foreach ($transaction->journalEntries as $entry) {
Cache::forget("account_balance_{$entry->account_id}_current");
}
}
});
}Testing Strategy
Domain Model Tests
class TransactionTest extends TestCase
{
/** @test */
public function it_validates_balanced_transactions(): void
{
$transaction = Transaction::factory()->create();
$cashAccount = ChartOfAccount::factory()->create(['account_type' => AccountType::ASSET]);
$revenueAccount = ChartOfAccount::factory()->create(['account_type' => AccountType::REVENUE]);
$transaction->addJournalEntry($cashAccount->id, 100.00, 0);
$transaction->addJournalEntry($revenueAccount->id, 0, 100.00);
$this->assertTrue($transaction->isBalanced());
$this->assertEquals(100.00, $transaction->getTotalDebits());
$this->assertEquals(100.00, $transaction->getTotalCredits());
}
/** @test */
public function it_prevents_posting_unbalanced_transactions(): void
{
$transaction = Transaction::factory()->create();
$account = ChartOfAccount::factory()->create();
$transaction->addJournalEntry($account->id, 100.00, 0);
// Missing balancing entry
$this->expectException(UnbalancedTransactionException::class);
$transaction->post();
}
}Integration Tests
class FinanceIntegrationTest extends TestCase
{
/** @test */
public function it_creates_financial_entries_from_sales_events(): void
{
Event::fake();
// Create sales invoice
$invoice = Invoice::factory()->create(['total_amount' => 500.00]);
// Fire event
event(new InvoiceGenerated($invoice));
// Assert financial transaction was created
$this->assertDatabaseHas('finance_transactions', [
'reference_type' => 'sales_invoice',
'total_amount' => 500.00,
'status' => 'posted'
]);
// Assert journal entries are balanced
$transaction = Transaction::where('reference_type', 'sales_invoice')->first();
$this->assertTrue($transaction->isBalanced());
}
}Next Steps:
- Review HRM Module Implementation for workforce management integration
- Check Analytics Module Implementation for financial reporting and KPIs
- See API Documentation for endpoint specifications