Skip to content

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

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
php
// 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

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

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

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

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

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

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

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

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

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

sql
-- 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

sql
-- 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

php
// 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

php
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

php
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:

Documentation for SynthesQ CRM/ERP Platform