Journal Vouchers & General Ledger

v0.1.0

Double-entry journal drafting, zero-sum minor unit validation, Maker-Checker segregation, GL postings, and immutable Storno reversals.

Journal Vouchers & General Ledger

The General Ledger represents the financial truth of an organization. In Obelawium Accounting, all ledger mutations take the form of balanced Journal Vouchers managed through ium()->accounting()->journalEntries() and reported through ium()->accounting()->ledger().


1. The Voucher Lifecycle

A journal voucher advances through an explicit state machine:

stateDiagram-v2
    [*] --> DRAFT: createDraft()
    DRAFT --> SUBMITTED: submit(actorId)
    SUBMITTED --> APPROVED: approve(approverId)
    SUBMITTED --> REJECTED: reject(approverId, reason)
    REJECTED --> DRAFT: revise()
    APPROVED --> POSTED: post(actorId)
    POSTED --> REVERSED: reverse(actorId, reason)
    REVERSED --> [*]

2. Drafting a Balanced Journal Voucher

Vouchers can be defined using minor units (integers) or major units (decimals) via CreateJournalEntryDto.

Using Decimal Major Units (fromMajor)

use Obelaw\Ium\Accounting\Data\CreateJournalEntryDto;
use Obelaw\Ium\Accounting\Enums\JournalEntryType;

$dto = CreateJournalEntryDto::fromMajor([
    'entry_date'    => '2026-03-31',
    'entry_type'    => JournalEntryType::Standard,
    'currency_code' => 'USD',
    'memo'          => 'Office Equipment Purchase via Bank Wire',
    'lines' => [
        [
            'account_code'     => '1510', // Office Equipment (Leaf Asset)
            'side'             => 'debit',
            'amount'           => 4250.00,
            'cost_center_code' => 'CC-OPS',
            'memo'             => 'MacBook Pro workstations',
        ],
        [
            'account_code'     => '1111', // Bank Operating (Leaf Asset)
            'side'             => 'credit',
            'amount'           => 4250.00,
            'memo'             => 'Wire Transfer #9021',
        ],
    ],
]);

// Persists the voucher with status = DRAFT
$voucher = ium()->accounting()->journalEntries()->createDraft($dto);

The Zero-Sum Check

When saving or submitting, the system asserts:

$$\sum \text{Debits (minor)} = \sum \text{Credits (minor)}$$

If debits and credits do not match to the exact integer minor unit, the method halts immediately with an UnbalancedJournalEntryException.


3. Maker-Checker Segregation of Duties

To protect organizations from unilateral or fraudulent ledger disbursements, ium-accounting features built-in Maker-Checker governance:

// Step 1: Preparer / Accountant submits the draft
ium()->accounting()->journalEntries()->submit($voucher->id, $preparerId);

// Step 2: Financial Controller / Auditor reviews and approves
// If $controllerId === $voucher->creator_id, throws MakerCheckerViolationException!
ium()->accounting()->journalEntries()->approve($voucher->id, $controllerId);

// Step 3: Posting to the General Ledger
$postedVoucher = ium()->accounting()->journalEntries()->post($voucher->id, $controllerId);

Strict Governance Setting: Set config('ium.accounting.maker_checker.strict') to false only in local development or single-user environments.


4. General Ledger Postings & Immutability

When a voucher is posted:

  1. JournalEntry status transitions permanently to POSTED.
  2. Individual GeneralLedgerPosting rows are written for each line item.
  3. Amounts are converted into the functional base currency: $$amount_functional = \text{round}(amount \times exchange_rate)$$
  4. Eloquent model hooks lock the voucher. Any subsequent in-place SQL UPDATE or DELETE throws an ImmutableJournalEntryException.

5. Correcting Errors: The Storno Reversal Pattern

GAAP and IFRS standards forbid deleting or modifying posted financial records. Corrections must always generate an equal-and-opposite Storno Reversal:

$reversalVoucher = ium()->accounting()->journalEntries()->reverse(
    entryId: $postedVoucher->id,
    actorId: $controllerId,
    reason: 'Incorrect asset account selected on original purchase wire'
);

What Happens During a Storno Reversal?

  1. The original entry status becomes REVERSED.
  2. A new journal entry is created with status POSTED and entry_type = JournalEntryType::Reversal.
  3. The new entry contains identical accounts and amounts, but with inverted sides: original debits become credits, and original credits become debits.
  4. The new entry links back via reversed_entry_id = $original->id.
  5. The forensic audit log records both actors, timestamps, and justification.

6. Real-Time Financial Statements

Query the General Ledger via ium()->accounting()->ledger():

Trial Balance

$tb = ium()->accounting()->ledger()->generateTrialBalance($periodId = null);

// Verification check
if ($tb['is_balanced'] === true) {
    echo "Trial balance verified: Debits = {$tb['total_debit_major']}, Credits = {$tb['total_credit_major']}\n";
}

Balance Sheet

$balanceSheet = ium()->accounting()->ledger()->generateBalanceSheet($asOfDate = '2026-03-31');

dump($balanceSheet['assets']);
dump($balanceSheet['liabilities']);
dump($balanceSheet['equity']);
dump($balanceSheet['discrepancy']); // Guaranteed 0

Income Statement (Profit & Loss)

$pnl = ium()->accounting()->ledger()->generateIncomeStatement($periodId);

echo "Revenues: {$pnl['total_revenue_major']}\n";
echo "Expenses: {$pnl['total_expense_major']}\n";
echo "Net Income: {$pnl['net_income_major']}\n";