Skip to content

Finance Module Implementation Guide

Version: 2.0 (Gold Standard Aligned) Last Updated: 2025-01-19 Reference: Operations Module Status: 📋 Implementation Guide


Overview

The Finance module provides comprehensive financial management with double-entry bookkeeping. ALL monetary values MUST use Money VO for accuracy and compliance.

Gold Standard Reference: Follow the Operations module patterns exactly.


Core Entities & Monetary Fields (ALL use Money VO) ✅

Transaction (Double-Entry)

  • debit_amount - Debit side (Money VO)
  • credit_amount - Credit side (Money VO)
  • Must balance: Sum of debits = Sum of credits

JournalEntry

  • debit_amount - Entry debit (Money VO)
  • credit_amount - Entry credit (Money VO)

ChartOfAccount

  • current_balance - Account balance (Money VO)

Module Structure

app/Modules/Finance/
├── Domain/
│   ├── Models/
│   │   ├── Transaction.php ✅ Money VO + Balance validation
│   │   ├── JournalEntry.php ✅ Money VO
│   │   ├── ChartOfAccount.php ✅ Money VO
│   │   └── Period.php
│   ├── Enums/
│   │   ├── TransactionStatus.php
│   │   ├── AccountType.php
│   │   └── BalanceType.php
│   ├── Events/
│   │   ├── TransactionPosted.php ✅ Money VO in event
│   │   ├── PeriodClosed.php
│   │   └── AccountBalanceUpdated.php
│   ├── Services/
│   │   ├── DoubleEntryService.php ✅ Balance validation
│   │   └── FinancialStatementService.php
│   ├── Exceptions/
│   │   └── UnbalancedTransactionException.php ✅ Critical
│   └── Contracts/
│       ├── TransactionRepositoryInterface.php
│       └── ChartOfAccountRepositoryInterface.php

├── Application/
│   ├── Commands/
│   │   ├── CreateTransactionCommand.php
│   │   ├── CreateTransactionCommandHandler.php
│   │   ├── PostTransactionCommand.php ✅ Balance check
│   │   ├── PostTransactionCommandHandler.php
│   │   ├── ReverseTransactionCommand.php
│   │   └── ReverseTransactionCommandHandler.php
│   ├── Queries/
│   │   ├── GetTransactionsQuery.php
│   │   ├── GetTrialBalanceQuery.php
│   │   └── GetAccountBalanceQuery.php
│   ├── DTOs/
│   │   ├── Transaction/
│   │   │   ├── CreateTransactionDTO.php
│   │   │   └── JournalEntryDTO.php ✅ Money conversion
│   │   └── ChartOfAccount/
│   │       └── CreateChartOfAccountDTO.php
│   └── Services/
│       └── TransactionService.php

└── Presentation/
    ├── Controllers/
    │   ├── Transactions/
    │   │   ├── TransactionController.php
    │   │   └── TransactionPostController.php
    │   └── ChartOfAccounts/
    │       └── ChartOfAccountController.php
    └── Resources/
        ├── TransactionResource.php ✅ Money to float
        └── JournalEntryResource.php ✅ Money to float

Step 1: Domain Layer

1.1 Create Enums

AccountType.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Domain\Enums;

enum AccountType: string
{
    case ASSET = 'asset';
    case LIABILITY = 'liability';
    case EQUITY = 'equity';
    case REVENUE = 'revenue';
    case EXPENSE = 'expense';
}

BalanceType.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Domain\Enums;

enum BalanceType: string
{
    case DEBIT = 'debit';
    case CREDIT = 'credit';
}

TransactionStatus.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Domain\Enums;

enum TransactionStatus: string
{
    case DRAFT = 'draft';
    case APPROVED = 'approved';
    case POSTED = 'posted';
    case VOIDED = 'voided';
}

1.2 Create Exception

UnbalancedTransactionException.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Domain\Exceptions;

final class UnbalancedTransactionException extends \DomainException
{
    public static function create(float $debits, float $credits): self
    {
        return new self(
            "Transaction must balance: Debits ({$debits}) must equal Credits ({$credits})"
        );
    }
}

1.3 Create Domain Models

Transaction.php (with Balance Validation)

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Domain\Models;

