PHP code example of otim-otim / bookkeeper

1. Go to this page and download the library: Download otim-otim/bookkeeper library. Choose the download type require.

2. Extract the ZIP file and open the index.php.

3. Add this code to the index.php.
    
        
<?php
require_once('vendor/autoload.php');

/* Start to develop here. Best regards https://php-download.com/ */

    

otim-otim / bookkeeper example snippets


BookKeeper::record([
    ['account' => '@bank',      'debit'  => '1180.00'],   // money in
    ['account' => '@sales',     'credit' => '1000.00'],   // income earned
    ['account' => '@vat_output','credit' => '180.00'],    // tax owed
]);

use OtimOtim\BookKeeper\Support\Money;

$a = Money::of('1500.50', 'USD');      // from a major-unit amount (string, int, float or Money)
$b = Money::ofMinor(150050, 'USD');    // from minor units
Money::ofRounded('10.005', 'USD');     // round excess decimals instead of rejecting them
Money::zero('UGX');

$a->plus($b)->minus('0.50');           // strings are major units, ints are minor units
$a->multipliedBy('1.5');               // rounded with the configured mode
$a->percent('18');                     // 18% of the amount
$a->allocate([1, 1, 1]);               // split without losing a cent
$a->convertTo('UGX', '3700');          // currency conversion
$a->amount();   // "1500.50"           (decimal string; also __toString and JSON)
$a->minor();    // 150050
$a->isPositive(); $a->isZero(); $a->equals($b); $a->compareTo($b);
Money::sum($list, 'USD');

$a->roundToCash();          // CHF 1.03 → 1.05, UGX 1,020 → 1,000 (smallest coin)
$a->format();               // "$1,500.50" (locale-aware, needs ext-intl)
$a->format('de_DE');        // "1.500,50 $"

BookKeeper::settings()->setBaseCurrency('TZS');   // allowed until the first posting
BookKeeper::settings()->baseCurrency();           // "TZS"
BookKeeper::settings()->isBaseCurrencyLocked();

'currencies' => [
    'KES' => ['cash_increment' => '1'],                // smallest coin
    'XBT' => ['decimals' => 8, 'name' => 'Bitcoin'],   // a non-ISO unit
],

BookKeeper::balance('1125');                       // base currency (UGX 560,000)
BookKeeper::account('1125')->currencyBalance();    // USD 150.00

BookKeeper::exchangeRates()->set('USD', '3700', '2026-03-01');
BookKeeper::exchangeRates()->convert(Money::of('100', 'USD'), 'KES');   // foreign → foreign via base
BookKeeper::exchangeRates()->crossRate('USD', 'KES');

class BankOfUgandaRates implements ExchangeRateProvider
{
    public function rates(string $base, array $currencies, CarbonInterface $date): array
    {
        // return ['USD' => '3712.50', 'KES' => '28.61'];   // base units per 1 unit
    }
}

use OtimOtim\BookKeeper\Data\{EntryData, LineData, DocumentData, DocumentLineData};

BookKeeper::record(
    [LineData::debit('@bank', Money::of('250.00')), LineData::credit('@sales', '250.00', memo: 'Consulting')],
    new EntryData(memo: 'March retainer', reference: 'INV-88', idempotencyKey: 'retainer:2026-03'),
);

BookKeeper::documents()->createFrom(new DocumentData(
    type: DocumentType::Invoice,
    contact: $customer,
    lines: [new DocumentLineData('@sales', '40.00', quantity: '3', tax: 'VAT')],
    cashRounding: true,
));

$entry = new EntryData(memo: 'Original');
$entry->with(memo: 'Changed');      // DTOs are immutable; with() returns a copy

use OtimOtim\BookKeeper\Facades\BookKeeper;
use OtimOtim\BookKeeper\Enums\AccountType;
use OtimOtim\BookKeeper\Enums\AccountSubtype;

