michael-orenda/general-ledger

Audit-grade, append-only General Ledger for Laravel 12 with strict accounting invariants, period locking, and sub-ledger integration.

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/michael-orenda/general-ledger

v0.0.1 2025-12-17 08:10 UTC

This package is auto-updated.

Last update: 2025-12-17 08:17:38 UTC


README

A robust, test-driven General Ledger (GL) package for Laravel 12 designed as a reusable component for accounting systems. Written to be embedded as a package in larger host applications.

Table of contents

  1. Overview
  2. Key Concepts
  3. Requirements
  4. Installation
  5. Configuration
  6. Database Migrations & Seeds
  7. Models & Relationships
  8. Services
  9. Controllers & Routes (API)
  10. Validation & Business Rules
  11. Database Constraints & Triggers
  12. Events & Listeners
  13. Testing
  14. Security & Concurrency
  15. Packaging & Release
  16. Troubleshooting
  17. Examples & Use-Cases
  18. Contributing
  19. Changelog
  20. License

Overview

This package provides a General Ledger implementation that follows standard double-entry accounting principles, shaped for multi-organisation, multi-fiscal-period systems. It aims to be:

  • Composable: usable as a Laravel package or integrated directly into your mono-repo.
  • Test-first: shipped with Testbench-powered tests.
  • Safe: includes DB-level protections (checks/triggers), API guards, and service-layer validation.
  • Extensible: sub-ledgers can post to the GL through a clear contract.

Primary capabilities:

  • Chart of Accounts (COA) -> auto-created ledger accounts
  • Posting journal entries (balanced debit/credit)
  • Ledger accounts and entries listing & reporting
  • Period locking and enforcement
  • Opening/closing period handling
  • Services to generate opening balances

Key Concepts

  • Organisation: top-level tenant scoping most data.
  • FiscalYear / FiscalPeriod: fiscal time boundaries. Periods can be open or closed.
  • LedgerAccount: representation of an account in the GL (asset, liability, equity, revenue, expense).
  • LedgerEntry: posted transactional rows tied to a ledger account. Entries are created by posting a JournalEntry which contains multiple debit/credit JournalLines.
  • Sub-ledger: domain modules (loans, payments, payroll) that use a PostsToGeneralLedger contract to post entries.

Requirements

  • PHP 8.2+
  • Laravel 12
  • Database: MySQL 5.7+/MariaDB or Postgres (some DB-triggers examples provided are for MySQL; adapt for Postgres)
  • ext-mbstring, ext-json

Installation

Install via composer (package name to replace with the published packagist name):

composer require michaelorenda/general-ledger

Publish config, migrations and seeds (if the package provides publishable assets):

php artisan vendor:publish --provider="MichaelOrenda\GeneralLedger\GeneralLedgerServiceProvider" --tag="config"
php artisan vendor:publish --provider="MichaelOrenda\GeneralLedger\GeneralLedgerServiceProvider" --tag="migrations"
php artisan vendor:publish --provider="MichaelOrenda\GeneralLedger\GeneralLedgerServiceProvider" --tag="seeds"

Run migrations:

php artisan migrate

Seed default Chart of Accounts and a sample organisation (optional):

php artisan db:seed --class="\MichaelOrenda\GeneralLedger\Database\Seeders\ChartOfAccountsSeeder"
php artisan db:seed --class="\MichaelOrenda\GeneralLedger\Database\Seeders\OrganisationSeeder"

Configuration

config/general-ledger.php (sensible defaults):

return [
    'default_currency' => env('GL_DEFAULT_CURRENCY', 'KES'),
    'default_start_of_day' => '00:00:00',
    'journal_prefix' => env('GL_JOURNAL_PREFIX', 'JE'),
    'locking' => [
        'enable_db_triggers' => true,
    ],
    // mapping of account classes/types
    'accounts' => [
        'asset' => 1,
        'liability' => 2,
        'equity' => 3,
        'revenue' => 4,
        'expense' => 5,
    ],
];

Explaination of key options:

  • default_currency: currency used when posting amounts that do not specify currency explicitly.
  • locking.enable_db_triggers: set to false if you prefer application-level enforcement only.

Database Migrations & Seeds

The package includes the following recommended tables:

  • ledger_accounts (id, organisation_id, code, name, account_class, normal_balance, created_at, updated_at)
  • ledger_entries (id, organisation_id, ledger_account_id, journal_id, amount, type (debit|credit), description, fiscal_period_id, created_at)
  • journals or journal_entries (id, organisation_id, reference, narration, posted_by, posted_at)
  • fiscal_years (id, organisation_id, start_date, end_date)
  • fiscal_periods (id, organisation_id, fiscal_year_id, start_date, end_date, is_closed, created_at)