use App\Shared\Domain\ValueObjects\Money;
use App\Modules\Finance\Domain\Enums\TransactionStatus;
use App\Modules\Finance\Domain\Events\TransactionPosted;
use App\Modules\Finance\Domain\Exceptions\UnbalancedTransactionException;
use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;
use Illuminate\Database\Eloquent\Model;

/**
 * @property int $id
 * @property string $transactionNumber
 * @property TransactionStatus $status
 * @property \DateTimeInterface $transactionDate
 */
final class Transaction extends Model
{
    use HasFactory, UsesTenantConnection, Auditable;

    protected $table = 'finance_transactions';

    protected $fillable = [
        'transaction_number',
        'transaction_date',
        'description',
        'status',
        'reference_type',
        'reference_id',
    ];

    protected function casts(): array
    {
        return [
            'status' => TransactionStatus::class,
            'transaction_date' => 'date',
            'posted_at' => 'datetime',
        ];
    }

    // ✅ CRITICAL: Validate balance before saving
    protected static function boot()
    {
        parent::boot();

        static::saving(function ($transaction) {
            // Only check balance for non-draft transactions
            if ($transaction->status !== TransactionStatus::DRAFT && !$transaction->isBalanced()) {
                $debits = $transaction->getTotalDebits()->toFloat();
                $credits = $transaction->getTotalCredits()->toFloat();

                throw UnbalancedTransactionException::create($debits, $credits);
            }
        });
    }

    // ✅ Domain method to post transaction
    public function post(): self
    {
        if (!in_array($this->status, [TransactionStatus::DRAFT, TransactionStatus::APPROVED])) {
            throw new InvalidTransactionStateException(
                'Only draft or approved transactions can be posted'
            );
        }

        // ✅ Validate balance before posting
        if (!$this->isBalanced()) {
            $debits = $this->getTotalDebits()->toFloat();
            $credits = $this->getTotalCredits()->toFloat();
            throw UnbalancedTransactionException::create($debits, $credits);
        }

        return \DB::transaction(function () {
            $this->status = TransactionStatus::POSTED;
            $this->posted_at = now();
            $this->save();

            // Update account balances
            foreach ($this->journalEntries as $entry) {
                $entry->account->updateBalance($entry);
            }

            TransactionPosted::dispatch($this->id, $this->transaction_number, now());

            return $this;
        });
    }

    // ✅ Check if debits equal credits using Money VO
    public function isBalanced(): bool
    {
        $totalDebits = $this->getTotalDebits();
        $totalCredits = $this->getTotalCredits();

        // Allow 0.01 difference for floating point precision
        return abs($totalDebits->toFloat() - $totalCredits->toFloat()) < 0.01;
    }

    // ✅ Calculate total debits using Money VO
    public function getTotalDebits(): Money
    {
        $totalCents = $this->journalEntries->sum(function ($entry) {
            return $entry->debitAmount ? $entry->debitAmount->amount() : 0;
        });

        return Money::fromAmount($totalCents, 'USD');
    }

    // ✅ Calculate total credits using Money VO
    public function getTotalCredits(): Money
    {
        $totalCents = $this->journalEntries->sum(function ($entry) {
            return $entry->creditAmount ? $entry->creditAmount->amount() : 0;
        });

        return Money::fromAmount($totalCents, 'USD');
    }

    // ✅ Add journal entry with validation
    public function addJournalEntry(
        int $accountId,
        Money $debitAmount = null,
        Money $creditAmount = null,
        string $description = null
    ): JournalEntry {
        if ($this->status === TransactionStatus::POSTED) {
            throw new PostedTransactionModificationException(
                'Cannot add entries to posted transaction'
            );
        }

        // Validate: entry must have either debit or credit, not both, not neither
        if ($debitAmount && $creditAmount) {
            throw new \InvalidArgumentException('Entry cannot have both debit and credit');
        }

        if (!$debitAmount && !$creditAmount) {
            throw new \InvalidArgumentException('Entry must have either debit or credit');
        }

        return $this->journalEntries()->create([
            'account_id' => $accountId,
            'debit_amount' => $debitAmount,
            'credit_amount' => $creditAmount,
            'description' => $description ?? $this->description,
        ]);
    }

    public function journalEntries()
    {
        return $this->hasMany(JournalEntry::class);
    }
}

JournalEntry.php (Money VO)

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Domain\Models;

use App\Shared\Domain\ValueObjects\Money;
use App\Shared\Infrastructure\Eloquent\Casts\MoneyCast;
use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;