BookKeeper::createAccount('1000', 'Current assets', AccountType::Asset, ['is_group' => true]);
BookKeeper::createAccount('1010', 'Cash', AccountSubtype::Cash, ['parent' => '1000', 'role' => 'cash', 'tags' => ['liquid']]);
BookKeeper::createAccount('1200', 'Receivables', AccountSubtype::Receivable, ['role' => 'accounts_receivable', 'is_control' => true]);

BookKeeper::chart()->import('sacco-ug');   // import a template (safe to repeat)
BookKeeper::accounts()->tree();             // nested accounts
BookKeeper::account('@cash')->ancestors();  // path from the root
BookKeeper::balance('1000');                // groups roll up their children

// Record and post
$entry = BookKeeper::record([
    ['account' => '@cash',  'debit'  => '1500.00', 'memo' => 'Cash received'],
    ['account' => '@sales', 'credit' => '1500.00'],
], [
    'date'            => '2026-03-01',
    'memo'            => 'Cash sale',
    'reference'       => 'INV-001',
    'branch_id'       => 3,
    'source'          => $order,               // any Eloquent model, for drill-down
    'idempotency_key' => "order:{$order->id}", // safe to retry
]);

$entry->number;      // JE-2026-000001 (gap-free, assigned when posted)

BookKeeper::transfer('@bank', '@cash', 500);                 // one debit, one credit
BookKeeper::reverse($entry, ['memo' => 'Entered in error']); // the only way to correct
BookKeeper::draft($lines, $options);                         // save without posting
BookKeeper::ledger()->post($draft);
BookKeeper::openingBalances('2026-01-01', ['1010' => 5000, '2100' => 1200]);

// Accruals that reverse themselves (processed at end of day)
BookKeeper::record($lines, ['date' => '2026-03-31', 'reverse_on' => '2026-04-01']);

// Foreign currency: converted at the line rate or the stored rate for the date
BookKeeper::exchangeRates()->set('USD', '3700', '2026-03-01');
BookKeeper::record([
    ['account' => '@bank_usd', 'debit' => '100.00', 'currency' => 'USD'],
    ['account' => '@sales', 'credit' => 370000],
]);

// config/bookkeeper.php
'postings' => [
    'savings.deposit' => [
        'memo' => 'Savings deposit',
        'lines' => [
            ['debit'  => ':till',          'amount' => 'amount'],                       // account from the context
            ['credit' => 'member_savings', 'amount' => 'amount', 'subledger' => 'account'],
        ],
    ],
    'loan.disbursed' => [
        'lines' => [
            ['debit'  => 'loan_portfolio',  'amount' => 'principal', 'subledger' => 'loan'],
            ['credit' => 'member_savings',  'amount' => 'principal - fee', 'subledger' => 'account'],
            ['credit' => 'loan_fee_income', 'amount' => 'fee'],
        ],
    ],
],

BookKeeper::post('savings.deposit', ['amount' => 50000], ['till' => $till->account, 'account' => $savingsAccount], [
    'idempotency_key' => $request->idempotencyKey(),
    'branch_id' => $branch->id,
]);

BookKeeper::subledgers()->balance('@member_savings', $account);                 // one party
BookKeeper::subledgers()->listing('@member_savings');                           // every party
BookKeeper::subledgers()->statement('@member_savings', $account, $from, $to);   // running balance
BookKeeper::subledgers()->reconcile('@loan_portfolio');                         // control = Σ parties?
BookKeeper::subledgers()->averageBalances('@share_capital', $from, $to);        // day-weighted averages

BookKeeper::periods()->createFiscalYear(2027);
BookKeeper::periods()->close('2026-06-01');      // lock June; postings now throw PeriodClosedException
BookKeeper::periods()->reopen('2026-06-01');

BookKeeper::close()->closeDay('2026-06-30');     // tasks, scheduled reversals, balance snapshot
BookKeeper::close()->closeMonth('2026-06-30');   // tasks, FX revaluation, depreciation, lock the period
BookKeeper::close()->closeYear(2026);            // close open months, closing entry → retained earnings, open next year
BookKeeper::close()->reopenYear(2026);