Migrations should include indexes on organisation_id, fiscal_period_id and ledger_account_id for performance.

Seeders

  • ChartOfAccountsSeeder — seeds a canonical set of ledger accounts.
  • LedgerAccountsSeeder — creates account instances per organisation from the COA.

Models & Relationships

Outline of essential Eloquent models and their core relationships:

  • LedgerAccount

    • belongsTo Organisation
    • hasMany LedgerEntry
  • LedgerEntry

    • belongsTo LedgerAccount
    • belongsTo FiscalPeriod
    • belongsTo JournalEntry
  • JournalEntry (or Journal)

    • hasMany JournalLine or LedgerEntry
  • FiscalPeriod

    • belongsTo FiscalYear
    • hasMany LedgerEntry

Add docblocks and typed properties on each model for clarity and static analysis support.

Services

This section documents all service classes in the General Ledger package, their responsibilities, invariants, and example usage patterns. These services are the only supported way to interact with the ledger at the domain level. Controllers, commands, and sub-ledgers must call services — never manipulate models directly.

Design rule: Services encapsulate business invariants. Models remain persistence-focused.

AccountLedgerService

Purpose

Produces a ledger (running balance) for a single account over a date or fiscal-period range.

Responsibilities

  • Fetch ledger entries for a given account
  • Sort chronologically
  • Compute running balances (debit/credit aware)
  • Support date-range or fiscal-period filtering

Typical Use Cases

  • Account detail view in accounting UI
  • Audit or reconciliation workflows

Example

$ledger = $accountLedgerService->getLedger(
    organisationId: 1,
    ledgerAccountId: 101,
    from: '2025-01-01',
    to: '2025-01-31'
);

Returns

  • Collection of ledger rows with:
    • entry date
    • debit / credit
    • running balance

AuditTrailService

Purpose

Provides a read-only, immutable audit trail of all ledger activity.

Responsibilities

  • Expose who posted what and when
  • Correlate journals, ledger entries, and users
  • Support forensic review and compliance

Important Rule

Audit data is never mutated — even reversals create new entries.

Example

$audit = $auditTrailService->forJournal($journalId);

GeneralLedgerReportService

Purpose

Produces the official General Ledger report used for statutory reporting.

Responsibilities

  • Aggregate all ledger accounts
  • Group by account class
  • Respect fiscal-period boundaries
  • Produce balances as-of a date or period

Example

$report = $glReportService->generate(
    organisationId: 1,
    fiscalPeriodId: 12
);

LedgerQueryService

Purpose

Low-level query abstraction for ledger entries.

Responsibilities

  • Centralize common ledger queries
  • Avoid query duplication across services
  • Apply organisation and period scoping consistently

Example

$entries = $ledgerQueryService
    ->forOrganisation(1)
    ->forPeriod(12)
    ->forAccount(101)
    ->get();

LedgerPostingService

Purpose

Lowest-level service responsible for inserting ledger entries.

Important

You should never call this directly from controllers. Use JournalPostingService instead.

Responsibilities

  • Insert debit/credit rows
  • Enforce period open-state
  • Operate strictly inside transactions

Used internally by:

  • JournalPostingService
  • OpeningBalanceService
  • ReversalJournalService

JournalPostingService

Purpose

Primary entry point for posting accounting transactions.

Responsibilities

  • Validate journal structure
  • Enforce balancing (debits == credits)
  • Validate fiscal period openness
  • Persist journal + ledger entries atomically
  • Emit JournalPosted event

Example

$journalId = $journalPostingService->post([
    'organisation_id' => 1,
    'fiscal_period_id' => 12,
    'reference' => 'INV-1001',
    'narration' => 'Invoice payment',
    'lines' => [
        ['ledger_account_id' => 10, 'type' => 'debit', 'amount' => 1000],
        ['ledger_account_id' => 200, 'type' => 'credit', 'amount' => 1000],
    ],
]);

Throws

  • DomainException — unbalanced journal
  • LogicException — posting to closed period

JournalReversalService / ReversalJournalService

Purpose

Creates a reversing journal for an existing journal.

Responsibilities

  • Clone original journal lines
  • Swap debit ↔ credit
  • Preserve auditability
  • Prevent double-reversal

Example

$reversalJournalId = $journalReversalService->reverse(
    journalId: 55,
    reason: 'Invoice cancelled'
);

Rule

Reversals must always post into an open fiscal period.

TrialBalanceService

Purpose

Produces a Trial Balance for a given period or date.

Responsibilities

  • Aggregate debits and credits per account
  • Ensure totals balance
  • Act as a validation gate before period close

Example

$trialBalance = $trialBalanceService->generate(
    organisationId: 1,
    fiscalPeriodId: 12
);

If the trial balance does not balance, period close must be blocked.