/**
 * @property Money|null $debitAmount ✅ Money VO
 * @property Money|null $creditAmount ✅ Money VO
 */
final class JournalEntry extends Model
{
    use HasFactory, UsesTenantConnection;

    protected $table = 'finance_journal_entries';

    protected $fillable = [
        'transaction_id',
        'account_id',
        'debit_amount',
        'credit_amount',
        'description',
    ];

    protected function casts(): array
    {
        return [
            'debit_amount' => MoneyCast::class, // ✅ Money VO
            'credit_amount' => MoneyCast::class, // ✅ Money VO
        ];
    }

    public function transaction()
    {
        return $this->belongsTo(Transaction::class);
    }

    public function account()
    {
        return $this->belongsTo(ChartOfAccount::class, 'account_id');
    }
}

ChartOfAccount.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Domain\Models;

use App\Shared\Domain\ValueObjects\Money;
use App\Shared\Infrastructure\Eloquent\Casts\MoneyCast;
use App\Modules\Finance\Domain\Enums\AccountType;
use App\Modules\Finance\Domain\Enums\BalanceType;
use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;

/**
 * @property string $accountCode
 * @property string $accountName
 * @property AccountType $accountType
 * @property BalanceType $normalBalance
 * @property Money $currentBalance ✅ Money VO
 */
final class ChartOfAccount extends Model
{
    use HasFactory, UsesTenantConnection;

    protected $table = 'finance_chart_of_accounts';

    protected $fillable = [
        'account_code',
        'account_name',
        'account_type',
        'normal_balance',
        'current_balance',
        'is_active',
    ];

    protected function casts(): array
    {
        return [
            'account_type' => AccountType::class,
            'normal_balance' => BalanceType::class,
            'current_balance' => MoneyCast::class, // ✅ Money VO
            'is_active' => 'boolean',
        ];
    }

    // ✅ Update balance from journal entry
    public function updateBalance(JournalEntry $entry): void
    {
        $change = Money::fromFloat(0, 'USD');

        if ($entry->debitAmount) {
            $change = $this->normalBalance === BalanceType::DEBIT
                ? $entry->debitAmount
                : $entry->debitAmount->multiply(-1);
        } elseif ($entry->creditAmount) {
            $change = $this->normalBalance === BalanceType::CREDIT
                ? $entry->creditAmount
                : $entry->creditAmount->multiply(-1);
        }

        $this->currentBalance = $this->currentBalance->add($change);
        $this->save();
    }

    // ✅ Get balance as of date using Money VO
    public function getBalanceAsOf(\DateTimeInterface $date): Money
    {
        $totalCents = $this->journalEntries()
            ->whereHas('transaction', function ($query) use ($date) {
                $query->where('transaction_date', '<=', $date)
                    ->where('status', TransactionStatus::POSTED);
            })
            ->get()
            ->sum(function ($entry) {
                if ($entry->debitAmount) {
                    return $this->normalBalance === BalanceType::DEBIT
                        ? $entry->debitAmount->amount()
                        : -$entry->debitAmount->amount();
                }

                if ($entry->creditAmount) {
                    return $this->normalBalance === BalanceType::CREDIT
                        ? $entry->creditAmount->amount()
                        : -$entry->creditAmount->amount();
                }

                return 0;
            });

        return Money::fromAmount($totalCents, 'USD');
    }

    public function journalEntries()
    {
        return $this->hasMany(JournalEntry::class, 'account_id');
    }
}

Step 2: Application Layer - CQRS

2.1 Commands

CreateTransactionCommand.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Application\Commands;

use App\Shared\Application\Commands\Command;
use App\Modules\Finance\Application\DTOs\Transaction\CreateTransactionDTO;

final class CreateTransactionCommand implements Command
{
    public function __construct(
        public readonly CreateTransactionDTO $dto,
    ) {}

    public function validate(): array
    {
        // Validate balance
        if (!$this->dto->isBalanced()) {
            return ['Transaction must balance: debits must equal credits'];
        }

        return [];
    }

    public static function fromArray(array $data): self
    {
        return new self(
            dto: CreateTransactionDTO::fromArray($data),
        );
    }
}

CreateTransactionCommandHandler.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Application\Commands;