use OtimOtim\BookKeeper\Contracts\CloseTask;
use OtimOtim\BookKeeper\Close\CloseContext;

class AccrueSavingsInterest implements CloseTask
{
    public function handle(CloseContext $context): void
    {
        // ... compute interest, then post it idempotently:
        BookKeeper::post('savings.interest_accrued', [...], [...], [
            'date' => $context->date,
            'idempotency_key' => $context->key('savings-interest'),
        ]);
    }
}

// config/bookkeeper.php
'close' => ['day' => [AccrueSavingsInterest::class], 'month' => [...], 'year' => [...]],

Schedule::command('bookkeeper:close-day')->dailyAt('23:55');
Schedule::command('bookkeeper:close-month')->monthlyOn(1, '01:00');
Schedule::command('bookkeeper:rates')->dailyAt('06:00');

$customer = BookKeeper::contacts()->customer('Kampala Traders', ['payment_terms_days' => 30, 'tax_number' => '1000123456']);
$customer = BookKeeper::contacts()->for($member);  // or link to any of your models

$invoice = BookKeeper::invoice($customer, [
    ['account' => '@sales', 'description' => 'Widgets', 'quantity' => 10, 'unit_price' => '50.00', 'tax' => 'VAT'],
    ['account' => '@service_income', 'amount' => '100.00', 'discount' => '10.00'],
], ['issue_date' => '2026-04-01', 'tax_inclusive' => false]);

BookKeeper::receivePayment($customer, '400.00', '@bank', ['allocations' => [$invoice]]);
BookKeeper::documents()->creditNote($customer, $lines);
BookKeeper::documents()->applyCredit($creditNote, $invoice);

$bill = BookKeeper::bill($vendor, [['account' => '@office_expenses', 'amount' => '1000.00', 'tax' => 'VAT-IN']]);
BookKeeper::makePayment($vendor, '1180.00', '@bank', ['allocations' => [$bill], 'withholding' => 'WHT']);

BookKeeper::payments()->allocate($payment, $laterInvoice);  // use an on-account payment
BookKeeper::documents()->void($invoice);                    // reverses its entry
BookKeeper::payments()->void($payment);

BookKeeper::aging()->receivables('2026-04-30');  // current, 1-30, 31-60, 61-90, over_90
BookKeeper::aging()->payables();

BookKeeper::taxes()->create('VAT', 'VAT 18%', 'sales', '18', ['account' => '@vat_output']);
BookKeeper::taxes()->create('VAT-IN', 'Input VAT', 'purchase', '18', ['account' => '@vat_input']);
BookKeeper::taxes()->create('WHT', 'Withholding tax 6%', 'withholding', '6', ['account' => '@withholding_tax']);

// Rate change: a new version of the same code; the old one ends the day before
BookKeeper::taxes()->create('VAT', 'VAT 20%', 'sales', '20', ['account' => '@vat_output', 'effective_from' => '2027-07-01']);

BookKeeper::taxes()->calculate(Money::of('118.00'), 'VAT', inclusive: true);  // net, tax, gross
BookKeeper::taxes()->report('2026-04-01', '2026-04-30');                  // output, input, net payable, withheld

$asset = BookKeeper::assets()->register([
    'code' => 'FA-001', 'name' => 'Laptop', 'cost' => 3600000,
    'useful_life_months' => 36,          // or 'method' => 'declining_balance', 'declining_rate' => 25
    'asset_account' => '1630', 'acquired_on' => '2026-01-10',
    'paid_from' => '@bank',              // optional: posts the purchase
]);

BookKeeper::assets()->depreciate('2026-03-31');   // one entry per month, idempotent; also run by month end
BookKeeper::assets()->schedule($asset);
BookKeeper::assets()->dispose($asset, '2026-04-15', ['proceeds' => 3000000, 'proceeds_account' => '@cash']);

