olaoluwasipe/multi-wallet-ledger

Double-entry multi-currency wallet ledger for Laravel

Maintainers

Package info

github.com/olaoluwasipe/laravel-wallet-ledger

pkg:composer/olaoluwasipe/multi-wallet-ledger

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-04-27 10:55 UTC

This package is auto-updated.

Last update: 2026-04-27 11:24:05 UTC


README

Double-entry, multi-currency ledger primitives for Laravel: wallets, accounts, journals, and optional business-level transactions. Amounts are stored in minor units (e.g. cents) per ISO currency code; each journal must balance per currency (total debits equal total credits for that currency).

The package focuses on persistence and invariants (balanced postings, line shape, idempotency). It does not ship UI or balance-sheet views—you build those on top of the models and journal_lines.

Requirements

  • PHP 8.2+
  • Laravel 11, 12, or 13 (illuminate/* ^11|^12|^13)

What it provides

Layer Role
Wallet A logical holder: user, system, or provider (extensible via config). May be tied to an owner (owner_type / owner_id morph) or stand alone (e.g. treasury). Optional code disambiguates system wallets (treasury, fees, float).
Account A posting target inside a wallet: account_kind (e.g. available, pending_in) maps to an accounting_type (asset, liability, …) from config. Accounts are identified in practice by wallet + generated code such as USD:available.
Journal One balanced posting event: header + many journal lines.
Journal line Debit or credit to one account, in one currency, in minor units.
Ledger (WalletLedger\Services\Ledger) Validates and persists a JournalDraft inside a DB transaction; optional idempotency via idempotency_key on the journal. Dispatches JournalPosted.
WalletRegistry getOrCreateWallet() and ensureAccount() using your published wallet-ledger config (wallet types, allowed codes, account kinds).
TransactionService (optional) Posts a draft LedgerTransaction + lines to the ledger, links journal_id and journal_line_id, sets status to posted. Requires extra migrations and features.business_transactions. Dispatches TransactionPosted.

Strict vs lenient validation: validation_mode is strict (default) or lenient. In strict mode, unknown wallet_type, account_kind, or transaction_type values throw. In lenient mode, those checks are skipped (useful while prototyping); unknown account kinds still default accounting_type to asset when missing from config.

Installation

1. Require the package

composer require olaoluwasipe/multi-wallet-ledger

Laravel auto-discovers WalletLedger\WalletLedgerServiceProvider, which:

  • Merges config/wallet-ledger.php
  • Registers Ledger, LedgerContract, TransactionService, TransactionServiceContract, WalletRegistry, ConfigValidator
  • Loads core migrations from the package (database/migrations) on every boot—no publish step is required for the base tables

2. Run migrations

php artisan migrate

This creates tables whose names use the configured prefix (default wl_): wl_wallets, wl_accounts, wl_journals, wl_journal_lines.

3. (Recommended) Publish configuration

Publish once so you can edit wallet types, account kinds, transaction types, table prefix, and flags:

php artisan vendor:publish --tag=wallet-ledger-config

Edit config/wallet-ledger.php. You can mirror important values in .env:

Env Config path Purpose
WALLET_LEDGER_VALIDATION_MODE validation_mode strict or lenient
WALLET_LEDGER_BUSINESS_TRANSACTIONS features.business_transactions Enable TransactionService
(none by default) table_prefix DB table prefix (default wl_)

Order matters: set table_prefix before the first migration if you do not want the default wl_. Changing prefix after tables exist requires a manual rename or new install.

4. Optional: copy migrations into your app

Normally you rely on the package’s loadMigrationsFrom. If you prefer owning migration files (e.g. to tweak indexes):

php artisan vendor:publish --tag=wallet-ledger-migrations
php artisan migrate

Avoid duplicating the same schema: either use package-loaded migrations or published copies, not both.

5. Optional: business transactions

Extra tables: wl_transactions, wl_transaction_lines, and transaction_id on wl_journals.

php artisan vendor:publish --tag=wallet-ledger-transaction-migrations
php artisan migrate

Then enable the feature:

WALLET_LEDGER_BUSINESS_TRANSACTIONS=true

or in config/wallet-ledger.php:

'features' => [
    'business_transactions' => true,
],

Without both migrations and this flag, TransactionService::post() throws a clear runtime exception.

Configuration reference

After publishing, config/wallet-ledger.php controls:

  • table_prefix — Prefix for all package tables (via WalletLedger\Support\Table::name()).
  • validation_modestrict | lenient.
  • features.business_transactions — Must be true to use TransactionService.
  • wallet_types — Each key is a allowed wallet_type. Options include:
    • owner_required — If true, getOrCreateWallet() requires an owner model.
    • allowed_codes — For system wallets, optional whitelist of code values (default includes treasury, fees, float).
  • account_kinds — Each key is an allowed account_kind; accounting_type drives reporting (asset vs liability, etc.).
  • transaction_types — Keys are allowed transaction_type values on LedgerTransaction when strict.

Inject WalletLedger\Services\WalletRegistry and resolve WalletLedger\Contracts\LedgerContract (or Ledger) in your services, seeders, and jobs.

Adding wallets and accounts in code

Use WalletRegistry so config rules (owner required, allowed codes, account kinds) are enforced in strict mode.

System wallet (no owner; use code from allowed_codes):

use WalletLedger\Services\WalletRegistry;

$registry = app(WalletRegistry::class);

$treasury = $registry->getOrCreateWallet('system', null, 'treasury', 'Treasury');
$treasuryUsd = $registry->ensureAccount($treasury, 'available', 'USD', 'Treasury USD');

User wallet (owner required):

$wallet = $registry->getOrCreateWallet('user', $user, null, 'Primary wallet');
$usdAvailable = $registry->ensureAccount($wallet, 'available', 'USD');

Adding more wallet types (e.g. merchant):

  1. Add a block under wallet_types in config/wallet-ledger.php with owner_required / allowed_codes as needed.
  2. Clear config cache if applicable: php artisan config:clear.

Adding more account kinds (e.g. escrow):

  1. Add an entry under account_kinds with the correct accounting_type (asset, liability, etc.) for how you want balances and reports to behave.
  2. Call ensureAccount($wallet, 'escrow', 'EUR', 'Optional display name').

Account codes are generated as {CURRENCY}:{account_kind} (currency is uppercased). One row per wallet + code combination.

Posting journals (core API)

Resolve WalletLedger\Contracts\LedgerContract and pass a JournalDraft with JournalLineDraft lines:

use WalletLedger\Contracts\LedgerContract;
use WalletLedger\DTO\JournalDraft;
use WalletLedger\DTO\JournalLineDraft;

$ledger = app(LedgerContract::class);

$journal = $ledger->post(new JournalDraft(
    lines: [
        new JournalLineDraft($debitAccountId, 'USD', debitMinor: 1000, creditMinor: 0),
        new JournalLineDraft($creditAccountId, 'USD', debitMinor: 0, creditMinor: 1000),
    ],
    idempotencyKey: 'optional_unique_key', // duplicate post returns same journal
    description: 'Transfer',
    referenceType: \App\Models\Order::class, // optional morph
    referenceId: 123,
    meta: ['source' => 'api'],
));

Rules enforced before insert:

  • At least one line; per currency, sum of debit_minor equals sum of credit_minor.
  • Each line: non-negative amounts, not both debit and credit positive, ISO 3-letter currency.
  • All account_id values must exist.

Idempotency: If idempotency_key is set and non-empty, a second post() with the same key returns the existing journal (with lines), without double-posting.

Events: WalletLedger\Events\JournalPosted carries the saved Journal (with relations as loaded).

Business transactions (optional)

  1. Publish and run transaction migrations (see above).
  2. Set features.business_transactions to true.

Create a WalletLedger\Models\LedgerTransaction with status = draft, a valid transaction_type, and related LedgerTransactionLine rows (balanced, same shape as journal lines). Then:

use WalletLedger\Contracts\TransactionServiceContract;

$posted = app(TransactionServiceContract::class)->post($transaction);

The service creates the journal, attaches journal_id to the transaction, sets posted_at and status = posted, and links each transaction line to its journal_line_id. Idempotency is supported on the transaction via idempotency_key when status is already posted.

Inspecting what is in the database

Table names below assume the default prefix wl_. If you changed table_prefix, substitute accordingly (or query config('wallet-ledger.table_prefix') in Tinker).

Laravel Tinker:

php artisan tinker
use WalletLedger\Models\Wallet;
use WalletLedger\Models\Account;
use WalletLedger\Models\Journal;
use WalletLedger\Models\JournalLine;

Wallet::with('accounts')->get();
Journal::with('lines.account.wallet')->latest('id')->take(5)->get();

Useful SQL (MySQL / SQLite style):

List wallets:

SELECT id, uuid, wallet_type, owner_type, owner_id, code, name
FROM wl_wallets
ORDER BY id;

List accounts with wallet:

SELECT a.id, a.code, a.name, a.account_kind, a.accounting_type,
       w.wallet_type, w.code AS wallet_code
FROM wl_accounts a
JOIN wl_wallets w ON w.id = a.wallet_id
ORDER BY w.id, a.id;

Recent journals with line count:

SELECT j.id, j.uuid, j.description, j.posted_at, COUNT(l.id) AS line_count
FROM wl_journals j
LEFT JOIN wl_journal_lines l ON l.journal_id = j.id
GROUP BY j.id
ORDER BY j.id DESC
LIMIT 20;

If business transactions are enabled:

SELECT id, uuid, transaction_type, status, journal_id, posted_at
FROM wl_transactions
ORDER BY id DESC;

Balances, trial balance, and “balance sheet” style views

The package does not compute running balances on write. You derive them by aggregating wl_journal_lines (and optionally filtering by posted_at or journal id for “as of” reports).

Trial balance (per account and currency) — raw debit and credit totals:

SELECT
    a.id AS account_id,
    a.code,
    a.name,
    a.accounting_type,
    jl.currency,
    SUM(jl.debit_minor) AS debit_minor,
    SUM(jl.credit_minor) AS credit_minor
FROM wl_journal_lines jl
JOIN wl_accounts a ON a.id = jl.account_id
GROUP BY a.id, a.code, a.name, a.accounting_type, jl.currency
ORDER BY a.accounting_type, a.code, jl.currency;

Signed balance in minor units depends on accounting_type (normal balance):

  • Asset (normal debit): debit_minor - credit_minor
  • Liability (normal credit): credit_minor - debit_minor

Example grouped by accounting side:

SELECT
    a.accounting_type,
    jl.currency,
    SUM(CASE WHEN a.accounting_type = 'asset'
        THEN jl.debit_minor - jl.credit_minor
        ELSE jl.credit_minor - jl.debit_minor
    END) AS balance_minor
FROM wl_journal_lines jl
JOIN wl_accounts a ON a.id = jl.account_id
GROUP BY a.accounting_type, jl.currency;

For a wallet-level “available USD” balance, filter accounts (e.g. account_kind = 'available') and restrict to the wallet’s account_id set, or join through wl_accounts.wallet_id.

Display tips:

  • Convert minor → major in the UI: amount_major = balance_minor / 10^exponent (often 2 for USD; use a money library or ISO currency metadata for exotic exponents).
  • For a traditional balance sheet, map your accounting_type values to line items (and split current vs non-current in your own metadata or naming convention—the package only stores the string accounting_type on accounts).
  • For multi-currency, never sum different currency values without FX logic; the ledger already isolates balances per currency.

Events

Event When
WalletLedger\Events\JournalPosted After a successful Ledger::post().
WalletLedger\Events\TransactionPosted After TransactionService::post() completes.

Subscribe in EventServiceProvider or use Laravel’s auto-discovery for listeners.

Testing (package developers)

Default composer test runs fast unit tests (no database).

composer test:integration

Integration tests expect MySQL and create a database named wallet_ledger_package_test unless overridden with WALLET_LEDGER_DB_* variables—see tests/bootstrap.php.

Summary

  1. composer require olaoluwasipe/multi-wallet-ledgerphp artisan migrate
  2. php artisan vendor:publish --tag=wallet-ledger-config → adjust wallet/account/transaction definitions
  3. Use WalletRegistry for wallets/accounts and LedgerContract for postings (or TransactionService when business transactions are enabled)
  4. Inspect data via Eloquent models or SQL on wl_* tables
  5. Build balance sheet and P&L views by aggregating journal_lines with your accounting_type and currency rules