OpeningBalanceService

Purpose

Generates opening balances for a new fiscal period.

Responsibilities

  • Carry forward asset/liability balances
  • Zero revenue and expense accounts
  • Transfer net income to retained earnings
  • Enforce idempotency

Example

$openingBalanceService->generate(
    organisationId: 1,
    fromPeriodId: 12,
    toPeriodId: 13
);

PeriodCloseService / FiscalPeriodCloseService

Purpose

Safely closes a fiscal period.

Responsibilities

  1. Generate Trial Balance
  2. Validate balance correctness
  3. Generate opening balances for next period
  4. Lock the period (DB + application)
  5. Emit PeriodClosed event

Example

$periodCloseService->close(
    organisationId: 1,
    fiscalPeriodId: 12
);

Hard Guarantees

  • Closed periods are immutable
  • Posting attempts fail at service + DB level

All core business logic lives in service classes. Keep controllers thin.

JournalPostingService

Responsibilities:

  • Validate payload shape (presence of fiscal_period_id, organisation_id, balanced lines).
  • Ensure the target FiscalPeriod is open before posting.
  • Persist a JournalEntry and corresponding LedgerEntry rows within a DB transaction.
  • Emit domain events (e.g., JournalPosted, LedgerUpdated).

Example usage:

$service->post([
    'organisation_id' => 1,
    'fiscal_period_id' => 12,
    'reference' => 'INV-1001',
    'narration' => 'Payment for invoice 1001',
    'lines' => [
        [ 'ledger_account_id' => 10, 'type' => 'debit', 'amount' => 1000.00, 'description' => 'Cash' ],
        [ 'ledger_account_id' => 200, 'type' => 'credit', 'amount' => 1000.00, 'description' => 'Revenue' ],
    ],
    'posted_by' => 5,
]);

Important checks inside post():

  • fiscal_period_id exists and belongs to organisation.
  • Period is not closed ($period->is_closed === false).
  • Sum of debit amounts equals sum of credit amounts.
  • Each ledger_account_id exists and belongs to the organisation (or is globally allowed per config).

Transaction handling:

  • Use DB::transaction() around create operations.
  • Lock rows where necessary (see concurrency section) to avoid race conditions.

PeriodCloseService

Responsibilities:

  • Calculate final balances for the period.
  • Move balances into next-period opening balances (create OpeningBalance entries).
  • Prevent further posting to closed periods (DB triggers + application-level flags).

Example:

$periodCloseService->close($organisationId, $fiscalPeriodId);

OpeningBalanceService

Generates the opening balances for a new fiscal period from previous period totals and retained earnings transformations. Should be idempotent — calling it multiple times should not duplicate balances.

Controllers & Routes (API)

Provide API endpoints namespaced under /api/general-ledger or similar. Use API resources for consistent responses.

Example routes:

Route::prefix('api')->group(function () {
    Route::prefix('general-ledger')->middleware(['auth:api'])->group(function () {
        Route::get('accounts', [AccountController::class, 'index']);
        Route::get('accounts/{id}', [AccountController::class, 'show']);

        Route::post('journals', [JournalController::class, 'store']);
        Route::get('journals/{id}', [JournalController::class, 'show']);

        Route::post('periods/{id}/close', [PeriodController::class, 'close']);
    });
});

Controller responsibilities:

  • Validate requests with FormRequests.
  • Use service classes to perform operations.
  • Return HTTP 400/422 for business validation failures (e.g., unbalanced journal or closed period) and 500 for unexpected errors.

Example API Request — Post Journal

POST /api/general-ledger/journals

Payload:

{
  "organisation_id": 1,
  "fiscal_period_id": 12,
  "reference": "INV-1001",
  "narration": "Payment for invoice 1001",
  "lines": [
    {"ledger_account_id": 10, "type": "debit", "amount": 1000.00, "description": "Cash"},
    {"ledger_account_id": 200, "type": "credit", "amount": 1000.00, "description": "Revenue"}
  ]
}

Success: HTTP 201 with created journal details. Failure: HTTP 422 with validation errors or HTTP 409 if period closed.

Validation & Business Rules

  1. Journals must be balanced (total debits == total credits).
  2. Every entry must reference a valid ledger account.
  3. Cannot post into a closed period — enforce at service-level and with DB-level constraints/triggers.
  4. Opening balances must be posted to the first period of the fiscal year only through the OpeningBalanceService.
  5. Avoid floating point rounding errors: store amounts as integers representing the smallest currency unit (e.g., cents) or use decimal(20,4) depending on requirements.

Database Constraints & Triggers

To prevent accidental posting to closed periods at the DB level (MySQL example):

