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 floatStep 1: Domain Layer
1.1 Create Enums
AccountType.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
declare(strict_types=1);
namespace App\Modules\Finance\Domain\Enums;
enum BalanceType: string
{
case DEBIT = 'debit';
case CREDIT = 'credit';
}TransactionStatus.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
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
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
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
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
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
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
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
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
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
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
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
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
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,BalanceTypeenums - [ ] Create
UnbalancedTransactionException - [ ] Create
Transactionmodel withisBalanced()validation - [ ] Create
JournalEntrymodel with Money VOs - [ ] Create
ChartOfAccountmodel 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