use App\Shared\Application\Commands\Command;
use App\Shared\Application\Commands\CommandHandler;
use App\Modules\Finance\Domain\Contracts\TransactionRepositoryInterface;
use App\Modules\Finance\Domain\Models\Transaction;

final class CreateTransactionCommandHandler implements CommandHandler
{
    public function __construct(
        private readonly TransactionRepositoryInterface $transactionRepository,
    ) {}

    public function handle(Command $command): Transaction
    {
        \assert($command instanceof CreateTransactionCommand);

        $dto = $command->dto;

        $transaction = new Transaction([
            'transaction_date' => $dto->transactionDate,
            'description' => $dto->description,
            'status' => TransactionStatus::DRAFT,
            'reference_type' => $dto->referenceType,
            'reference_id' => $dto->referenceId,
        ]);

        $transaction->transaction_number = Transaction::generateTransactionNumber();
        $transaction->save();

        // Add journal entries
        foreach ($dto->entries as $entryDTO) {
            $transaction->addJournalEntry(
                $entryDTO->accountId,
                $entryDTO->debitAmount,
                $entryDTO->creditAmount,
                $entryDTO->description
            );
        }

        // Refresh to get all entries
        $transaction->load('journalEntries');

        // ✅ Validate balance
        if (!$transaction->isBalanced()) {
            throw new UnbalancedTransactionException(
                $transaction->getTotalDebits()->toFloat(),
                $transaction->getTotalCredits()->toFloat()
            );
        }

        return $transaction;
    }
}

PostTransactionCommand.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Application\Commands;

use App\Shared\Application\Commands\Command;

final class PostTransactionCommand implements Command
{
    public function __construct(
        public readonly int $transactionId,
    ) {}

    public function validate(): array
    {
        return [];
    }
}

PostTransactionCommandHandler.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Application\Commands;

use App\Shared\Application\Commands\Command;
use App\Shared\Application\Commands\CommandHandler;
use App\Modules\Finance\Domain\Contracts\TransactionRepositoryInterface;
use App\Modules\Finance\Domain\Models\Transaction;

final class PostTransactionCommandHandler implements CommandHandler
{
    public function __construct(
        private readonly TransactionRepositoryInterface $transactionRepository,
    ) {}

    public function handle(Command $command): Transaction
    {
        \assert($command instanceof PostTransactionCommand);

        $transaction = $this->transactionRepository->findOrFail($command->transactionId);

        // ✅ Event dispatched inside post() method
        // ✅ Balance validated inside post() method
        $transaction->post();

        return $transaction;
    }
}

2.2 DTOs with Money Conversion

CreateTransactionDTO.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Application\DTOs\Transaction;

final class CreateTransactionDTO
{
    /**
     * @param JournalEntryDTO[] $entries
     */
    public function __construct(
        public readonly \DateTimeInterface $transactionDate,
        public readonly string $description,
        public readonly array $entries,
        public readonly ?string $referenceType = null,
        public readonly ?int $referenceId = null,
    ) {
        $this->validate();
    }

    public static function fromArray(array $data): self
    {
        // Convert entry arrays to DTOs
        $entries = array_map(
            fn($entry) => JournalEntryDTO::fromArray($entry),
            $data['entries'] ?? []
        );

        return new self(
            transactionDate: new \DateTimeImmutable($data['transaction_date']),
            description: $data['description'],
            entries: $entries,
            referenceType: $data['reference_type'] ?? null,
            referenceId: isset($data['reference_id']) ? (int) $data['reference_id'] : null,
        );
    }

    // ✅ Validate balance in DTO
    public function isBalanced(): bool
    {
        $totalDebits = Money::fromFloat(0, 'USD');
        $totalCredits = Money::fromFloat(0, 'USD');

        foreach ($this->entries as $entry) {
            if ($entry->debitAmount) {
                $totalDebits = $totalDebits->add($entry->debitAmount);
            }
            if ($entry->creditAmount) {
                $totalCredits = $totalCredits->add($entry->creditAmount);
            }
        }

        return abs($totalDebits->toFloat() - $totalCredits->toFloat()) < 0.01;
    }

    private function validate(): void
    {
        if (empty($this->entries)) {
            throw new \InvalidArgumentException('Transaction must have at least 2 entries');
        }

        if (count($this->entries) < 2) {
            throw new \InvalidArgumentException('Double-entry requires at least 2 entries');
        }
    }
}