DELIMITER $$
CREATE TRIGGER prevent_closed_period_posting
BEFORE INSERT ON ledger_entries
FOR EACH ROW
BEGIN
    IF EXISTS (
        SELECT 1 FROM fiscal_periods
        WHERE id = NEW.fiscal_period_id
        AND is_closed = 1
    ) THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Cannot post to a closed fiscal period';
    END IF;
END$$
DELIMITER ;

Create similar triggers for BEFORE UPDATE and BEFORE DELETE depending on policy. In the migration files, add these triggers conditionally (guard with if (Schema::hasTable('fiscal_periods')) { ... }).

Note: not all DBs support triggers or SIGNAL semantics. If you need Postgres, implement plpgsql triggers or prefer application-level enforcement.

Events & Listeners

Emit meaningful domain events:

  • JournalPosted (payload: journal id, organisation id, posted_by)
  • PeriodClosed (period id, organisation id)
  • OpeningBalancesGenerated (period id)

Listeners can:

  • Update cached ledgers or materialized views
  • Send notifications to admins
  • Integrate sub-ledger reconciliations

Example event dispatch:

event(new JournalPosted($journal));

Testing

  • Use orchestral/testbench for package testing with Laravel.
  • Include unit tests for: service logic (balanced journals, period checks), model factories, API endpoints.
  • Include integration tests that run migrations and seeders.

Suggested tests:

  • JournalPostingServiceTest — posts valid/invalid journals; asserts DB rows and events emitted.
  • PeriodCloseServiceTest — closes period; checks opening balances created for subsequent period.
  • TriggersMigrationTest — if your environment supports triggers, assert that triggers prevent posting to closed periods.

Run tests:

composer test
# or
vendor/bin/phpunit

Security & Concurrency

  • Always wrap posting in DB transactions.
  • Use row-level locks for balances where needed (e.g., SELECT ... FOR UPDATE) to prevent concurrent postings causing inconsistent totals.
  • Validate organisation_id for multi-tenant isolation.
  • Avoid storing user-supplied JSON directly — sanitize structured data.

Packaging & Release

  • Use semantic versioning (MAJOR.MINOR.PATCH).
  • Create a README.md (this document), CHANGELOG.md and tags for releases.
  • Run static analysis (phpstan), tests, and composer validate before tagging.

Suggested git commit message for major release:

chore(release): v1.0.0 — initial stable GL package

Troubleshooting

  • "Cannot post to closed fiscal period" — check if period is_closed flag is set; if you suspect trigger misfire, check migrations created DB triggers.
  • Undefined variable or method errors — ensure service injection in constructors and proper imports.
  • Intelephense type complaints — add docblocks and typed properties; for collection vs int errors, ensure method signatures match usage (pass collections where expected).

If you run into issues, enable detailed logs (set APP_DEBUG=true) and inspect laravel.log.

Examples & Use-Cases

Posting a Sales Invoice (from a SubLedger)

  1. The invoice module builds a Journal payload.
  2. It validates business rules (customer exists, amount positive).
  3. Calls JournalPostingService::post($payload).
  4. The GL persists entries, emits JournalPosted and the invoice marks itself posted_to_gl.

Closing a Period (end of month)

  1. Admin triggers POST /api/general-ledger/periods/{id}/close.
  2. Controller calls PeriodCloseService::close() inside a transaction.
  3. Service computes retained earnings transfers and creates opening balances for the next period.
  4. fiscal_period.is_closed set to true and DB triggers now block further inserts.

Contributing

  1. Fork repository
  2. Create feature branch: feature/awesome-thing
  3. Implement code + tests
  4. Submit PR and ensure CI passes

Coding standards: PSR-12, typed properties where helpful, docblocks for public APIs.

Changelog

  • Unreleased: initial comprehensive README and structure guidance.

License

MIT — see LICENSE in the repository.

Appendix: Helpful Snippets

DB Transaction skeleton (service):

DB::transaction(function () use ($payload) {
    $journal = Journal::create([...]);
    foreach ($payload['lines'] as $line) {
        LedgerEntry::create([...]);
    }
});

Check balanced:

$totalDebits = array_sum(array_map(fn($l) => $l['type'] === 'debit' ? $l['amount'] : 0, $lines));
$totalCredits = array_sum(array_map(fn($l) => $l['type'] === 'credit' ? $l['amount'] : 0, $lines));
if (bccomp((string)$totalDebits, (string)$totalCredits, 4) !== 0) {
    throw new \DomainException('Journal is not balanced.');
}

Trigger creation in migration (MySQL):

Add migration content carefully and conditionally — see up()/down() examples in package migrations.

If you'd like, I can also:

  • Produce a printable PDF of this README.
  • Generate a README_short.md (one-page quickstart).
  • Create the API OpenAPI (Swagger) spec from the routes.

Tell me which of those you want and I will generate them next.