$budget = BookKeeper::budgets()->create('2026 budget', 2026);
BookKeeper::budgets()->setAnnual($budget, '@salaries', 1200000);        // spread evenly over months
BookKeeper::budgets()->set($budget, '@rent', '2026-03-01', 150000);    // one month
BookKeeper::budgets()->approve($budget);
BookKeeper::budgets()->report($budget, '2026-01-01', '2026-03-31');    // budget, actual, variance, favourable

$vault = BookKeeper::tills()->create('VAULT', 'Main vault', ['account' => '@cash', 'type' => 'vault']);
$till  = BookKeeper::tills()->create('T1', 'Teller 1');   // creates its own cash account under @cash_on_hand

$session = BookKeeper::tills()->open($till, $teller);
BookKeeper::tills()->transfer($vault, $till, 500000);
BookKeeper::tills()->assertOpen($till);                   // call before teller postings
BookKeeper::tills()->close($session, counted: 615000);    // posts any shortage or overage to @cash_over_short

$statement = BookKeeper::bank()->import('@mtn_momo', [
    'start_date' => '2026-01-01', 'end_date' => '2026-01-31',
    'opening_balance' => 0, 'closing_balance' => 1048500,
], $lines);                                         // [date, amount (+in/-out), description, reference]

BookKeeper::bank()->autoMatch($statement);          // same amount, ±3 days, reference preferred
BookKeeper::bank()->match($statementLine, $journalLine);
BookKeeper::bank()->adjust($chargesLine, '@mobile_money_charges');  // book bank-only items
BookKeeper::bank()->summary($statement);            // outstanding items, difference
BookKeeper::bank()->reconcile($statement);

// After the year is closed and the AGM approves:
BookKeeper::appropriation()->appropriate(2026, ['rules' => [
    ['name' => 'Statutory reserve', 'credit' => 'statutory_reserve', 'percent' => '10', 'base' => 'net_surplus'],
    ['name' => 'Share transfer fund', 'credit' => 'share_transfer_fund', 'percent' => '5', 'base' => 'remaining'],
    ['name' => 'Stabilisation levy', 'credit' => 'stabilisation_levy_payable', 'percent' => '0.5', 'base' => 'average_total_assets'],
]]);

// Dividends on time-weighted share balances, with withholding tax
$calc = BookKeeper::dividends()->calculate('@share_capital', '12', '2026-01-01', '2026-12-31', ['withholding' => 'WHT-DIV']);
BookKeeper::dividends()->pay($calc, [
    'credit' => fn ($type, $id) => ['account' => '@member_savings', 'subledger' => SavingsAccount::primaryFor($id)],
]);

BookKeeper::trialBalance('2026-12-31');
BookKeeper::balanceSheet('2026-12-31', ['branch' => 3]);   // tree rows, current earnings, classification
BookKeeper::incomeStatement('2026-01-01', '2026-12-31');   // excludes closing entries
BookKeeper::cashFlow('2026-01-01', '2026-12-31');          // indirect method, reconciles to cash
BookKeeper::changesInEquity('2026-01-01', '2026-12-31');
BookKeeper::generalLedger('@bank', '2026-03-01', '2026-03-31');   // opening, running balance
BookKeeper::statements()->balanceByTag('liquid');                 // prudential ratios

beforeEach(function () {
    BookKeeper::chart()->import('sacco-ug');
});

it('posts a deposit to the member subledger', function () {
    $account = SavingsAccount::factory()->create();

    app(SavingsService::class)->deposit($account, 50000, idempotencyKey: 'test-1');

    expect(BookKeeper::subledgers()->balance('@member_savings', $account)->minor())->toBe(50000)
        ->and(BookKeeper::trialBalance()['balanced'])->toBeTrue();
});
bash
php artisan vendor:publish --tag=bookkeeper-tenant-migrations   # → database/migrations/tenant