JournalEntryDTO.php

php
<?php

declare(strict_types=1);

namespace App\Modules\Finance\Application\DTOs\Transaction;

use App\Shared\Domain\ValueObjects\Money;

final class JournalEntryDTO
{
    public function __construct(
        public readonly int $accountId,
        public readonly ?Money $debitAmount, // ✅ Money VO
        public readonly ?Money $creditAmount, // ✅ Money VO
        public readonly ?string $description = null,
    ) {
        $this->validate();
    }

    public static function fromArray(array $data): self
    {
        // ✅ Convert float to Money VO
        $debitAmount = isset($data['debit_amount']) && $data['debit_amount'] > 0
            ? Money::fromFloat((float) $data['debit_amount'], 'USD')
            : null;

        $creditAmount = isset($data['credit_amount']) && $data['credit_amount'] > 0
            ? Money::fromFloat((float) $data['credit_amount'], 'USD')
            : null;

        return new self(
            accountId: (int) $data['account_id'],
            debitAmount: $debitAmount,
            creditAmount: $creditAmount,
            description: $data['description'] ?? null,
        );
    }

    private function validate(): void
    {
        // Must have either debit or credit, not both, not neither
        if ($this->debitAmount && $this->creditAmount) {
            throw new \InvalidArgumentException('Entry cannot have both debit and credit');
        }

        if (!$this->debitAmount && !$this->creditAmount) {
            throw new \InvalidArgumentException('Entry must have either debit or credit');
        }
    }
}

Step 3: Migrations

create_finance_transactions_table.php

php
Schema::create('finance_transactions', function (Blueprint $table) {
    $table->id();
    $table->string('transaction_number')->unique();
    $table->date('transaction_date');
    $table->text('description');
    $table->string('status');
    $table->string('reference_type')->nullable();
    $table->unsignedBigInteger('reference_id')->nullable();
    $table->timestamp('posted_at')->nullable();

    $table->timestamps();
    $table->softDeletes();
});

create_finance_journal_entries_table.php

php
Schema::create('finance_journal_entries', function (Blueprint $table) {
    $table->id();
    $table->foreignId('transaction_id')->constrained('finance_transactions')->cascadeOnDelete();
    $table->foreignId('account_id')->constrained('finance_chart_of_accounts');

    // ✅ JSON for Money VOs (can be null - one must be set)
    $table->json('debit_amount')->nullable();
    $table->json('credit_amount')->nullable();

    $table->text('description')->nullable();

    $table->timestamps();

    $table->index(['transaction_id', 'account_id']);
});

Testing

php
public function test_transaction_must_balance(): void
{
    $this->expectException(UnbalancedTransactionException::class);

    $transaction = Transaction::factory()->create();

    // Unbalanced entries
    $transaction->addJournalEntry(
        accountId: 1,
        debitAmount: Money::fromFloat(100, 'USD'),
    );

    $transaction->addJournalEntry(
        accountId: 2,
        creditAmount: Money::fromFloat(50, 'USD'), // ❌ Doesn't match
    );

    $transaction->post(); // ❌ Should throw
}

public function test_transaction_balances_correctly(): void
{
    $transaction = Transaction::factory()->create();

    // Balanced entries
    $transaction->addJournalEntry(
        accountId: 1,
        debitAmount: Money::fromFloat(100, 'USD'),
    );

    $transaction->addJournalEntry(
        accountId: 2,
        creditAmount: Money::fromFloat(100, 'USD'), // ✅ Balanced
    );

    $this->assertTrue($transaction->isBalanced());

    $transaction->post(); // ✅ Should succeed
}

Implementation Checklist

  • [ ] Create TransactionStatus, AccountType, BalanceType enums
  • [ ] Create UnbalancedTransactionException
  • [ ] Create Transaction model with isBalanced() validation
  • [ ] Create JournalEntry model with Money VOs
  • [ ] Create ChartOfAccount model with balance tracking
  • [ ] Implement boot() method to validate balance on save
  • [ ] Create Commands with balance validation
  • [ ] Create DTOs with Money conversion
  • [ ] Test double-entry bookkeeping rules

Critical: Finance module MUST enforce double-entry rules. Every transaction MUST balance!

Reference: docs/implementation/operations-module-v2.md

Documentation for SynthesQ CRM/ERP Platform