Search by

alimarchal / laravel-chart-of-accounts

alimarchal

Laravel enterprise accounting package: chart of accounts, double-entry journals, multi-company, AR/AP, payroll, tax, WhatsApp, with React, Blade and API

Package info

github.com/alimarchal/laravel-chart-of-accounts-package

pkg:composer/alimarchal/laravel-chart-of-accounts

Statistics

Installs: 78

Dependents: 0

Suggesters: 0

Stars: 5

Open Issues: 0

v2.42.1 2026-10-24 10:42 UTC

This package is auto-updated.

Last update: 2026-10-24 10:42:58 UTC


README

Latest Version on Packagist Total Downloads License PHP Version

Chart of Accounts and double-entry accounting module for Laravel. One-command install. Works with Jetstream (Blade/Livewire) and Breeze (Inertia/React).

Author: Ali Raza Marchal — kh.marchal@gmail.com Package: alimarchal/laravel-chart-of-accounts Source: github.com/alimarchal/laravel-chart-of-accounts-package

Help & guides: Help catalog — every screen and API endpoint with its permission · اردو استعمال گائیڈ (Urdu user guide with screenshots, right-to-left) · OpenAPI · Postman · UAT checklist

Highlights

  • Double-entry journal — draft → posted → reversed / void, balanced in exact cents
  • Branches — head office, campuses or shops inside one company: a working-branch switcher, entries, invoices and payments booked to a branch, branch-wise reports, and users limited to their branches
  • Multi-company — any number of companies in one database, each with its own chart, periods, journal and reports; per-user access, a company switcher, and consolidated group reports
  • Tamper-proof ledger — posted entries are immutable at the database layer (triggers on MySQL/MariaDB, PostgreSQL, SQLite), the chart-of-accounts rules (valid tree, locked used accounts, same-company lines, no posting to group accounts) are enforced there too, and every change is written to an audit log
  • Maker-checker approvals — entries above a threshold need a second person to approve; nobody approves their own work
  • Roles with segregation of duties — super-admin, admin, accountant (maker), approver (checker), auditor, viewer; accounting:roles audits the matrix
  • Multi-currency — every line keeps its frozen base-currency amount; all reports are in the base currency
  • Voucher numbering — JV, CPV, CRV, BPV, BRV (and your own types) with gapless numbers such as JV-2026-00012, issued at posting, restarting per fiscal year or month, locked in the database
  • Source documents — every entry can name the invoice, bill or receipt it records (and link to your own Invoice / Bill model); a document can be posted only once, enforced by the database
  • Attachments — scanned bills, receipts and contracts on every entry, stored privately, never removable once posted, duplicate-file warning, optional "evidence required above" amount
  • Recurring entries — rent, subscriptions, depreciation and accruals on a daily-to-yearly schedule: generated as drafts or posted automatically, with catch-up, pause/resume, run-now and a full history
  • ERPNext standard chart on a fresh install — a new company starts with ERPNext's chart of accounts and its account numbers (1110 Cash, 1310 Debtors, 2110 Creditors, 4110 Sales …), already wired to the modules through account roles; pick the package's own general or the school chart instead with --chart=
  • Account roles — which account is "cash", "bank", "salary expense", "receivables in the ageing" … is a setting per company (screen, API, accounting:account-roles), not a code hard-wired in the program
  • Filters and search on every list — one filter language (?filter[search]=…, dates, amounts, status, sorting) on every screen, React and Blade, and every API list, built on Spatie Laravel Query Builder; reports and their exports use the same dates
  • Industry chart templates — trading, manufacturing, services, school, NGO and healthcare charts: start a company from one, or add an industry's accounts to the chart you have (existing accounts are never changed); add your own in config
  • Chart import & export — bring your existing chart in from Excel or CSV with a line-by-line preview (new / updated / errors) before anything is saved; export it, edit it, import it back
  • Renumber & merge accounts — give an account (and its sub-accounts) new codes, or fold a duplicate into another account with a posted transfer entry — the ledger history is never rewritten
  • Control accounts — receivables, payables, inventory, payroll and tax control accounts that only their module may post to, with a manual-postings exception list
  • Professional PDFs — reports on your letterhead (logo, filters, totals, page X of Y) and printable vouchers with amount in words, signature boxes and a DRAFT/VOID watermark; big Excel/PDF exports run in the background
  • Month-end & year-end close — a close workspace with a checklist (drafts, approvals, trial balance, bank reconciliation, earlier periods), a closing-entry preview, monthly periods, and audited reopening
  • Financial statements by report lines — map accounts to statement lines (Revenue, Cost of sales, Trade receivables, PPE …); balance sheet and income statement with gross / operating / net profit and comparatives; indirect-method cash flow that always reconciles to cash
  • 12 reports — trial balance, balance sheet, income statement, cash flow, general ledger, account statement (running balance), bank & cash book, aged AR/AP — export to CSV (streamed, any size), XLSX, PDF
  • REST API (OpenAPI 3.1 + Postman), React (Inertia) and Blade/Livewire UIs — or API only
  • Events & signed webhooks for every ledger action (posted, reversed, approved, period closed, …)
  • Proven at scale — 400,000 journal lines: trial balance 0.77 s, account statement 0.1 s, 46 concurrent postings/s with zero imbalance (performance report)
  • Payroll — employees, grades, quantity x rate pay, arrears, attendance and leave, loans, EOBI/PESSI/PF, bank file, payslip PDF/e-mail, settlements, tax certificates, HR-to-finance approval, bonuses
  • Feature switches — turn any module or payroll feature off and on from a screen, the API or php artisan accounting:features; menus and routes follow, data stays
  • Help catalog and Urdu guide — every screen and endpoint listed, plus a right-to-left Urdu manual with screenshots of every screen
  • Tested — 590+ Pest tests in CI on PHP 8.2–8.4, Laravel 11–13 (+ Laravel 14 dev), SQLite/MySQL/MariaDB/PostgreSQL; Larastan level 5

How it works (visual guide)

Every diagram is generated from the Mermaid sources in docs/diagrams/ (SVG + PNG in docs/images/).

1. Architecture — who calls what

Architecture

2. Installation flow

Installation flow

3. Journal entry lifecycle

Journal entry lifecycle

4. What happens when an entry is posted

Posting checks

* The control-account check is skipped for reversals and year-end closing entries, which follow their source entry.

5. Month-end and year-end close

Period close

6. An API request, step by step

API request

7. Maker-checker approval

Maker-checker

8. Multi-company: which company a request works in

Multi-company

Requirements

Supported
PHP 8.2, 8.3, 8.4 (Laravel 13 needs ≥ 8.3)
Laravel 11, 12, 13 — Laravel 14 (expected Q1 2027, PHP ≥ 8.4) is tracked in CI against its dev branch and will be supported on release
Database MySQL 8 · MariaDB 10.6+ · PostgreSQL 13+ · SQLite 3.35+
React UI Laravel React starter kit — Inertia 3 (current kit) or Inertia 2 (small app.tsx addition, see Installation)
Blade UI Livewire 3 or 4
API auth Laravel Sanctum (or any guard via ACCOUNTING_API_MIDDLEWARE)

Installation (5 minutes)

1. Install the package

composer require alimarchal/laravel-chart-of-accounts
php artisan install:api            # Sanctum, for the REST API (skip if already installed)

2. Add the Spatie trait to app/Models/User.php (and HasApiTokens if you use the REST API)

use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasFactory, HasRoles, Notifiable;
}

3. Run the installer, and answer its questions

php artisan accounting:install

The installer asks what it needs, so there is nothing to edit by hand afterwards:

Question What it does
Which screens do you want? Livewire starter kit (Flux), Blade (Jetstream / Breeze), React (Inertia) or REST API only (it suggests the one that matches your starter kit) → ACCOUNTING_UI_DRIVER and ACCOUNTING_BLADE_KIT
Base currency ACCOUNTING_BASE_CURRENCY (PKR, USD, …)
Chart of accounts ERPNext standard chart, general business chart or school chart → ACCOUNTING_CHART_PRESET
Several companies? ACCOUNTING_MULTI_COMPANY and the short code of your first company → ACCOUNTING_DEFAULT_COMPANY
Approval by a second person? maker-checker, its threshold and whether one may approve their own entry → ACCOUNTING_APPROVALS_ENABLED, ACCOUNTING_APPROVAL_THRESHOLD, ACCOUNTING_ALLOW_SELF_APPROVAL
URL of the screens default accounting; the starter kit's own URLs (settings, dashboard, login, …) are refused → ACCOUNTING_ROUTE_PREFIX
Email, name and password of the administrator the user is created when it does not exist, verified, and given super-admin

Then one yes/no question per topic, and a "yes" asks that topic's settings (each shows the current or default value; an empty answer leaves the package default): REST API (prefix, rate limit, page size), WhatsApp (sandbox or live, token, phone number ID, business account ID, app secret, verify token, country code, language, receipt template, queue), signed webhooks (URLs, secret, attempts, timeout), payroll (days per month, approval, automatic run, overtime, gratuity, tax year), budgets and bank statements, PDF, exports and attachments, language and modules you will not use (switched off through ACCOUNTING_FEATURES_DISABLED). Secrets (verify token, webhook secret) can be generated for you. --advanced asks every topic without the yes/no. Every key the package reads is covered by these questions or the ones above; .env keys that are not asked keep their documented defaults.

Your answers are written to .env. Then the installer publishes the config and migrations, migrates, seeds the chart of accounts, currencies, the current fiscal year, roles and tax codes, creates the database views and triggers, and verifies everything. It adds the Accounting menu (one entry that opens into sections, settings and help included) next to Dashboard in the menu of your starter kit (Livewire/Flux: resources/views/layouts/app/sidebar.blade.php; Jetstream: resources/views/navigation-menu.blade.php; Breeze: resources/views/layouts/navigation.blade.php; React: resources/js/components/app-sidebar.tsx and app-header.tsx) and, for Blade, adds the package's screens to the Tailwind sources (resources/css/app.css for Tailwind 4, tailwind.config.js for Tailwind 3 as in Jetstream and Breeze; run npm run build once). It is safe to re-run.

Everything can be given as an option instead (no questions): --ui=livewire|blade|inertia|api, --currency=PKR, --chart=erpnext|general|school, --prefix=accounting, --admin-email=… --admin-name=… --admin-password=…; --no-menu keeps your sidebar untouched, --no-env leaves .env alone, and --setup-only asks the questions, writes .env, creates the administrator and adds the menu without publishing, migrating or seeding. The starter kits of Vue and Svelte are supported through the Blade screens (the installer tells you which plain link to add to your sidebar).

Log in with the administrator you entered (the login page is your starter kit's) and open Accounting in the sidebar, or /accounting.

Your UI (the installer sets it in .env):

ACCOUNTING_UI_DRIVER You get Notes
inertia (default) React pages in resources/js/pages/accounting React starter kit; run npm run build afterwards
blade Blade pages inside your layout Livewire starter kit, Jetstream, Breeze (also Vue and Svelte kits)
api REST API only Lightest: no web routes, views or Livewire

The installer offers three kinds of screens: React, Blade for Jetstream / Breeze and Livewire for the Laravel Livewire starter kit (Flux). The two Blade kinds are the same pages (ACCOUNTING_UI_DRIVER=blade); ACCOUNTING_BLADE_KIT tells them which shell to look native in:

ACCOUNTING_BLADE_KIT For Look
auto (default) whatever your application has Jetstream, else Flux (Livewire kit), else Breeze
flux Livewire starter kit Flux style: flat bordered cards, zinc colours, buttons as in Flux, dark mode with the kit's .dark switch
jetstream Laravel Jetstream Jetstream cards, dark mode follows the system setting
breeze Laravel Breeze Breeze cards, dark mode with the .dark class
plain none the package's own page layout

Matches the official starter kits (laravel.com/docs/starter-kits): Livewire (Flux, sidebar or header layout, also with teams or WorkOS) gets the Flux look; React gets the React pages and the sidebar/header link; Vue and Svelte have no Blade layout, so the Blade screens use the package's own page layout and you add one plain <a href="/accounting"> link (not an Inertia Link); Jetstream and Breeze (no longer developed by Laravel, still widely used) are supported as described above. In the Flux sidebar layout the pages use the full width next to the sidebar.

The screens are rendered in your application's layout: Jetstream and Breeze <x-app-layout>, the Livewire kit its livewire.component_layout (layouts::app), so the sidebar stays; anything else gets the package's own layout. Scripts and styles that the screens need (the filter button, the select boxes) are printed for you when your layout has no @stack('scripts'), @stack('styles') or @stack('modals'). To name your own layout component: ACCOUNTING_BLADE_LAYOUT=layouts::app.

What it looks like in each kit (a real accounting:install on a fresh Laravel Livewire starter kit and on Jetstream; the screenshots are of the Blade screens):

Livewire starter kit (Flux) Jetstream
Journal entries in the Livewire starter kit: the Accounting link in the sidebar, Flux-style buttons and cards Journal entries in Jetstream: the Accounting link in the top navigation
The filters of a list, opened with the filter button, in the Livewire starter kit The filters of a list, opened with the filter button, in Jetstream
Working in one branch: the Branch switcher above the page limits the list The branches screen in Jetstream
Filters: type a value and press Apply filters (Livewire 4 kit) The list after the filter
The filter panel opened with a value typed in, in the Livewire 4 starter kit The list narrowed to the entries that match, in the Livewire 4 starter kit
A confirm dialog instead of the browser's own (Jetstream) The same in React
Delete asks in a modal: Cancel or Delete Delete asks in a modal in React

Checked with a browser in three real apps (the Laravel Livewire starter kit of Livewire 3 and the current one of Livewire 4, and Jetstream), each after a real accounting:install: every one of the 89 list, report and form pages opens without errors, every filter button opens its filters and applies them, the "add" forms open and refuse an empty submit, a journal entry can be created from the form, a branch can be created, edited and filtered, and the branch switcher limits lists and reports. The React screens of the branches were checked the same way.

React: the menu link by hand and (older starter kits) the layout

The installer adds this itself; to do it by hand add Accounting to resources/js/components/app-sidebar.tsx:

import { Calculator } from 'lucide-react';

const mainNavItems: NavItem[] = [
    { title: 'Dashboard', href: dashboard(), icon: LayoutGrid },
    { title: 'Accounting', href: '/accounting', icon: Calculator },
];

The pages declare their breadcrumbs with Page.layout = { breadcrumbs }. The current starter kit (Inertia 3) wraps them in the app layout automatically. Starter kits on Inertia 2 need this resolve in resources/js/app.tsx (tested with the February 2025 kit):

import type { ReactNode } from 'react';
import AppLayout from './layouts/app-layout';
import type { BreadcrumbItem } from './types';

createInertiaApp({
    resolve: (name) =>
        resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')).then((module) => {
            const page = (module as { default: { layout?: unknown } }).default;
            const options = page.layout as { breadcrumbs?: BreadcrumbItem[] } | undefined;

            if (name.startsWith('accounting/') && typeof options !== 'function') {
                page.layout = (child: ReactNode) => <AppLayout breadcrumbs={options?.breadcrumbs}>{child}</AppLayout>;
            }

            return module;
        }),
    // ...
});
Optional settings
ACCOUNTING_BASE_CURRENCY=USD            # default PKR; set before installing
ACCOUNTING_CHART_PRESET=erpnext         # erpnext (default), general or school
ACCOUNTING_ROUTE_PREFIX=accounting      # URL prefix for the web UI
ACCOUNTING_MULTI_COMPANY=true           # several companies (see below)
ACCOUNTING_APPROVALS_ENABLED=true       # maker-checker (see below)
ACCOUNTING_APPROVAL_THRESHOLD=100000    # in base currency; 0 = every entry
ACCOUNTING_WEBHOOK_URLS=https://erp.example.com/hooks/accounting
ACCOUNTING_WEBHOOK_SECRET=change-me

API quick start

# A token for an existing user (or issue tokens from your own login endpoint)
php artisan tinker --execute="echo App\Models\User::first()->createToken('erp')->plainTextToken;"
TOKEN=1|xxxxxxxx
API=http://localhost:8000/api/v1/accounting
H=(-H "Accept: application/json" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json")

# Is everything installed?
curl "${H[@]}" $API/health

# Easiest possible entry: debit one account, credit another (posts immediately)
curl "${H[@]}" -X POST $API/journal-entries/simple \
  -H "Idempotency-Key: rent-2026-10" \
  -d '{"debit_account_code":"5102","credit_account_code":"1101","amount":2500,"description":"Office rent"}'

# Multi-line entry using account codes — no ids needed
curl "${H[@]}" -X POST $API/journal-entries -d '{
  "entry_date": "2026-10-04", "reference": "INV-1001", "auto_post": true,
  "lines": [
    {"account_code": "1103", "debit": 1500, "credit": 0, "cost_center_code": "ADMIN"},
    {"account_code": "4101", "debit": 0, "credit": 1500}
  ]}'

# Balances and reports
curl "${H[@]}" "$API/chart-of-accounts/tree"
curl "${H[@]}" "$API/reports/trial-balance"
curl "${H[@]}" "$API/reports/income-statement?date_from=2026-01-01&date_to=2026-12-31"
curl "${H[@]}" "$API/reports/balance-sheet?as_of_date=2026-12-31"
  • Full reference: docs/openapi.yaml (OpenAPI 3.1 — open it in Swagger UI, Redoc, Stoplight, or import into Insomnia).
  • Postman: import docs/postman_collection.json, set the host and token variables, send.
  • Retries are safe: send an Idempotency-Key header when creating entries — a retry returns the original entry (200, Idempotent-Replayed: true) instead of posting twice.
  • Errors are predictable: 401 no token · 403 missing permission · 404 not found · 422 validation (errors) or accounting rule (message) · 429 rate limit.

Upgrading

2.42 — one migration per table. The migrations were rewritten: there is now one create_…_table migration for each table (68 files, in dependency order, no add_… migrations) and the database objects (views and triggers) are created by the last one. The resulting schema is the same, but the file names changed, so an installation that already ran the old migrations should be rebuilt (php artisan migrate:fresh on a development database, or export and re-import the data); this package is still in development, so no upgrade path is provided.

After composer update alimarchal/laravel-chart-of-accounts:

php artisan accounting:update

Refreshes package-owned files (React pages / Select2 assets), runs new migrations (--force), re-syncs the database views and triggers, and adds roles/permissions introduced by the new version (your changes to existing roles are kept). It never overwrites your config/accounting.php or customised views (--views re-publishes Blade views explicitly).

Upgrading to 2.34. The default chart of a new installation is now the ERPNext chart (ACCOUNTING_CHART_PRESET=erpnext); companies that already have a chart are not touched and keep the account codes they used (cash 1101, receivables 1103 …). The fixed account codes in config/accounting.php (defaults.*, aging.*) are now optional overrides — if your published config still has them, they keep winning and nothing changes. Lists accept the shared filters; an unknown filter[...] now answers 400 instead of being ignored by the lists that used to read their own parameters.

Route Configuration

By default the package uses the /accounting/ URL prefix. To change it:

# .env
ACCOUNTING_ROUTE_PREFIX=settings        # URLs: /settings/journal-entries, /settings/reports/...
ACCOUNTING_ROUTE_NAME_PREFIX=accounting # Route names stay: accounting.dashboard, accounting.journal-entries.index

Route names stay as accounting.* regardless of the URL prefix, so views and redirects work without changes.

Settings-style routes example (used in our demo):

URL Route Name
/settings accounting.dashboard
/settings/journal-entries accounting.journal-entries.index
/settings/reports/general-ledger accounting.reports.general-ledger
/settings/chart-of-accounts accounting.chart-of-accounts.index
/settings/periods accounting.periods.index
/settings/users settings.users.index
/settings/roles settings.roles.index
/settings/permissions settings.permissions.index

Creating Journal Entries — Three Ways

1. Static Helper — JournalEntry::record()

The fastest way to create a balanced GL entry programmatically:

use Alimarchal\LaravelChartOfAccounts\Models\JournalEntry;

// Create a draft entry
$entry = JournalEntry::record(
    description: 'Office rent payment',
    debitAccountCode: '5102',   // Rent Expense
    creditAccountCode: '1101',  // Cash In Hand
    amount: '150000.00',        // strings avoid float rounding
    post: false,                // save as draft
);

// Create and post immediately
$entry = JournalEntry::record(
    description: 'Salary payment — June 2026',
    debitAccountCode: '5101',   // Salary Expense
    creditAccountCode: '1108',  // Operating Bank Account
    amount: 500000,
    post: true,                 // post immediately
    reference: 'SAL-2026-06',
);

echo $entry->status;      // 'posted'
echo $entry->reference;   // 'SAL-2026-06'
echo $entry->id;          // auto-assigned ID

Parameters:

Parameter Type Required Description
description string Yes Human-readable transaction description
debitAccountCode string Yes Account code for the debit line (e.g. '5101')
creditAccountCode string Yes Account code for the credit line (e.g. '1101')
amount float|int|string Yes Amount — same value debited AND credited (rounded to cents)
post bool No true = post immediately, false = draft (default)
reference string|null No Optional voucher/invoice reference number

What it does automatically:

  • Resolves account codes to IDs via ChartOfAccount
  • Finds active base currency
  • Finds currently open accounting period
  • Creates entry header + two perfectly balanced lines
  • Optionally posts via JournalEntryService::post()
  • Wraps in a DB transaction

Returns: Fresh JournalEntry model. Throws: ModelNotFoundException if an account code or an open period for today is not found; AccountingException if posting violates a rule (unbalanced, group/inactive account, closed period).

JournalEntry::record() posts as the current user without a permission check — it is a server-side API. Authorize the caller yourself (e.g. Gate::authorize('journal-entries.post')) when exposing it to users.

2. REST API

Create a draft entry:

POST /api/v1/accounting/journal-entries
Authorization: Bearer {token}
Content-Type: application/json

{
  "entry_date": "2026-06-04",
  "accounting_period_id": 1,
  "currency_id": 1,
  "fx_rate_to_base": 1,
  "reference": "SAL-2026-06",
  "description": "Salary payment June 2026",
  "lines": [
    { "chart_of_account_id": 42, "debit": 500000, "credit": 0, "description": "Salaries Expense" },
    { "chart_of_account_id": 11, "debit": 0, "credit": 500000, "description": "Cash" }
  ]
}

Post entry:

POST /api/v1/accounting/journal-entries/{id}/post
Authorization: Bearer {token}

Reverse entry (reversal_date optional, defaults to today, cannot precede the original):

POST /api/v1/accounting/journal-entries/{id}/reverse
Authorization: Bearer {token}
Content-Type: application/json

{ "description": "Reversal of SAL-2026-06", "reversal_date": "2026-06-30" }

auto_post: true on create/update additionally requires the journal-entries.post permission.

Errors: validation failures return 422 with errors; accounting rule violations (unbalanced entry, closed period, already reversed, account in use, …) return 422 with a message.

Void entry:

POST /api/v1/accounting/journal-entries/{id}/void
Authorization: Bearer {token}

List with filters:

GET /api/v1/accounting/journal-entries?filter[status]=posted&filter[entry_date_from]=2026-06-01&sort=-entry_date

3. Web UI (Blade/Livewire)

Visit /settings/journal-entries/create (or /accounting/journal-entries/create).

UI features:

  • All account and cost center dropdowns use Select2 with search
  • Real-time balance status badge (green = balanced, red = not balanced)
  • Save Draft button is disabled until debits = credits — prevents unbalanced saves
  • Live debit/credit totals update as you type

Recurring entries

Dashboard → Recurring Entries (React and Blade) or /api/v1/accounting/recurring-entries. A template holds a balanced set of lines and a schedule; the scheduler turns it into journal entries.

  • Schedule: daily, weekly, monthly, quarterly or yearly, every N periods, from a first date, optionally until an end date or after N entries. Monthly dates keep their day and are clamped in short months (the 31st becomes the 28th in February, then returns to the 31st).
  • Each entry is created as a draft, or posted automatically. Posting runs as the template's creator: if they may post, it posts; under maker-checker it is submitted for approval; a closed period, a control account or a creator without posting rights leaves it a draft with the reason on the run. The schedule moves on either way.
  • Runs are logged (one per scheduled date, enforced by a unique index, so two schedulers cannot duplicate an occurrence). An entry that cannot even be created stops the template at that date and is retried next time.
  • Catch-up: if the scheduler was off, the missed occurrences are generated (dated on their scheduled days), up to ACCOUNTING_RECURRING_MAX_CATCH_UP (12) per run. A paused template skips what it missed when resumed (or generates it).
  • Generate now makes the next occurrence immediately; pause / resume; templates that have generated entries are paused, not deleted. Amounts are in the base currency.
php artisan accounting:run-recurring              # generate what is due (every company)
php artisan accounting:run-recurring --dry-run    # list what is due
php artisan accounting:run-recurring --date=2026-12-31 --company=SUB

The package schedules the command daily at ACCOUNTING_RECURRING_TIME (02:00) — your app needs the Laravel scheduler (* * * * * php artisan schedule:run); ACCOUNTING_RECURRING_SCHEDULE=false leaves scheduling to you. Permissions recurring-entries.view / create / update / delete / run (accountant: all; approver, auditor, viewer: view).

Customers, suppliers, invoices and bills

Dashboard → Customers & Suppliers, Invoices & Bills, Receipts & Payments (React and Blade) or /api/v1/accounting/parties, /party-documents, /party-payments. The receivables and payables sub-ledger: who owes you, whom you owe, and the ledger moves with it.

  • Parties: customers, suppliers or both, with payment terms (the default due date), an optional credit limit (a warning on the account page when the balance passes it), a tax number and, optionally, their own receivable / payable account. Otherwise documents post to the receivables / payables control account (Control accounts), so only this module — not a manual entry — moves those balances.
  • Documents: invoice, credit note (customers), bill, debit note (suppliers). Lines carry an account, quantity, unit price and an output (sales) or input (purchase) tax code, tax-exclusive or inclusive prices; amounts and tax are worked out on the issue date. A document is a draft until posted; posting gives it a gapless number (INV-2026-00001, per kind and year; a posting that fails gives the number back) and books one entry: the control account against the lines and their tax, the tax lines marked for the tax report, with the invoice number as the entry's source document. A posted document is never edited: void it (its entry is reversed) and issue a new one.
  • Receipts and payments are posted when recorded (bank or cash account against the control account) and numbered (RCT-…, PAY-…). Allocate them to specific invoices (bills), or leave the oldest due first; what is not allocated stays on the account as an unapplied payment. A credit (debit) note is applied to invoices (bills) the same way. Allocations can be undone; a document with allocations cannot be voided until they are.
  • Statements and open items: per party, the open invoices with days overdue, unapplied credits and payments, and a statement of account for any period with a running balance.
  • Ageing by customer or supplier (not due, 1–30, 31–60, 61–90, over 90 days past due, unapplied, total) as of any date, with CSV / XLSX / PDF export — and a check against the control account: the sub-ledger total must equal the ledger, and any difference (a manual posting on the control account) is shown.
  • Base currency only in this release.

Permissions parties.view / create / update / delete, party-documents.view / create / update / delete / post / void and party-payments.view / create / void (accountant: all; approver, auditor, viewer: view). Audited (PARTY_*).

Tax

Dashboard → Tax (React and Blade) or /api/v1/accounting/tax/…. Tax codes and their dated rates (Accounting → Tax codes) drive a tax ledger and tax returns.

  • Tax codes now have a kind — output (collected on sales), input (paid on purchases), withheld (deducted from payments we make) or advance (deducted from payments to us) — the tax account the tax is booked to, and a jurisdiction. A rate is picked by date: the active rate with the latest start on or before the document's date.
  • Calculator: base, tax and total for an amount, tax-exclusive or inclusive, rounded to the cent (an inclusive amount always splits exactly: base + tax = amount).
  • Taxed documents: Tax → New taxed document books an invoice, bill, credit or debit note, or a payment / receipt with tax withheld in one go — the tax is worked out and booked to the code's tax account, with a live preview. In the API and in any journal entry a line may carry tax_code_id (and tax_inclusive): it is split into the taxable amount and a tax line on the same side. Both lines are marked (tax_role base / tax), which is what the reports read; zero-rated and exempt sales keep only the marked base.
  • Tax report: per tax code the taxable base, the tax and the number of documents for any period, from posted entries in the base currency (credit notes and returns reduce them), with the documents behind it, and CSV / XLSX / PDF export. Totals: output tax, input tax, net payable, tax withheld by us, advance tax.
  • Tax return: File a return settles a period in one entry dated at its end — output tax debited and input tax credited on their tax accounts, the difference on the payable account you pick (a refund debits it). Periods cannot overlap; a return can be voided (its entry is reversed and the period can be filed again). Audited (TAX_RETURN_*).
  • Editing a draft keeps its tax markers (send tax_code_id, tax_role and tax_rate back with the lines). Withholding you remit, or advance tax you claim, is an ordinary payment / entry on the withholding account.

Permissions tax-returns.view (accountant, approver, auditor, viewer), tax-returns.file and tax-entries.create (accountant); the calculator needs tax-codes.view.

Urdu and right-to-left

The screens can be shown in Urdu (اردو), right to left. A language switcher appears on every screen (a selector in the company bar in Blade, a small selector in the corner in React); the choice is kept for the session, and ?lang=ur selects it directly. ACCOUNTING_LOCALE=ur makes Urdu the default.

  • How it works: phrases are translated one by one: any visible text, placeholder, title or aria-label that equals a key of resources/lang/ur.json exactly is replaced (Blade pages on the server, React pages in the browser). A phrase that is not in the dictionary, or has a name or number inside it, stays in English. About 500 phrases are included: the module names, buttons, statuses, table headings and report names used across the screens.
  • Your own wording: put a lang/ur.json in your application with the phrases you want to add or change; it wins over the package's. php artisan vendor:publish --tag=accounting-lang copies the package dictionary as a starting point. More languages: add them to accounting.locales (and rtl_locales if they run right to left) with their own lang/<code>.json.
  • Direction: lang and dir="rtl" are set on the page, with a small stylesheet that flips alignment and spacing, uses an Urdu-capable font stack and moves the starter kit's sidebar to the right. Numbers, dates and amounts keep Western digits, as accounts are usually kept.
  • Not translated: the API (its messages stay as they are for integrations), the PDFs and Excel exports, Livewire updates after the first render, texts that include data (for example "Last 6 months, closing entries excluded"), and the host application's own menus. The Urdu terms were written without review by an accountant: have yours check them and override what they would say differently.

The Accounting menu, the two parts of the area, and the help centre

One entry in your starter kit's menu. Instead of two loose links, the installer adds a single Accounting menu next to Dashboard. It opens into sections, in the same order in every kit, and shows each person only what their role may open (a module you switched off disappears too):

Section Items
Overview Overview (the dashboard), All modules
Journal & ledger Journal entries, Recurring entries, Chart of accounts, Account types, Currency revaluation, Balance snapshots
Sales & purchases Customers & suppliers, Invoices & bills, Receipts & payments, Aged receivables, Aged payables, Tax returns, WhatsApp
Banking Bank accounts, Bank statements, Reconciliations, Bank book, Cash book
Payroll Payroll runs, Employees, Attendance & leave, Loans & advances, Payroll reports & tax
Assets & stock Fixed assets, Inventory
Reports Trial balance, General ledger, Balance sheet, Income statement, Cash flow, Financial statements, Account balances, Budgets, Report mapping
Settings: Users & access Users, Roles, Permissions, Audit log
Settings: Set-up Accounting periods, Currencies, Cost centers, Branches, Voucher types, Tax codes, Tax rates, Control accounts, Account roles, Modules on / off
Help Help centre
Livewire / Flux sidebar React sidebar
Flux sidebar: the Accounting menu with Payroll open React sidebar: the same menu
Jetstream / Breeze top navigation Flux header layout
Jetstream: Accounting dropdown with the sections side by side Flux header: the same dropdown
  • The list lives in one place, Support/AccountingMenu: a new screen is added there once, never in your host files. The host file holds one line: Flux sidebar <x-accounting::menu.sidebar /> (variant="sidebar" for Flux 2), top navigation <x-accounting::menu.dropdown /> with its mobile twin <x-accounting::menu.responsive />, React sidebar <AccountingNav /> below <NavMain /> (header layout: <AccountingHeaderMenu />), all from @/components/accounting/accounting-nav. The React menu is shared with every page of the application (it sits in your own sidebar), so it also shows on the starter kit's Dashboard.
  • An installation with the two old links (Accounting and Accounting settings) is moved to the new menu by php artisan accounting:install (or --setup-only); nothing else needs doing.
  • The All modules page (/accounting) and its Settings tab remain, as cards grouped like the menu, each with a one-line description.

Help on every screen

The Help centre (/accounting/help, a Help centre entry in the menu) has a short article for every item of the menu, written in plain English: what it is, when to use it, how to, good to know, related screens. There is also Getting started, a Glossary of accounting words and Shortcuts. Every screen has a ? button (a Help button at the bottom right of the React screens) that opens the article of the screen you are on. The index shows the sections of the menu the person can see and has a search box.

Blade (Livewire, Jetstream, Breeze) React
A help article in Blade A help article in React

The articles are markdown files in resources/help/en/{topic}.md (a title: and a summary: front matter, then text; [[topic]] links to another article). To change or add one, put a file of the same name in resources/views/vendor/accounting/help/en/; it wins over the package's.

Accounting and Settings (the two parts of the area)

The accounting area still has two parts, with tabs at the top of the All modules pages:

Part URL / route What is in it
Accounting /accounting · accounting.dashboard the daily work: journal entries, chart of accounts, recurring entries, reports (overview, ledger, trial balance, balance sheet, income statement, cash flow, statements, ageing), customers and suppliers, invoices/bills, receipts/payments, tax, banking, payroll, inventory, fixed assets, budgets, WhatsApp
Settings /accounting/settings · accounting.settings Users & access (users with their roles, direct permissions and branches; roles and what each may do; permissions; features; account roles; branches; companies), Setup (currencies, periods, account types, cost centers, tax codes and rates, voucher types, control accounts, report mapping) and Audit

Only the cards and links the signed-in person's permissions allow are shown, and a route without the permission answers 403. Roles are created and changed in Settings → Users & access → Roles (React /accounting/roles, Blade /settings/roles): tick the permissions a role has; give a user extra permissions on the user's own screen; remove them the same way.

Accounting Settings
The Accounting tab in the Livewire 4 starter kit The Settings tab: Users & access, Setup, Audit

Screens, design and dropdowns

All three front ends (React/Inertia, Blade with Livewire, the REST API) share the same screens; the Blade screens run inside the layout of your starter kit (Livewire/Flux, Jetstream, Breeze) and follow its light/dark mode.

Livewire (Flux) Livewire 4
Journal form, Livewire Flux Journal form, Livewire 4
Jetstream React
Journal form, Jetstream Journal form, React
  • Every dropdown is searchable. Blade screens use Select2 (add class="no-select2" to a <select> to keep it native); React screens use components/accounting/dropdown.tsx, which takes the same <option> children and onChange as a native <select>. An <option data-code="1101" data-name="Cash" data-tag="Asset"> is drawn as number + name + type.
  • The journal entry form lists every account (number and name) as soon as the dropdown opens; type a number or part of a name to narrow it. Debit and credit are typed once, a line's balance button fills in what is missing, the bar at the bottom shows Balanced or the difference, Ctrl + S saves.
  • Filters: the Filters button shows how many are active, the panel opens by itself when a filter is applied, and what is filtered is shown as chips with Clear all.
Journal entries, filters open Account dropdown with search
Journal entries Account dropdown
  • Payroll is five tabs on every payroll screen (Runs, People & pay, Attendance & leave, Loans & extras, Reports & tax); the choices inside a tab show once you are in it, and hidden entries follow the feature switches and permissions. Long filter lists keep the rarer fields under More filters, and a screen with many actions keeps the less common ones under More. Status words are coloured pills in every list.
Payroll (Livewire / Flux) Payroll, dark mode
Payroll Payroll dark
Payroll, Jetstream Payroll, React
Payroll Jetstream Payroll React
General ledger Journal entry, React
General ledger Journal entry React

The look lives in one file, resources/views/accounting/components/ui-styles.blade.php (CSS variables such as --acc-accent, scoped to .acc-kit); publish the views (php artisan vendor:publish --tag=accounting-views) and change the variables to match your brand.

Dashboard

/accounting/overview (and GET /api/v1/accounting/dashboard?as_of=&months=) shows what an accountant opens the system for: cash and bank, receivables and payables with ageing, income vs expense by month, top expenses, recent entries and a list of things needing attention, each linking to the page that deals with it. Sections are filtered by the user's permissions. The existing /accounting page remains the module hub.

WhatsApp (Cloud API)

Send a WhatsApp message from anywhere in your application with one call, and see on /accounting/whatsapp (and /api/v1/accounting/whatsapp) what was sent and how far each message got: sent → delivered → read, or failed with the reason in plain words. Everything you set is in .env.

use Alimarchal\LaravelChartOfAccounts\Support\WhatsApp;

// A school fee has been received: tell the parent (an approved template, which is how a business starts a conversation)
WhatsApp::template('0300-1234567', 'fee_received', [
    'student' => 'Ali Raza', 'amount' => 'Rs 5,000', 'receipt' => 'RV-2026-00012', 'date' => '10 Oct 2026',
], related: $payment);

WhatsApp::text('+92 300 1234567', 'Your receipt is ready.');   // free text: only within 24 hours of the person's last message
WhatsApp::queueTemplate(...);                                   // the same, sent by a queue worker

template() and text() return the saved WhatsAppMessage (status sent: Meta accepted it) and throw an AccountingException with a message you can show when WhatsApp refuses (a wrong number, the 24-hour window, parameters that do not match the template). Numbers may be written as people write them (+92 300 1234567, 0300-1234567, 0092 300 …); they are turned into digits with the country code.

The WhatsApp screen

The school-fee example, end to end

  1. Add the parent as a customer with a phone number (/accounting/parties), and the fee template to Meta (below).
  2. In .env: ACCOUNTING_WHATSAPP_ENABLED=true and WHATSAPP_NOTIFY_RECEIPT=fee_received.
  3. Record the fee as a receipt from that customer (/accounting/party-payments, or POST /api/v1/accounting/party-payments). The moment it is saved the parent gets "Dear parent, we received Rs 5,000 for Ali Raza. Receipt RCT-2026-00002 dated 10 Oct 2026. Thank you." The template parameters are filled from the receipt (student/party/name = the customer, amount, receipt, date, reference, method; the order is the one in accounting.whatsapp.templates.fee_received.parameters).
  4. A problem with WhatsApp never stops the receipt: it is saved, and the message shows as failed on the WhatsApp screen with a Retry button.

To send from your own code (for example a fee voucher in your school app), call WhatsApp::template() as shown above.

Set-up in .env

Key What it is
ACCOUNTING_WHATSAPP_ENABLED true switches the module on (default false)
ACCOUNTING_WHATSAPP_MODE fake (default, the sandbox: nothing leaves your server) or live (Meta's Graph API)
WHATSAPP_TOKEN Access token of a system user (a permanent token) with whatsapp_business_messaging
WHATSAPP_PHONE_NUMBER_ID The ID of the sending number (WhatsApp Manager → API Setup); not the phone number itself
WHATSAPP_BUSINESS_ACCOUNT_ID WhatsApp Business Account ID (optional: lists the approved templates)
WHATSAPP_VERIFY_TOKEN A word you choose; you type the same word into Meta's webhook settings
WHATSAPP_APP_SECRET Meta app → Settings → Basic; proves a webhook call really comes from Meta
WHATSAPP_API_VERSION Graph API version, default v25.0 (a version lives about two years: check Meta's changelog)
WHATSAPP_DEFAULT_COUNTRY_CODE 92: 0300 1234567 becomes 92 300 1234567 (empty = always require +country code)
WHATSAPP_DEFAULT_LANGUAGE Language code of your templates (en, en_US, ur …)
WHATSAPP_USE_QUEUE / WHATSAPP_QUEUE Send the automatic messages through the queue
WHATSAPP_NOTIFY_RECEIPT Template key sent to the customer when a receipt is recorded (empty = none)
WHATSAPP_TIMEOUT / WHATSAPP_RETRIES / WHATSAPP_BASE_URL Timeout (15 s), quick retries of a dropped connection or a 5xx answer (2), base URL (https:// only)

Your templates are listed in config/accounting.php → whatsapp.templates (key, name as in WhatsApp Manager, language, and the order of the parameters {{1}}, {{2}} …; set 'format' => 'named' for templates with named parameters). A template you did not list is sent under its own name with the parameters you pass.

php artisan accounting:whatsapp status shows what is set and what is missing; accounting:whatsapp test +923001234567 sends Meta's hello_world (--template=fee_received --param=Ali --param=…, or --text="…"); accounting:whatsapp templates lists the templates Meta has approved (live mode).

Try it first: the sandbox (mode=fake)

With ACCOUNTING_WHATSAPP_MODE=fake nothing is sent and no Meta account is needed, but the rules of the real thing apply: a template is accepted, free text to someone who has not written to you in the last 24 hours is refused (error 131047), and parameters are checked. On the WhatsApp screen use Mark delivered / Mark read (what Meta's webhook does later) and Customer replies (which opens the 24-hour window), or the API: POST /whatsapp/messages/{id}/simulate {"status": "delivered|read|failed"}, POST /whatsapp/simulate-reply {"from": "0300…"}. These answer 404 in live mode.

Going live, step by step

Going live

  1. Meta: create a developer account and an app of type Business, add the WhatsApp product. Meta gives a free test number and the hello_world template; a test number only sends to the recipients you add in WhatsApp Manager → API Setup (otherwise error 131030).
  2. Token: create a system user in Business settings, give it the app and whatsapp_business_messaging, generate a token that does not expire. Put it, the Phone number ID, the WhatsApp Business Account ID and the app secret into .env and run php artisan accounting:whatsapp status.
  3. Test: php artisan accounting:whatsapp test +92XXXXXXXXXX (set ACCOUNTING_WHATSAPP_MODE=live first). The message should reach the phone.
  4. Webhook: in WhatsApp → Configuration enter the callback URL shown on the WhatsApp screen (https://your-domain/api/v1/accounting/whatsapp/webhook, it must be public https) and your WHATSAPP_VERIFY_TOKEN, then subscribe to messages. Without the webhook messages stay sent and never turn delivered or read.
  5. Templates: a business starts a conversation only with an approved template (WhatsApp Manager → Message templates, category Utility for receipts; approval usually takes minutes to a day). Create fee_received with four body parameters in the order listed in the config, using the same name and language.
  6. Go live: a real business number needs a verified business and a payment method; then set WHATSAPP_NOTIFY_RECEIPT=fee_received.

The process

What the integration does for you

  • Request (POST {base}/{version}/{phone-number-id}/messages, Authorization: Bearer, JSON messaging_product: whatsapp, to, type: template|text). https only; the token is never sent over plain http.
  • Parameters are cleaned the way WhatsApp requires (no line breaks, tabs or runs of spaces, never empty, at most 1024 characters) and checked against the template (count, names) before anything is sent.
  • Errors are kept on the record with Meta's code and a sentence you can act on (131047 the 24-hour window, 131026 not on WhatsApp, 131030 not on the test list, 132000/132001 template mismatch, 132018 bad parameter, 190 token expired, rate limits …). Only a dropped connection, HTTP 5xx and "too many requests" are retried (quickly in the request, and with 30 s / 2 min / 10 min pauses on the queue); a 4xx never is.
  • Webhook (GET verification, POST events, no login): verified by the verify token and by the X-Hub-Signature-256 HMAC of the raw body with your app secret (in live mode a call without a valid signature is refused). A status never moves backwards, a reply from a customer is stored once however often Meta repeats it, and events of another phone number of the same app are ignored.
  • Scope: every message belongs to the company that sent it and is listed with its filters (search, status, direction, template, number, date_from, date_to).
  • Permissions whatsapp.view and whatsapp.send; the feature switch whatsapp. To use another provider, bind your own Alimarchal\LaravelChartOfAccounts\Contracts\WhatsAppGateway.
  • Check before going live: this was written from Meta's published WhatsApp Business Platform (Cloud API) documentation as understood when written (Meta's own pages could not be read from the build environment, so details come from secondary summaries of them); the package has not been exercised against Meta's servers (no credentials were available), only against the request and response shapes. Meta changes versions, fees and template rules: check the current documentation and changelog at developers.facebook.com/docs/whatsapp and run the test command before relying on it.

Payroll

/accounting/payroll (and /api/v1/accounting/payroll) runs the monthly salaries:

  • Employees with a monthly basic salary, join and leave dates, cost center, bank details and a "withhold income tax" flag; allowances and deductions (a fixed amount or a percent of basic, per employee overridable), where earnings are booked to an expense account and deductions to the liability they are owed to.
  • A payroll run for a month works out every payslip: new joiners and leavers are paid for the days employed, percent components follow the prorated basic, and flagged employees have income tax withheld from the slabs in accounting.payroll.tax_slabs (annualised taxable pay; the defaults are illustrative Pakistani salaried-individual slabs, so check them against the current rules). A negative net salary is refused (the message lists every overdrawn employee at once). How a day's pay is worked out (joiners, leavers, unpaid absence, leave encashment) follows ACCOUNTING_PAYROLL_DAY_BASIS: calendar (the real 28–31 days, the default), fixed (gratuity.days_per_month every month) or working (the month's days less the weekly off days in ACCOUNTING_PAYROLL_WEEKLY_OFF, 0 = Sunday … 6 = Saturday). One run per month; a draft can be recalculated or deleted.
  • Post books one entry in the payroll module: basic pay and earnings as expense (by account and cost center), deductions and tax as liabilities, and the net pay owed to employees (ACCOUNTING_PAYROLL_NET_PAYABLE, default 2103). Pay clears that liability against a bank or cash account. Void reverses the payment and the salary entry; the month can then be run again.
  • Quantity x rate components: an allowance can be a quantity at a rate (fuel: 75 litres x 280). The rate lives on the component, the quantity per employee (or default), so changing the price once changes everybody on the next calculation.
  • Salary grades (/accounting/payroll/grades): a basic salary with its allowances and deductions. Employees on a grade follow it: change an allowance of the grade and everybody on it is paid the new amount next time; an employee's own components and values override the grade's. Leaving the salary blank on an employee takes the grade's.
  • Bulk changes (/accounting/payroll/bulk): give or take away a component for many employees, move a group to a grade (optionally moving their salary), or raise salaries (by percent, by an amount, or set to an amount, with rounding) with a preview before it is applied. Who is meant: the employees picked, else everybody on a grade, else every active employee.
  • Salary history: every change of an employee's salary is a revision with the date it applies from and a reason. A revision in the middle of a month pays the old salary up to the day before and the new one from that day.
  • Arrears (/accounting/payroll/arrears): when a raise is applied late (say it is October and the raise was from January), the difference between what the posted payslips paid and what the salary history says was due is worked out month by month (allowances that are a percent of the basic follow). Preview, create (drafts), approve (the approver, payroll.post), and the payroll run of the payment month pays them as an Arrears line, booked to accounting.payroll.arrears_account (default: the salary expense account). Income tax on arrears is worked out as if they had been paid in the months they belong to, so one big month does not push the employee into a higher slab; it appears as its own "Income tax on arrears" line. Months already claimed are never claimed twice; a month in which an employee was overpaid is not recovered; a voided or deleted run puts its arrears back in the approved queue. Arrears of an employee who has already left are not paid by a run (they are not in it). A register shows every arrears record with totals by status and by month.
  • Attendance and leave: a monthly attendance sheet (/accounting/payroll/attendance) with absent days and overtime hours per employee, and leave types (paid or unpaid, with a yearly entitlement), leaves and yearly balances (/accounting/payroll/leaves). Absent days and unpaid leave come off the basic and the fixed allowances by the day (percent allowances follow the basic); half days are allowed; leave that overlaps other leave, or exceeds a paid type's balance, is refused. A month with a posted payroll cannot be changed.
  • Overtime: employees flagged "paid overtime" get hours x (salary / accounting.payroll.overtime.hours_per_month) x the multiplier (a separate one for holidays), as a taxable Overtime line on the payslip.
  • Loans and advances (/accounting/payroll/loans): record the amount, the number of instalments and the first salary it comes out of (equal instalments, the last takes the rounding), then pay it out (the approver: debit accounting.payroll.employee_loans_account, default 1105, credit the bank). Each payroll run takes the due instalments as a deduction ("Loan instalment 2/12"; the credit goes to the loan account), a voided, deleted or recalculated run gives them back, skip moves the next instalment to the end, settle takes the rest in cash, and a loan closes when it has been recovered.
  • Contributions (EOBI, PESSI/SESSI, provident fund; /accounting/payroll/schemes): a rate of the basic or the gross up to a ceiling, or a fixed amount; the employee's share is a deduction owed to a liability account, the employer's share is an expense owed to another liability account and is not part of the net pay (it is booked with the run and shown on the payslip). A scheme applies to everybody or to the employees it is given to, and can also be taken on arrears. The rates are yours to set: check them against the current rules.
  • Bank salary file: from a posted run, CSV or Excel in a layout of accounting.payroll.bank_file.layouts (standard, IBAN, or your bank's); employees without a bank account are left out and listed on the screen. Needs payroll.post.
  • Payslip files (print, PDF, e-mail): every payslip opens as a printable page and a PDF, and goes by e-mail to the employee's address (one, or the whole posted run: accounting.payroll.payslip_mail). Employees without an address are listed as skipped.
  • Automatic run: php artisan accounting:payroll-run [--month=2026-10] [--company=1] creates the draft run (default: last month; skips a month that already has one). Turn on accounting.payroll.auto_run to have the scheduler do it every month.
  • Final settlement (/accounting/payroll/settlements): when an employee leaves, preview and save the gratuity (days per year x basic/30 x years served, after a minimum service), pay for unused leave of one leave type, a signed adjustment and the loans still owed; post it (the approver), pay it, or void it (the loans open again). Rules in accounting.payroll.gratuity.
  • Tax (/accounting/payroll/tax): the salary tax statement of a tax year for every employee (CSV, Excel, PDF) and a printable certificate per employee, from the posted payroll. The tax year starts in accounting.payroll.tax_year_start_month (7 = July).
  • Payroll reports (/accounting/payroll/reports): month by month with the change, cost by cost center, and headcount; CSV, Excel, PDF.
  • Run approval: with accounting.payroll.require_approval (or the Run approval switch on the Features screen) HR (payroll.run) submits a draft run, which is locked, and finance (payroll.approve, never the person who submitted it) approves it or sends it back with a reason; only an approved run can be posted. Submitted, approved and returned runs are audited.
  • Bonuses and one-off pay (/accounting/payroll/adjustments): an earning (bonus, extra allowance) or a deduction (fine) for an employee and month, or for every active employee at once as a fixed amount or a percent of each basic salary; taxable or not, with or without a pay component (a plain bonus goes to accounting.payroll.bonus_account, default the salary expense account). The month's run takes the open ones up; a recalculated, deleted or voided run gives them back; a month whose run is already submitted or posted is closed to new ones.
  • Printable payslips. Permissions payroll.view/manage/run/post/void: the accountant prepares (manage, run), the approver posts, pays and voids, so one person cannot do both. Audited (PAYROLL_RUN_*, EMPLOYEE_SAVED).

Feature switches

Everything beyond the core (chart of accounts, journal entries, periods, reports, users and roles) is optional. An administrator (accounting.manage-settings) turns modules and payroll features off and on from Features (/accounting/features; /settings/features with Blade), the API (GET /api/v1/accounting/features, PUT with {"features": {"inventory": false}}) or the command line:

php artisan accounting:features                       # the list, with state and source
php artisan accounting:features disable inventory fixed_assets
php artisan accounting:features enable payroll_loans
php artisan accounting:features reset budgets          # back to the config default

A feature that is off disappears from the menus and its web and API routes answer 404; nothing is deleted, and turning it on again brings it back. A sub-feature (payroll_loans) is off whenever its parent (payroll) is off, the payroll engine skips what is off (loans, attendance, contributions, arrears, bonuses), and the schedulers of recurring entries and payroll do nothing while their module is off. The defaults come from accounting.features.disabled (ACCOUNTING_FEATURES_DISABLED="inventory,fixed_assets"); what is saved from the screen, API or command wins. Every change is in the audit trail (FEATURE_ENABLED, FEATURE_DISABLED). Keys: parties, tax, banking, budgets, fixed_assets, inventory, recurring_entries, fx_revaluation, whatsapp, attachments, report_mapping, chart_templates, control_accounts, snapshots, audit_log, payroll, payroll_grades, payroll_arrears, payroll_attendance, payroll_loans, payroll_contributions, payroll_bank_file, payroll_payslip_mail, payroll_adjustments, payroll_approval, payroll_settlements, payroll_reports. Multi-company and maker-checker approvals of journal entries keep their own switches (accounting.multi_company.enabled, accounting.approvals.enabled).

Filtering and searching lists

Every list, in the React screens, the Blade screens and the REST API, filters the same way (Spatie Laravel Query Builder underneath). On a screen the filter bar sits above the table; the same names work in the address and in an API call:

GET /api/v1/accounting/party-documents?filter[search]=rent&filter[status]=posted&filter[date_from]=2026-01-01&sort=-total
GET /api/v1/accounting/party-documents?search=rent&status=posted&date_from=2026-01-01&sort=-total        # the short form is the same
  • filter[name]=value is the standard form; the short ?name=value form is read too, and filter[...] wins when both are sent. A link to a filtered screen and an API call therefore look alike.
  • Several values, separated by commas, match any of them (filter[status]=draft,posted).
  • Text (search, name, email …) matches part of the text, ignoring case; % and _ in what you type match themselves, so searching 100% finds "100% Organic" and nothing else.
  • Dates are YYYY-MM-DD (date_from / date_to or a name that says what it is, e.g. due_from, joined_to), amounts min_total / max_total (or _amount, _cost, _salary, _net), yes/no filters take 1, 0, true or false.
  • ?sort=name and ?sort=-name (descending) sort where a list offers it; the allowed columns are in the API reference. Each list has its own default order (newest first for documents and payments, by code for accounts).
  • Strict where it matters: an unknown filter or sort answers 400, a malformed date or number 422, so a typo never silently returns everything.
  • Filters combine with AND; a result that is capped (for example the 300 newest documents) is capped after filtering, so the record you are looking for is found by its status or date, not only when it is recent.
  • Reports keep their filters when exported: the CSV / Excel / PDF of the trial balance, balance sheet, income statement and the aged reports contain exactly the date and filters on the screen (the Blade screens pick a period instead of a date; its last day is used). The trial balance takes as_of_date, date_from (the movement of a period), search, account_type, report_group and hide_zero.
List Filters (besides search)
Customers and suppliers type, email, tax_number, active
Invoices and bills kind, status, party_id, date_from/date_to (issued), due_from/due_to, min_total/max_total
Receipts and payments kind, status, party_id, method, date_from/date_to, min_amount/max_amount
Fixed assets category, method, status, as_of, acquired_from/acquired_to, min_cost/max_cost
Inventory, movements, warehouses category, unit, low_stock, warehouse_id, as_of; item_id, type, dates; active
Budgets, bank statements status, dates; bank_account_id, unmatched, dates
Recurring entries frequency, mode, active, due_by
Payroll: runs, employees status, period_from/period_to; active, withhold_tax, cost_center_id, joined_from/joined_to, min_salary/max_salary
Payroll: loans, arrears, adjustments, settlements employee_id and a search by the employee's code or name, status, kind, dates, amounts
Payroll: components, grades, schemes kind, method, taxable, base, applies_to_all, active, min_salary/max_salary
Users, roles, voucher types, companies, control accounts role, name, email; guard_name; active, system, reset; is_active; control_type
Tax returns, exchange rates, revaluations, periods return_from/return_to, min_payable; currency_id, source, rate_from/rate_to; status, kind, dates; status, dates
Journal entries, chart of accounts, audit log, small master data reference, status, entry_date_from/entry_date_to, … (see the OpenAPI document)

Inventory

/accounting/inventory (and /api/v1/accounting/inventory) keeps stock and its value at moving average cost: items with the inventory and cost-of-goods-sold accounts they post to, warehouses, and a stock ledger of movements.

  • Receive (quantity, unit cost, the account that paid or owes): debits inventory. Issue: leaves at the current average and debits cost of goods sold (or the account you choose); the last unit out takes whatever value is left, so no cent is lost. Adjust a count difference: gains come in at the average (or a cost you give), losses go to a gain/loss account. Transfer between warehouses moves quantity only.
  • Each movement and its journal entry are made in one transaction (origin inventory, so it may post to inventory control accounts); a closed period stops the stock change too. Stock never goes negative, in total or in a warehouse.
  • The valuation report shows quantity, average cost and value per item and warehouse at any date, flags items at or below their reorder level, checks the stock ledger against the inventory accounts, and exports to CSV, Excel and PDF. Each item has a stock card with the running balance.
  • An item's accounts are locked once it has movements; items and warehouses with movements cannot be deleted, only deactivated. Permissions inventory.view/manage/move; audited (INVENTORY_ITEM_*, STOCK_*).

Fixed assets

/accounting/fixed-assets (and /api/v1/accounting/fixed-assets) is the asset register: code, cost, salvage value, useful life, straight-line or declining-balance depreciation, and the three accounts an asset posts to (cost, accumulated depreciation, depreciation expense). Giving an "offset" account when registering books the purchase (debit the asset account, credit cash, bank or the supplier).

  • Depreciation run (/fixed-assets/depreciation): previews and books everything due up to a month end. One journal entry per month, dated on the month's last day, one line per expense/accumulated account. Depreciation starts in the month the asset enters service, never takes an asset below its salvage value, keeps every cent (the last month takes the remainder), and a month is booked once per asset, so running it again does nothing. Months missed are caught up in order.
  • Disposal: sell or scrap an asset. Depreciation is brought up to the month before the disposal date, cost and accumulated depreciation are removed, the proceeds are received in the account you choose, and the gain or loss goes to the account you choose. Disposed assets stay in the register history.
  • Cost, life, method, dates and accounts are locked once an asset has an acquisition or depreciation entry; the register shows the difference between the ledger and the register (should be zero) and exports to CSV, Excel and PDF.
  • Entries use the fixed_assets origin, so they may post to accounts marked as fixed-asset control accounts. Permissions fixed-assets.view/create/update/delete/depreciate/dispose; audited (FIXED_ASSET_*, ASSET_DEPRECIATION_POSTED).

Budgets

Dashboard → Budgets (React and Blade) or /api/v1/accounting/budgets. A budget plans income and expenses by account (optionally cost center) and month, in the base currency and the account's natural direction (income earned, expense spent), and is then followed against the ledger.

  • Plan: a first and last month (up to 24 months) and one line per account — an annual figure that is spread evenly (the odd cents land in the first months) or amounts by month. Build from last year: add every account that had income or expenses in the same months a year earlier, raised or lowered by a percentage. Copy a budget to a later year with a percentage change.
  • Workflow: draft (editable) → approved (needs budgets.approve — the accountant plans, the approver approves) → closed; reopen to revise. Only one approved budget may cover a month.
  • Budget against actual: for the whole budget or any range of months, and for one cost center. Actuals are posted entries in base currency (closing entries excluded). Per account: budget, actual, variance (favourable when positive: income above plan, expenses below it), share used and a status — ok, warning (an expense at ACCOUNTING_BUDGET_WARN_PERCENT, 90%, of its budget), over, behind (income below plan), unbudgeted (spending with no budget line); click a row for the months. Totals for income, expenses and net. Export to CSV, XLSX and PDF.
  • Optional control: ACCOUNTING_BUDGET_CONTROL=block refuses to post an entry that takes a budgeted expense account past its cumulative budget (the sum of its monthly budgets up to the entry's month) in the approved budget covering the date. Unbudgeted accounts, income, drafts and system entries are never blocked; users with budgets.override may post anyway. The default is off.

Permissions budgets.view (accountant, approver, auditor, viewer), budgets.create / update / delete (accountant), budgets.approve (approver) and budgets.override (administrators). Audited (BUDGET_*).

Bank statements

Dashboard → Bank Statements (React and Blade) or /api/v1/accounting/bank-statements. Import the statement your bank gives you, match it to the ledger and reconcile — no retyping.

  • Import a CSV or Excel file for a bank account (one linked to a chart of accounts account). Columns are recognised by name: Date (Transaction / Value / Posting Date), Description (Narration, Particulars, Details …), Reference (Cheque No, Ref No …), Withdrawal / Debit and Deposit / Credit — or one signed Amount — and Balance. Dates as 2026-10-05, 05/10/2026 (ACCOUNTING_BANK_DATE_ORDER=mdy for US order), 5-Oct-2026 or Excel serials; amounts as 1,234.50, (120.00), -45, 500.00 DR, 1.234,56. A preview lists every row first; a file with an error imports nothing.
  • No duplicates: each transaction is fingerprinted (account, date, amount, reference, description and its n-th occurrence in the file), so importing an overlapping statement again brings in only what is new, while two identical charges on one day both come in. The fingerprint is unique in the database.
  • Matching: a transaction matches a posted, unreconciled ledger line on the bank's account with the same amount, dated within ACCOUNTING_BANK_MATCH_DAYS (5) days. Auto-match takes every transaction with exactly one candidate (and whose candidate nobody else wants); the rest you match by hand from the list of candidates. A ledger line serves one transaction. Matched ledger lines are marked cleared.
  • Book it: for a transaction not in the books yet (bank charges, interest, a direct deposit) pick the other account: a deposit debits the bank and credits it, a withdrawal the reverse; the entry is posted when possible, otherwise left as a draft (closed period, approval, evidence required) and still linked. Or ignore it.
  • Reconcile once every transaction is matched, booked or ignored (and the closing balance — read from the file's last balance, or typed — is known): the matched ledger lines become reconciled in a bank reconciliation dated at the statement's end. A reconciled statement is locked; deleting an unreconciled one releases its matches.

Permissions bank-statements.view (accountant, approver, auditor, viewer), bank-statements.import and bank-statements.match (accountant). Audited (BANK_STATEMENT_*).

Currency revaluation

Dashboard → Currency Revaluation (React and Blade) or /api/v1/accounting/fx-revaluation. An asset or liability account whose currency is not the base currency (a USD bank account, a EUR payable, …) holds a foreign balance (what its lines say in that currency) and a carrying value in the base currency (what the ledger booked at the rates of the day). At period end the carrying value is restated to foreign balance × closing rate; the difference is an unrealised exchange gain or loss.

  • Closing rates: type them in, or keep a dated rate history (/fx-revaluation/rates): the latest rate on or before the date is used, else the currency's own rate. Calculate first — the preview lists every account with its foreign balance, rate, carrying value, revalued amount and adjustment; nothing is posted until you confirm.
  • One adjusting entry in the base currency (voucher type JV, origin fx-revaluation): each account is debited or credited by its adjustment and the net goes to the gain/loss account you pick (any active income or expense account; ACCOUNTING_FX_GAIN_LOSS_ACCOUNT pre-selects one by code). Only posted entries up to the date count, and only lines in the account's own currency make up its foreign balance, so earlier adjustments never distort it.
  • Safe to repeat: the carrying value already includes earlier adjustments, so running again at the same rates adjusts nothing ("Nothing to revalue"); a new rate adjusts only the difference.
  • Auto-reverse (optional): a reversal dated after the revaluation (default: the next day, needs an open period) puts the books back on the original rates for the next period — the usual month-end routine. A revaluation can also be reversed later from its page. Each run keeps its per-account detail and is audited (FX_REVALUATION_*).

Permissions fx-revaluation.view (accountant, approver, auditor, viewer), fx-revaluation.run and fx-revaluation.rates (accountant).

The chart of accounts on a fresh install (ERPNext)

A new installation starts with the ERPNext standard chart of accounts with account numbers, so an accountant who knows ERPNext finds every account where it expects it:

Number Account Number Account
1000 Application of Funds (Assets) 4000 Income
1100 / 1110 Cash In Hand / Cash 4110 Sales
1200 / 1210 Bank Accounts / Primary Bank Account 4120 Service
1300 / 1310 Accounts Receivable / Debtors 5000 Expenses
1400 / 1410 Stock Assets / Stock In Hand 5111 Cost of Goods Sold
1700 Fixed Assets (1710–1790, 1780 accumulated depreciation) 5213 Salary
2000 Source of Funds (Liabilities) 5212 Round Off
2100 / 2110 Accounts Payable / Creditors 5219 Exchange Gain / Loss
2300 Duties and Taxes (2310 TDS Payable, 2320 Output Sales Tax Payable) 3000 / 3400 Equity / Retained Earnings

Choose another chart when you install (or later for a new company):

php artisan accounting:install --chart=erpnext      # the default: ERPNext's standard chart
php artisan accounting:install --chart=general      # the package's own business chart (1101 Cash In Hand, 1103 Accounts Receivable …)
php artisan accounting:install --chart=school       # the school chart
php artisan accounting:seed --chart=erpnext --company=BRANCH2     # seed one company
php artisan accounting:create-company BRANCH2 "Branch 2" --template=erpnext   # a new company; without --template it uses ACCOUNTING_CHART_PRESET

ACCOUNTING_CHART_PRESET sets the default (erpnext, general or school). Run interactively, the installer asks which chart to use (ERPNext is the default answer). The chart is chosen per company and kept in the company's settings; the seeder only creates missing accounts and never renames or removes one.

  • Everything is wired up — cash book, bank book, ageing, control accounts, year-end close, rounding, exchange gain/loss, payroll (salary expense, income tax, net salary payable, loans) all find their accounts through account roles, and the financial statements are mapped to every account.
  • Mixing is refused — adding an ERPNext account to a company that was seeded with the general chart (or the other way round) is not allowed, because the two charts use the same numbers for different things. Industry templates (accounting:chart-templates) fit both: the template's accounts are numbered after the chart you have.
  • Existing installations are unchanged — a company that already has a chart keeps behaving as the general chart did (the same account codes), and accounting:update adds the roles table without touching your accounts. To switch such a company to roles explicitly, run php artisan accounting:account-roles seed general once, then change roles as you like.
  • Licence — the chart follows ERPNext's published structure and names (ERPNext is GPL-3.0); see NOTICE.md. Install with --chart=general if you prefer the package's own chart.

Account roles

A module never asks for "account 1101". It asks for a role: "which account is the cash book?", "which accounts are the receivables in the ageing report?". Each company has its own answers, filled in from the chart when it was seeded and changeable from Account roles (/accounting/account-roles, /settings/account-roles with Blade; needs accounting.manage-settings), the API or the command line.

Role Used for
cash, bank Cash book, bank book, close checklist (bank reconciled)
retained_earnings, rounding, fx_gain_loss Year-end close, rounding differences, currency revaluation
opening_balance_equity Carries the difference of the opening balances (see below)
aging.receivables, aging.payables The ageing reports and the party statements
control.receivables, .payables, .inventory, .payroll, .tax "Recommended setup" of the control accounts
salary_expense, net_salary_payable, income_tax_payable, employee_advances Payroll posting, loans, settlements
salary_arrears, bonus_expense, gratuity_expense, gratuity_payable Optional: arrears, bonuses and settlements default to salary expense / net salary payable
php artisan accounting:account-roles                          # who plays what, and what the config pins
php artisan accounting:account-roles set cash 1110
php artisan accounting:account-roles set aging.receivables 1310 1311
php artisan accounting:account-roles seed general --overwrite  # fill the roles of a chart again
GET /api/v1/accounting/account-roles
PUT /api/v1/accounting/account-roles   {"roles": {"cash": [12], "aging.receivables": [14, 15]}}
  • The order of resolution is: the config value (accounting.defaults.cash_account_code, accounting.aging.*, accounting.payroll.*_account, accounting.fx.gain_loss_account — a config value pins the role and the screen shows it as fixed), then the company's saved roles, then the default of the company's chart.
  • A role takes active posting accounts of the company; a role that takes one account refuses two. An empty list puts the role back to the chart's default. Every change is in the audit trail (ACCOUNT_ROLES_UPDATED).
  • They follow the money: merging an account into another moves its roles to the account that keeps the balance; an account that plays a role cannot be deleted or deactivated until the role is given to another account; and cash, bank, retained earnings and rounding cannot be renumbered, merged away or deactivated while they play their role.

Opening balances

A company that starts using the books brings its balances over with one entry on the go-live date:

POST /api/v1/accounting/opening-balances
{"date": "2026-07-01", "balances": [
  {"chart_of_account_id": 12, "debit": "5000.00"},
  {"chart_of_account_id": 31, "credit": "1500.00"}
]}
GET  /api/v1/accounting/opening-balances     # the posted entry, or null
  • One posted entry (reference OPENING-BALANCE, voucher number from the normal sequence). Each balance is either a debit or a credit; whatever the two sides do not balance by is booked to the opening_balance_equity role (account 3102, or 3300 in the ERPNext chart), so the entry always balances.
  • Needs journal-entries.post. The normal posting rules apply: open period, active posting accounts, and control accounts (receivables, payables, stock) are opened with their own opening invoices, bills and stock rather than with a plain balance.
  • Only one opening entry can stand at a time (422 otherwise); reverse it to post the balances again.
  • API only for now: there is no screen for it.

The posted entry as it appears on the journal entry page:

Opening balance entry

Industry chart templates

Chart of Accounts → Templates (React and Blade), GET /chart-templates, or php artisan accounting:chart-templates. A template is a base chart plus the accounts an industry adds:

Template Adds (examples)
erpnext The ERPNext standard chart with account numbers (the default for a new installation)
general The package's own business chart
trading Merchandise and goods in transit, sales returns / discounts (contra income), purchases, purchase returns, freight inward, customs duty, sales commission
manufacturing Work in progress, packing materials, spare parts, factory building, tools and dies, a Manufacturing Overheads group (rent, utilities, depreciation, indirect labour, wastage) and production variances
services Unbilled revenue, retention payable, consulting / retainer / project income, subcontractor costs
school The school chart (student fee receivable, fee incomes, books and uniform stock) plus hostel, annual charges and examination accounts
ngo Restricted / unrestricted / endowment funds, grants and pledges receivable, donations, grant income, programme and fundraising costs
healthcare Patient and insurance receivables, medical and pharmacy stock, consultation / laboratory / pharmacy / procedure income, doctors' fees
  • Preview first: every account is listed as new, already there, named differently (your name is kept) or blocked (missing parent).
  • Safe on a chart in use: only missing accounts are added, parents first; nothing existing is renamed, moved or re-typed, so a template can be applied again later or on top of another one. Contra accounts get the opposite side, and every new account is mapped to its statement line (see Report mapping) so the statements stay complete.
  • New companies can start from one: php artisan accounting:create-company SUB "Clinic" --template=healthcare.
  • Permission chart-templates.apply (super-admin); audited as CHART_TEMPLATE_APPLIED.
// config/accounting.php — your own template
'chart_templates' => [
    'bakery' => ['name' => 'Bakery', 'description' => 'Bread and cakes', 'base' => 'general',
        'extras' => [['5121', '5100', 'EXPENSE', 'Flour and Ingredients', false, ['line' => 'IS-COST-OF-SALES']]]],
],
php artisan accounting:chart-templates                       # list
php artisan accounting:chart-templates ngo --dry-run          # what it would add
php artisan accounting:chart-templates ngo --company=SUB      # add it to a company

Importing and exporting the chart

Bring an existing chart of accounts in from Excel (.xlsx) or CSV — Chart of Accounts → Import (React and Blade), or POST /api/v1/accounting/chart-of-accounts/import.

  1. Download the template (or export the current chart — the export is in the same layout).
  2. Fill in or edit the rows and upload the file.
  3. The preview lists every line as new, updated (old → new for each field), unchanged or error, with the reason. Nothing is saved yet.
  4. Import applies the whole file in one transaction. If any row is in error, nothing is imported.
Column Required Notes
account_code always identifies the account; an existing code is updated (or left alone with "Leave them unchanged")
account_name new accounts
parent_code no an existing account or another row — parents may come after their children in the file
account_type top-level new accounts type code or name (ASSET, Expense …); children default to their parent's type
normal_balance no debit / credit; defaults to the type's
currency no currency code; defaults to the parent's, then the base currency
is_group, is_active no yes / no / 1 / 0 / true / false
control_type no receivables, payables … (needs control-accounts.manage)
description no

Blank cells keep an existing account's value. Common header names are understood (Code, Name, Parent, Type …), CSVs may use commas, semicolons or tabs, and Excel / Windows-1252 files are read as UTF-8. Every row passes the same rules as the screens and the API and the database guards — a used account cannot change type, a posting account cannot get children, loops are refused. The import is audited (CHART_IMPORTED, with the codes created and updated), and needs the chart-of-accounts.import permission (super-admin by default). Limits: ACCOUNTING_CHART_IMPORT_MAX_KB (5 MB) and ACCOUNTING_CHART_IMPORT_MAX_ROWS (5,000).

# Preview over the API, then import
curl -H "Authorization: Bearer $TOKEN" -F file=@chart.xlsx -F dry_run=1 $APP/api/v1/accounting/chart-of-accounts/import
curl -H "Authorization: Bearer $TOKEN" -F file=@chart.xlsx $APP/api/v1/accounting/chart-of-accounts/import

Renumbering and merging accounts

Chart of Accounts → the ⇄ icon on an account (React and Blade), or the API. Needs chart-of-accounts.restructure (super-admin by default). Both show a preview first and are audited.

Renumber — give an account a new code, even if it has journal entries (lines point to the account, not its code, so every report follows). A group can take its sub-accounts along: codes sharing the group's prefix (the code without trailing zeros) get the new prefix — 5100 → 6100 turns 5101 into 6101 and 5110 into 6110. To take its sub-accounts along, the new code keeps the group's length and trailing zeros (7000 → 8000, not 7000 → 7700); otherwise renumber the account alone. Codes already in use are refused, and so are accounts named in config('accounting.defaults') (change the configured code first).

Merge — for duplicates (Stationery and Stationary). Posted entries are immutable, so nothing is rewritten:

  1. The source's balance moves to the target with a posted transfer entry, one pair of lines per cost center, through the normal rules (open period, maker-checker approval, control accounts). If the transfer needs approval, the merge is refused: post the transfer through approvals first, then merge (the balance is zero).
  2. Draft lines, sub-accounts and bank accounts that used the source now use the target.
  3. The source is deactivated with metadata.merged_into; its history stays in every report.

Only accounts of the same type, normal balance, currency and control type merge; entries waiting for approval on the source must be decided first; configured and system accounts cannot be merged away.

curl -X POST -H "Authorization: Bearer $TOKEN" -d account_code=6100 $APP/api/v1/accounting/chart-of-accounts/59/renumber
curl -H "Authorization: Bearer $TOKEN" "$APP/api/v1/accounting/chart-of-accounts/120/merge-preview?target_account_id=67"
curl -X POST -H "Authorization: Bearer $TOKEN" -d target_code=5104 $APP/api/v1/accounting/chart-of-accounts/120/merge

Voucher numbering

Every journal entry has a voucher type; when it is posted it gets the next number of that type's series.

Code Voucher Typical use
JV Journal Voucher (default) Adjustments, accruals, transfers; reversals and closing entries of untyped entries
CPV / CRV Cash Payment / Cash Receipt Voucher Cash paid / received
BPV / BRV Bank Payment / Bank Receipt Voucher Cheques and transfers out / in
  • Gapless, in posting order. The number is taken inside the posting transaction under a row lock, so two users posting at once never get the same number, and a failed posting gives its number back. Drafts have no number, so a voided draft never leaves a gap (auditors check exactly this).
  • Format per type, default {PREFIX}-{FY}-{SEQ:5} → JV-2026-00012. Tokens: {PREFIX}, {FY} (2026, or 2025-26 for a July–June year), {YYYY}, {YY}, {MM}, {SEQ} / {SEQ:n}.
  • Restarts every fiscal year (default), every month (format needs {MM} and a year), or never.
  • Locked: the database refuses to change a posted entry's voucher number or type. A type with numbered entries keeps its code and restart rule (name, prefix and format may change; new numbers use them). The default type JV cannot be deleted or deactivated; other used types can only be deactivated.
  • A reversal is numbered in the series of the entry it reverses (BRV-2026-00002 reverses BRV-2026-00001).
  • Choose the type in the journal form (React and Blade), or send voucher_type_code / voucher_type_id to the API. Manage types under Accounting → Voucher Types (voucher-types.*; admin manages, everyone else views).
  • Upgrading numbers the entries already posted as JV, in posting order, and continues the series from there.
$entry = JournalEntry::query()->find($id);
$entry->voucher_number;          // "CPV-2026-00007" (null while draft)
$entry->voucherType->name;       // "Cash Payment Voucher"
app(VoucherNumberService::class)->preview($type, '2026-11-01');   // next number, not reserved

Source documents

An entry can say which document it records — a sales invoice, purchase bill, receipt, payment voucher, credit/debit note, expense claim, payroll sheet, bank statement, contract or other (config('accounting.source_documents.types')) — with its number and date, and can be linked to the application model it came from.

  • No double posting. A document (type + number, case-insensitive) can be posted only once per company. The second posting is refused: "Purchase bill BILL-778 is already posted as JV-2026-00012. Reverse that entry first, or correct the document number." A unique database index backs this up, so two users posting the same bill at the same moment cannot both succeed. Drafts with the same number can still be saved. Turn it off with ACCOUNTING_PREVENT_DUPLICATE_DOCUMENTS=false.
  • Reversal frees the document: the reversal records the same document (and link) but never holds it, so a corrected entry can be posted for the same bill.
  • Locked after posting: the database refuses to change the document type, number, date or link of a posted entry.
  • Shown in the journal form (React and Blade), the entry page, the journal list (filter Document no.) and the general ledger (voucher number and document number on every line, exports included).
  • API: source_document_type, source_document_number, source_document_date on POST /journal-entries and /journal-entries/simple; responses carry source_document; filters filter[source_document_number], filter[source_document_type].

Link entries to your own models:

use Alimarchal\LaravelChartOfAccounts\Concerns\HasJournalEntries;

class Invoice extends Model
{
    use HasJournalEntries;
}

JournalEntry::record("Invoice {$invoice->number}", '1103', '4101', $invoice->total, post: true,
    source: $invoice, documentType: 'invoice', documentNumber: $invoice->number);

$invoice->journalEntries;          // every entry for the invoice, reversals included
$invoice->postedJournalEntry();    // the posted entry that currently holds it
JournalEntry::forSource($invoice)->get();

JournalEntryService::create() takes the same source_document_* keys and 'source' => $invoice.

Control accounts

A control account summarises a sub-ledger — customers (Accounts Receivable), suppliers (Accounts Payable), stock, fixed assets, payroll or tax. Its balance must always equal the sub-ledger, so only entries of that module may post to it. A manual journal entry (UI or API) touching a control account is refused:

Account 1103 Accounts Receivable is the control account for accounts receivable (customers): post through that module, or ask a controller with the "control-accounts.post-manual" permission.

  • Setting up: open Accounting → Control Accounts and click Recommended setup (marks 1103 → receivables, 2101 → payables, 1151–1153 → inventory, 2103 → payroll, 1107/2104 → tax; config('accounting.control_accounts.recommended')), or mark any posting account yourself. Existing installs are not marked on upgrade, so nothing changes until you opt in. Changing control accounts needs control-accounts.manage (super-admin and admin).
  • Modules post with their name: JournalEntryService::create([... 'origin_module' => 'receivables']) or JournalEntry::record(..., module: 'receivables'). The API never accepts a module, so API clients post as manual. The module of a posted entry is locked in the database.
  • Controller adjustments: users with control-accounts.post-manual (super-admin only by default) may post manual entries to control accounts — under maker-checker both the submitter and the approver need it. Every such entry appears in the Manual postings column and list, which is the first thing an auditor reviews.
  • Reversals and year-end closing entries follow the entry they come from and are never blocked.
  • API: GET /control-accounts (balances and manual postings), POST /control-accounts/recommended, GET /control-accounts/{id}/manual-postings, PUT /chart-of-accounts/{id}/control-type; accounts carry control_type.

Attachments (supporting documents)

Every journal entry can carry its evidence — the scanned bill, receipt, contract or bank advice. Use the Supporting documents panel on the entry page (React and Blade) or the API.

  • Private storage: files go to ACCOUNTING_ATTACHMENTS_DISK (default local, i.e. storage/app/private) under accounting/{company}/{yyyy}/{mm}/{uuid}.ext and are served only through the authorised download route (attachments.view, company-scoped). Paths never leave the server.
  • Limits: ACCOUNTING_ATTACHMENTS_MAX_KB (default 10 MB) and config('accounting.attachments.mimes') (pdf, images, Office files, csv, txt, xml, zip).
  • Evidence is kept: documents of posted or voided entries cannot be removed; drafts can lose them. Every upload and removal is in the audit trail (ATTACHMENT_ADDED / ATTACHMENT_REMOVED, with the SHA-256 of the file).
  • Duplicate warning: a file that is already attached to another entry (same SHA-256) is flagged — the classic "same bill booked twice" check.
  • Evidence required above an amount: ACCOUNTING_ATTACHMENTS_REQUIRED_ABOVE=50000 makes entries of that total (base currency) or more need at least one document before they are posted or submitted for approval.
  • Permissions: attachments.view (every role that sees the journal), attachments.create and attachments.delete (accountant). API: GET/POST /journal-entries/{id}/attachments (multipart file, description), GET /attachments/{id}/download, DELETE /attachments/{id}.
app(AttachmentService::class)->attach($entry, $request->file('bill'), 'Supplier bill');
$entry->attachments;   // morphMany

Branches

One company, several places of business (head office, campuses, shops). Turn it on once; nothing changes while it is off.

ACCOUNTING_BRANCHES=true           # off by default; `php artisan accounting:install` asks for it

Create the branches in Accounting → Branches (React and Blade, permission branches.create) or with POST /api/v1/accounting/branches. After upgrading an existing installation run php artisan accounting:update once, so the branches.* permissions are created.

Blade (Livewire kit): the Branch switcher limits the list React: the branches screen
Karachi picked in the Branch switcher: the journal list shows only its entries The branches screen in React
Booked to a branch Journal entries, invoices/bills and payments. An invoice, bill or payment gives its branch to the entry it posts; a reversal keeps the branch of the original; the year-end close is for the whole company. Entries with no branch are "head office / not allocated".
Working branch Pick it in the Branch switcher (above every Blade page and on the React dashboard; add <BranchSwitcher /> from @/components/accounting/branch-switcher to your sidebar), or send X-Branch: LHR (code or id) to the API. New records go to it; lists and reports follow it. "All branches" shows the whole company.
Reports by branch Trial balance, general ledger, account statement, income statement, balance sheet, cash flow, ageing, dashboard KPIs, financial statements. The branches add up to the company.
Who sees what Admins and super-admins always work in every branch (accounting.branches.unrestricted_roles). A user given branches (Branches screen → People who work in this branch, or POST /branches/{id}/users) works in those only: other branches' entries, invoices and reports are not even found, and X-Branch of another branch is 403. A user with no branch, and a super-admin, work in all branches. A user with several branches chooses the branch of each entry.
Users Tick the branches when you create or edit a user (React: Users, Blade: Settings → Users, API: branches: [ids] on POST/PATCH /users). The user sees and books only the data of those branches.
What belongs to a branch Journal entries, invoices/bills, payments, customers/suppliers, bank accounts, employees, fixed assets and warehouses. Anything created while a working branch is picked (or by a user with a single branch) belongs to it; a user of several branches must pick the working branch first. Records without a branch (all data from before branches were turned on) are for people without a branch restriction. Chart of accounts, periods, tax codes and currencies stay company-wide.
Deleting A branch with entries, invoices, bills or payments cannot be deleted: deactivate it. Inactive branches cannot be chosen.
Permissions branches.view, branches.create, branches.update, branches.delete (admin: all; accountant and auditor: view).

Branch reports

GET /api/v1/accounting/reports/branch-comparison (permission reports.income-statement.view) shows revenue, expenses and net income of every branch side by side; the other reports (trial balance, income statement, balance sheet, general ledger, ageing, cash flow, dashboard) follow the working branch (X-Branch) or, with none picked, the user's branches. Seeing only what you are allowed to see is automatic. To build your own report by branch:

  • Group by the entry's branch_id: vw_accounting_general_ledger has a branch_id column (null = head office / not allocated), or join accounting_journal_entries.branch_id as Reports/BranchComparisonReport does (one grouped query, posted entries only, is_closing_entry = false, amounts in base_debit / base_credit).
  • In your own queries limit to what the user may see with CurrentBranch::constrain($query, 'entry.branch_id') (or CurrentBranch::accessibleIds() for a branch list); models that use the HasBranch trait are limited automatically.
  • Company-wide on purpose: budgets, tax returns and the customer/supplier control-account reconciliation (they follow the one chart of accounts), and the year-end close. Entries and documents booked before branches were switched on have no branch: they are visible to admins, super-admins and users without a branch only.
  • A posted entry's branch cannot be changed (a database trigger refuses it); reverse and re-book instead.

Period close and the year-end close are company-wide: run them as a user without branch restriction. Branches are a booking dimension of the company's one chart of accounts, periods and numbering; for separate books use Multi-company instead (the two can be combined: branches of each company).

Multi-company

Run several companies (legal entities) from one installation. Each company has its own chart of accounts, periods, journal, cost centers, bank accounts, tax codes, reports and audit trail; account types and currencies are shared, and every company reports in the shared base currency.

ACCOUNTING_MULTI_COMPANY=true      # off by default: a single-company install works exactly as before

A new installation starts with the default company (MAIN), created by the companies migration; nothing changes until you add a second company.

Create a company (chart of accounts, current fiscal year, cost centers and tax codes are created for it):

php artisan accounting:create-company SUB "Subsidiary Ltd" --fiscal-start=7 --user=accountant@example.com

…or in the React UI (Accounting → Companies, permission companies.manage), or with POST /api/v1/accounting/companies. --fiscal-start=7 gives a July–June fiscal year.

Who sees what

Access A user works only in companies they are given access to (Companies screen, POST /companies/{id}/users, or --user). super-admin works in all companies. Roles are the same in every company.
Web UI A company switcher on the accounting dashboard (React) and above every Blade page. Add <CompanySwitcher /> from @/components/accounting/company-switcher to your sidebar to show it everywhere.
API Send X-Company: SUB (code or id). Without it the user's default company is used. Unknown company → 404, no access → 403.
Commands accounting:close-period {id} and friends work in the company that owns the period; --company=SUB on accounting:seed, verify, health-check, rebuild-snapshots.
Isolation Records of another company are never returned (404 by id); ids and codes of another company are rejected in requests; a journal line can never use another company's account; a posted entry cannot be moved to another company (database trigger).

Consolidated reports (reports.consolidated.view): trial balance, balance sheet and income statement for several companies side by side with a group total — Accounting → Consolidated Reports, or GET /api/v1/accounting/reports/consolidated/{trial-balance|balance-sheet|income-statement}?companies=MAIN,SUB. Intercompany balances are not eliminated automatically; post elimination entries in a separate consolidation company if you need them.

Maker-checker approvals

Turn on four-eyes control for journal entries:

ACCOUNTING_APPROVALS_ENABLED=true
ACCOUNTING_APPROVAL_THRESHOLD=100000   # base currency; entries below post directly. 0 = every entry

Maker-checker

Step Who Web UI API
Record the draft maker (accountant) Save POST /journal-entries
Submit maker Submit for approval POST /journal-entries/{id}/submit
Approve → posts it checker (approver) Approve & post POST /journal-entries/{id}/approve
or Reject with a reason checker Reject POST /journal-entries/{id}/reject {"reason": "…"}
Fix and resubmit maker Edit → Submit PUT then submit
  • The checker can never be the person who created or submitted the entry (not even super-admin) unless ACCOUNTING_ALLOW_SELF_APPROVAL=true.
  • Posting an entry that needs approval directly is refused (422), from the UI, the API and JournalEntry::record().
  • All posting rules (balance, open period, active accounts) are checked at submission, so makers learn about problems before the checker sees the entry.
  • Editing a rejected or pending entry sends it back to draft; it must be submitted again.
  • The threshold is compared in the base currency (amount × fx_rate_to_base).
  • Year-end closing entries are system-generated and are not held for approval. A reversal at or above the threshold can only be made by a user who may approve (ACCOUNTING_APPROVAL_INCLUDE_REVERSALS, default true).
  • Module postings (ACCOUNTING_APPROVAL_MODULES, default receivables,payables,inventory,fixed_assets,tax-return) at or above the threshold can only be made by a user who holds journal-entries.approve; the budget control applies to every module posting. Payroll has its own run approval.
  • Whoever last edited an entry cannot approve it either (edited_by), so editing is not a way around the four-eyes rule.
  • ACCOUNTING_REQUIRE_REVERSAL_REASON=true makes a reason mandatory when reversing an entry (reopening a closed period always needs one).
  • Every step is recorded (who + when) and shown as an audit trail on the entry page; the pending queue is GET /journal-entries?filter[approval_status]=pending.

Events & webhooks

Every ledger action dispatches a Laravel event after the database transaction commits (listeners never see rolled-back work):

Event class Webhook name
JournalEntryPosted journal_entry.posted
JournalEntryReversed journal_entry.reversed
JournalEntryVoided journal_entry.voided
JournalEntrySubmitted / Approved / Rejected journal_entry.submitted / .approved / .rejected
AccountingPeriodClosed / Reopened accounting_period.closed / .reopened

All live in Alimarchal\LaravelChartOfAccounts\Events and implement AccountingEvent, so one listener can catch them all:

Event::listen(AccountingEvent::class, fn (AccountingEvent $e) => logger($e->name(), $e->payload()));

Webhooks — set ACCOUNTING_WEBHOOK_URLS (comma separated) and ACCOUNTING_WEBHOOK_SECRET. Each endpoint gets its own queued job (retried with backoff 10 s → 1 h, ACCOUNTING_WEBHOOK_TRIES, default 5). Run a queue worker in production; a receiver being down never fails the request that posted the entry.

POST /hooks/accounting
X-Accounting-Event: journal_entry.posted
X-Accounting-Delivery: 0b6f…           # same on every retry of this delivery
X-Accounting-Signature: t=1791100000,v1=5d41…

{"id":"9c1e…","event":"journal_entry.posted","occurred_at":"2026-10-04T10:15:00Z","data":{"journal_entry":{"id":42,…}}}

Verify the signature on the receiving side (id is stable across retries — use it to de-duplicate):

[$t, $v1] = array_map(fn ($p) => explode('=', $p, 2)[1], explode(',', $request->header('X-Accounting-Signature')));
$valid = hash_equals(hash_hmac('sha256', $t.'.'.$request->getContent(), config('services.accounting.secret')), $v1)
    && abs(time() - (int) $t) < 300;   // reject replays older than 5 minutes

Multi-currency

Each journal entry has a currency and fx_rate_to_base. When it is posted, every line's base-currency amount is calculated once and frozen (base_debit / base_credit, cents-exact: any rounding difference goes to the largest line, so the entry still balances in the base currency). All reports, balances and the approval threshold use these base amounts, so a later change of the exchange rate never changes history.

Financial statements and report mapping

Report Mapping (dashboard → Report Mapping, report-mapping.manage) decides which statement line each account reports under. Every company starts with a standard (IFRS-style) layout:

Statement Sections and standard lines
Balance sheet Current assets: Cash and cash equivalents, Trade and other receivables, Inventories, Tax receivable · Non-current assets: Property, plant and equipment, Accumulated depreciation, Long-term investments · Current liabilities: Trade and other payables, Tax payable, Deferred income, Short-term borrowings · Non-current liabilities: Long-term borrowings · Equity: Share capital, Reserves, Retained earnings
Income statement Revenue · Cost of sales · Other income · Operating expenses (Administrative, Depreciation, Other) · Finance costs · Income tax
  • Map a group and everything under it follows; an account can override its parent. Lines can be renamed, reordered and added (custom lines can be deleted once unused).
  • Apply recommended mapping maps the seeded chart in one click (new installs and new companies are mapped automatically; existing accounts are never re-mapped).
  • Each balance sheet line has a cash-flow class — cash, operating (working capital), non-cash adjustment, investing or financing — which an account can override.
  • Accounts without a line are reported on an "unmapped" line of their section (and as unclassified cash flow), so the statements always add up; the screens say how many are left.

Financial Statements (reports.financial-statements.view): one screen with three tabs (React and Blade), GET /reports/statements/{balance-sheet|income-statement|cash-flow}, and CSV / Excel / PDF exports (/reports/statement-balance-sheet/export/pdf …).

  • Balance sheet as of a date, optional comparative date; profit not yet closed shows in equity; totals and a difference check. Every line expands to its accounts.
  • Income statement for a period, optional comparative period, with gross profit, operating profit, profit before tax and net profit.
  • Cash flow — indirect method: profit for the period, non-cash adjustments (depreciation …), changes in working capital, investing and financing activities, then opening and closing cash. Year-end closing entries are left out, and because every entry balances, opening cash + net change = closing cash — any difference would point at a data problem.

Reports & exports

Report Web API Notes
Trial balance ✓ reports/trial-balance totals must be equal
Balance sheet ✓ reports/balance-sheet as_of_date
Income statement ✓ reports/income-statement date_from, date_to
Cash flow ✓ reports/cash-flow direct method: cash & bank movements, period totals; paginated
General ledger ✓ reports/general-ledger filter by account, dates, status; paginated
Account statement ✓ reports/account-statement opening balance, running balance, closing balance; paginated
Bank book / cash book ✓ reports/bank-book, cash-book
Aged receivables / payables ✓ reports/aged-receivables, aged-payables 30/60/90 buckets

Every report exports to CSV, Excel (XLSX) and PDF (/accounting/reports/{report}/export/{csv|xlsx|pdf} and the same under the API, with the same filters; buttons on every React and Blade report). The permission is checked before any data is read.

  • CSV is streamed row by row, so it works for millions of lines.
  • PDF is typeset with dompdf when installed (composer require dompdf/dompdf): company letterhead (logo, address, NTN), report title, the filters used, right-aligned amounts, debit/credit totals, "generated by … on …" and Page X of Y. Without dompdf a built-in renderer produces a plain PDF.
  • Big Excel/PDF exports run in the background. Above ACCOUNTING_EXPORT_MAX_XLSX_ROWS (50,000) / ACCOUNTING_EXPORT_MAX_PDF_ROWS (2,000) rows the export is queued and shows up under My Exports (/accounting/exports), where only its owner can download it. The job re-checks the permission when it runs. Run a queue worker (php artisan queue:work) in production, and schedule accounting:prune-exports daily (keeps 7 days, ACCOUNTING_EXPORTS_KEEP_DAYS). ACCOUNTING_QUEUE_LARGE_EXPORTS=false refuses big exports with a 422 instead.

Printing vouchers

Every journal entry prints as a voucher — Print (browser) and PDF buttons on the entry page, or /accounting/journal-entries/{id}/print · /pdf · GET /api/v1/accounting/journal-entries/{id}/pdf: voucher type and number, dates, source document, lines with cost centers, totals, amount in words ("PKR One Thousand Two Hundred Fifty and 50/100 Only", or Lakh/Crore with ACCOUNTING_NUMBER_SYSTEM=south_asian), Prepared / Approved / Posted / Received signature boxes, and a DRAFT / VOID / REVERSED watermark.

// config/accounting.php
'pdf' => [
    'engine' => env('ACCOUNTING_PDF_ENGINE', 'auto'),   // auto | dompdf | builtin
    'paper' => env('ACCOUNTING_PDF_PAPER', 'a4'),
    'logo' => env('ACCOUNTING_PDF_LOGO'),                // path on ACCOUNTING_PDF_LOGO_DISK; a company's own logo wins
    'number_system' => env('ACCOUNTING_NUMBER_SYSTEM', 'international'),   // or south_asian (Lakh, Crore)
],

Performance with 400,000 posted lines (PostgreSQL 16, 4 vCPU): trial balance 0.77 s, balance sheet 0.32 s, general ledger page 0.09–0.38 s, account statement 0.11 s, 46 postings/s from 24 concurrent clients with zero imbalance. Full numbers: docs/performance.md.

Full REST API Reference

Base URL: /api/v1/accounting — ACCOUNTING_API_PREFIX; middleware ACCOUNTING_API_MIDDLEWARE (default api,auth:sanctum) plus throttle:accounting-api (ACCOUNTING_API_RATE_LIMIT, default 120/min per user). Every endpoint checks a permission. Token abilities: with ACCOUNTING_API_ENFORCE_TOKEN_ABILITIES=true (accounting.api_enforce_token_abilities, off by default) a Sanctum token needs accounting:read to read (GET) and accounting:write for anything else (read/write and * also work), e.g. $user->createToken('reporting', ['accounting:read']). Permissions are always checked as well.

Amounts: a transaction amount may not exceed accounting.max_amount (ACCOUNTING_MAX_AMOUNT, default 999,999,999,999.99; the database columns hold more, DECIMAL(18,2)). Larger values answer 422. Machine-readable spec: docs/openapi.yaml. Every screen and endpoint with its permission: the help catalog.

328 endpoints, generated from the routes by docs/generate-route-catalog.py. Screens: see the help catalog.

Journal entries

Method Endpoint What it does Permission
GET /journal-entries/{journalEntry}/pdf Printable voucher PDF journal-entries.view
GET /journal-entries/{journalEntry}/attachments Supporting documents of an entry attachments.view
POST /journal-entries/{journalEntry}/attachments Attach a document (multipart/form-data) attachments.create
GET /attachments/{attachment}/download Download a document attachments.view
DELETE /attachments/{attachment} Remove a document of a draft attachments.delete
GET /opening-balances The opening-balance entry of the company, if one is posted journal-entries.view
POST /opening-balances Post the balances the company starts with journal-entries.post
GET /journal-entries List entries journal-entries.view
POST /journal-entries Create (and optionally post) an entry journal-entries.create
POST /journal-entries/simple Two-line entry by account codes journal-entries.create
GET /journal-entries/{journalEntry} Show an entry with lines journal-entries.view
PUT /journal-entries/{journalEntry} Update a draft (PATCH also accepted) journal-entries.update
POST /journal-entries/{journalEntry}/post Post a draft journal-entries.post
POST /journal-entries/{journalEntry}/reverse Reverse a posted entry journal-entries.reverse
POST /journal-entries/{journalEntry}/submit Submit a draft for approval (maker) journal-entries.create
POST /journal-entries/{journalEntry}/approve Approve and post (checker) journal-entries.approve
POST /journal-entries/{journalEntry}/reject Reject back to the maker (checker) journal-entries.approve
POST /journal-entries/{journalEntry}/void Void a draft journal-entries.void

Chart of accounts

Method Endpoint What it does Permission
GET /chart-of-accounts List accounts chart-of-accounts.view
POST /chart-of-accounts Create an account chart-of-accounts.create
GET /chart-of-accounts/export/{format} Export the chart (re-importable) chart-of-accounts.view
GET /chart-of-accounts/import/template/{format} Import template with example rows chart-of-accounts.import
POST /chart-of-accounts/import Import accounts from CSV or Excel (multipart/form-data) chart-of-accounts.import
GET /chart-of-accounts/{chartOfAccount}/renumber-preview Preview a renumbering chart-of-accounts.restructure
POST /chart-of-accounts/{chartOfAccount}/renumber Renumber an account (used accounts too) chart-of-accounts.restructure
GET /chart-of-accounts/{chartOfAccount}/merge-preview Preview merging this account into another chart-of-accounts.restructure
POST /chart-of-accounts/{chartOfAccount}/merge Merge this account into another chart-of-accounts.restructure
GET /chart-templates Industry chart templates chart-templates.apply
GET /chart-templates/{template} What a template would add to this company chart-templates.apply
POST /chart-templates/{template}/apply Add a template's accounts to this company chart-templates.apply
GET /chart-of-accounts/tree Whole chart as a nested tree chart-of-accounts.view
GET /chart-of-accounts/{chartOfAccount}/balance Account balance chart-of-accounts.view
GET /chart-of-accounts/{chartOfAccount} Show an account chart-of-accounts.view
PUT /chart-of-accounts/{chartOfAccount} Update an account (PATCH also accepted) chart-of-accounts.update
DELETE /chart-of-accounts/{chartOfAccount} Delete an account chart-of-accounts.delete
GET /control-accounts Control accounts with balances and manual postings chart-of-accounts.view
POST /control-accounts/recommended Mark the recommended control accounts control-accounts.manage
GET /control-accounts/{chartOfAccount}/manual-postings Manual postings to a control account chart-of-accounts.view
PUT /chart-of-accounts/{chartOfAccount}/control-type Set or clear an account's control type control-accounts.manage

Reports

Method Endpoint What it does Permission
GET /report-mapping Report lines and the mapping of every account report-mapping.manage
POST /report-mapping/recommended Map the seeded accounts to their standard lines report-mapping.manage
PUT /chart-of-accounts/{chartOfAccount}/report-mapping Map an account (and its sub-accounts) to a line report-mapping.manage
POST /report-lines Add a statement line report-mapping.manage
PUT /report-lines/{reportLine} Rename, move or reclassify a line report-mapping.manage
DELETE /report-lines/{reportLine} Delete a custom line report-mapping.manage
GET /reports/statements/{type} Financial statement by report lines reports.financial-statements.view
GET /reports/{report}/export/{format} Download a report as CSV, Excel or a typeset PDF accounting.view
POST /reports/{report}/exports/{format} Queue a background export accounting.view
GET /exports Your background exports accounting.view
GET /exports/{export}/download Download a finished export accounting.view
DELETE /exports/{export} Delete one of your exports and its file accounting.view
GET /reports/consolidated/{report} Consolidated report reports.consolidated.view
GET /reports/trial-balance Trial balance reports.trial-balance.view
GET /reports/balance-sheet Balance sheet reports.balance-sheet.view
GET /reports/income-statement Income statement reports.income-statement.view
GET /reports/general-ledger General ledger reports.general-ledger.view
GET /reports/cash-flow Cash flow reports.cash-flow.view
GET /reports/bank-book Bank book reports.bank-book.view
GET /reports/cash-book Cash book reports.cash-book.view
GET /reports/aged-receivables Aged receivables reports.aged-receivables.view
GET /reports/aged-payables Aged payables reports.aged-payables.view
GET /reports/account-statement Account statement reports.account-statement.view
GET /reports/branch-comparison Branch comparison reports.income-statement.view

Periods

Method Endpoint What it does Permission
POST /periods/generate-monthly Create twelve monthly periods periods.create
GET /periods List periods periods.view
POST /periods Create periods periods.create
GET /periods/{record} Show periods periods.view
PUT /periods/{record} Update periods (PATCH also accepted) periods.update
DELETE /periods/{record} Delete periods periods.delete
GET /periods/{period}/close-checklist Pre-close checklist periods.view
POST /periods/{period}/close Close a period periods.close
POST /periods/{period}/reopen Reopen a closed period periods.reopen
POST /periods/{period}/close-fiscal-year Year-end close periods.close
GET /account-balance-snapshots List balance snapshots account-balance-snapshots.view
GET /account-balance-snapshots/{record} Show a balance snapshot account-balance-snapshots.view

Account types

Method Endpoint What it does Permission
GET /account-types List account-types account-types.view
POST /account-types Create account-types account-types.create
GET /account-types/{record} Show account-types account-types.view
PUT /account-types/{record} Update account-types (PATCH also accepted) account-types.update
DELETE /account-types/{record} Delete account-types account-types.delete

Currencies

Method Endpoint What it does Permission
GET /currencies List currencies currencies.view
POST /currencies Create currencies currencies.create
GET /currencies/{record} Show currencies currencies.view
PUT /currencies/{record} Update currencies (PATCH also accepted) currencies.update
DELETE /currencies/{record} Delete currencies currencies.delete

Cost centers

Method Endpoint What it does Permission
GET /cost-centers List cost-centers cost-centers.view
POST /cost-centers Create cost-centers cost-centers.create
GET /cost-centers/{record} Show cost-centers cost-centers.view
PUT /cost-centers/{record} Update cost-centers (PATCH also accepted) cost-centers.update
DELETE /cost-centers/{record} Delete cost-centers cost-centers.delete

Bank accounts

Method Endpoint What it does Permission
GET /bank-accounts List bank-accounts bank-accounts.view
POST /bank-accounts Create bank-accounts bank-accounts.create
GET /bank-accounts/{record} Show bank-accounts bank-accounts.view
PUT /bank-accounts/{record} Update bank-accounts (PATCH also accepted) bank-accounts.update
DELETE /bank-accounts/{record} Delete bank-accounts bank-accounts.delete

Reconciliations

Method Endpoint What it does Permission
GET /reconciliations List reconciliations reconciliations.view
POST /reconciliations Create reconciliations reconciliations.create
GET /reconciliations/{record} Show reconciliations reconciliations.view
PUT /reconciliations/{record} Update reconciliations (PATCH also accepted) reconciliations.update
DELETE /reconciliations/{record} Delete reconciliations reconciliations.delete

Tax

Method Endpoint What it does Permission
POST /tax/calculate Tax on an amount tax-codes.view
POST /tax/entries Book a taxed document as a journal entry tax-entries.create
GET /tax/returns/report The tax ledger of a period tax-returns.view
GET /tax/returns/report/export/{format} Export the tax report tax-returns.view
GET /tax/returns Filed tax returns tax-returns.view
POST /tax/returns File a return for a period tax-returns.file
GET /tax/returns/{taxReturn} A filed return tax-returns.view
DELETE /tax/returns/{taxReturn} Void a return (its entry is reversed) tax-returns.file
GET /tax-codes List tax-codes tax-codes.view
POST /tax-codes Create tax-codes tax-codes.create
GET /tax-codes/{record} Show tax-codes tax-codes.view
PUT /tax-codes/{record} Update tax-codes (PATCH also accepted) tax-codes.update
DELETE /tax-codes/{record} Delete tax-codes tax-codes.delete
GET /tax-rates List tax-rates tax-rates.view
POST /tax-rates Create tax-rates tax-rates.create
GET /tax-rates/{record} Show tax-rates tax-rates.view
PUT /tax-rates/{record} Update tax-rates (PATCH also accepted) tax-rates.update
DELETE /tax-rates/{record} Delete tax-rates tax-rates.delete

Recurring entries

Method Endpoint What it does Permission
GET /recurring-entries Recurring entry templates recurring-entries.view
POST /recurring-entries Create a template recurring-entries.create
GET /recurring-entries/{recurringEntry} A template with its generated entries and upcoming dates recurring-entries.view
PUT /recurring-entries/{recurringEntry} Change a template (PATCH also accepted) recurring-entries.update
DELETE /recurring-entries/{recurringEntry} Delete a template that has not generated anything recurring-entries.delete
POST /recurring-entries/{recurringEntry}/pause Pause a template recurring-entries.update
POST /recurring-entries/{recurringEntry}/resume Resume a paused template recurring-entries.update
POST /recurring-entries/{recurringEntry}/run Generate the next entry now recurring-entries.run

Voucher types

Method Endpoint What it does Permission
GET /voucher-types List voucher types with their next numbers voucher-types.view
POST /voucher-types Create a voucher type voucher-types.create
GET /voucher-types/{record} Show a voucher type voucher-types.view
PUT /voucher-types/{record} Update a voucher type (PATCH also accepted) voucher-types.update
DELETE /voucher-types/{record} Delete an unused voucher type voucher-types.delete
GET /voucher-types/{record}/next-number Preview the next number voucher-types.view

Branches

Method Endpoint What it does Permission
GET /branches Branches of the company branches.view
POST /branches Create a branch branches.create
GET /branches/{branch} Show a branch with the people who work in it branches.view
PUT /branches/{branch} Update a branch branches.update
DELETE /branches/{branch} Delete a branch branches.delete
POST /branches/{branch}/users Limit a user to this branch (add) branches.update
DELETE /branches/{branch}/users/{user} Remove a user from this branch branches.update

Companies

Method Endpoint What it does Permission
GET /companies Companies you can work in accounting.view
POST /companies Create a company companies.manage
GET /companies/{company} Show a company with its members companies.manage
PUT /companies/{company} Update a company companies.manage
POST /companies/{company}/users Give a user access companies.manage
DELETE /companies/{company}/users/{user} Remove a user's access companies.manage

Users & roles

Method Endpoint What it does Permission
GET /users List users user.view
POST /users Create a user user.create
GET /users/{user} Show a user with all effective permissions user.view
PUT /users/{user} Update a user (PATCH also accepted) user.update
DELETE /users/{user} Delete a user user.delete
PUT /users/{user}/roles Replace a user's roles user.assign-role
PUT /users/{user}/permissions Replace a user's direct permissions user.assign-permission
GET /roles List roles with permission and user counts accounting.manage-settings
POST /roles Create a role accounting.manage-settings
GET /roles/{role} Show a role accounting.manage-settings
PUT /roles/{role} Update a role (PATCH also accepted) accounting.manage-settings
DELETE /roles/{role} Delete a role accounting.manage-settings
GET /permissions All permissions grouped by area accounting.manage-settings

System

Method Endpoint What it does Permission
GET /dashboard Dashboard overview accounting.view
GET /health Installation health accounting.view

Bank statements

Method Endpoint What it does Permission
GET /bank-statements Imported statements bank-statements.view
POST /bank-statements/import Import a statement (CSV or XLSX) bank-statements.import
GET /bank-statements/{bankStatement} A statement with its transactions bank-statements.view
PUT /bank-statements/{bankStatement} Set the closing balance (PATCH also accepted) bank-statements.match
DELETE /bank-statements/{bankStatement} Delete a statement and release its matches bank-statements.match
POST /bank-statements/{bankStatement}/auto-match Match every transaction that has exactly one ledger candidate bank-statements.match
POST /bank-statements/{bankStatement}/reconcile Reconcile the statement bank-statements.match
GET /bank-statement-lines/{bankStatementLine}/candidates Ledger lines that could be this transaction bank-statements.view
POST /bank-statement-lines/{bankStatementLine}/match Match a transaction to a ledger line bank-statements.match
POST /bank-statement-lines/{bankStatementLine}/unmatch Release a matched or booked transaction bank-statements.match
POST /bank-statement-lines/{bankStatementLine}/ignore Ignore (or restore) a transaction bank-statements.match
POST /bank-statement-lines/{bankStatementLine}/create-entry Book a transaction as a journal entry bank-statements.match

Budgets

Method Endpoint What it does Permission
GET /budgets Budgets budgets.view
POST /budgets Create a draft budget budgets.create
GET /budgets/{budget} Budget against actual budgets.view
PUT /budgets/{budget} Change a draft budget (PATCH also accepted) budgets.update
DELETE /budgets/{budget} Delete a budget that is not approved budgets.delete
POST /budgets/{budget}/approve Approve a draft budget budgets.approve
POST /budgets/{budget}/reopen Reopen an approved or closed budget as a draft budgets.update
POST /budgets/{budget}/close Close an approved budget budgets.approve
POST /budgets/{budget}/copy Copy as a draft, optionally shifted and raised budgets.create
GET /budgets/{budget}/export/{format} Export budget against actual budgets.view

Currency revaluation

Method Endpoint What it does Permission
GET /fx-revaluation Past revaluations and the dated exchange rates fx-revaluation.view
GET /fx-revaluation/preview What a revaluation would adjust (nothing is posted) fx-revaluation.view
POST /fx-revaluation Post a revaluation fx-revaluation.run
POST /fx-revaluation/rates Save a dated exchange rate (replaces the rate of that currency and date) fx-revaluation.rates
DELETE /fx-revaluation/rates/{exchangeRate} Remove a dated rate fx-revaluation.rates
GET /fx-revaluation/{fxRevaluation} One revaluation with its accounts fx-revaluation.view
POST /fx-revaluation/{fxRevaluation}/reverse Reverse a revaluation fx-revaluation.run

Fixed assets

Method Endpoint What it does Permission
GET /fixed-assets Fixed asset register at a date fixed-assets.view
POST /fixed-assets Register an asset fixed-assets.create
GET /fixed-assets/depreciation Preview the depreciation due fixed-assets.view
POST /fixed-assets/depreciation Book the depreciation due fixed-assets.depreciate
GET /fixed-assets/export/{format} Export the register fixed-assets.view
GET /fixed-assets/{asset} An asset with its depreciation history fixed-assets.view
PUT /fixed-assets/{asset} Change an asset (PATCH also accepted) fixed-assets.update
DELETE /fixed-assets/{asset} Delete an asset without entries fixed-assets.delete
POST /fixed-assets/{asset}/dispose Sell or scrap an asset fixed-assets.dispose

Inventory

Method Endpoint What it does Permission
GET /inventory Stock valuation at a date inventory.view
GET /inventory/export/{format} Export the stock valuation inventory.view
POST /inventory/items Create an item inventory.manage
GET /inventory/items/{item} An item with its stock card inventory.view
PUT /inventory/items/{item} Change an item (PATCH also accepted) inventory.manage
DELETE /inventory/items/{item} Delete an item without movements inventory.manage
GET /inventory/warehouses Warehouses inventory.view
POST /inventory/warehouses Add a warehouse inventory.manage
PUT /inventory/warehouses/{warehouse} Change a warehouse (PATCH also accepted) inventory.manage
DELETE /inventory/warehouses/{warehouse} Delete a warehouse without movements inventory.manage
GET /inventory/movements The latest 200 stock movements inventory.view
POST /inventory/movements Receive, issue, adjust or transfer stock inventory.move

Payroll

Method Endpoint What it does Permission
GET /payroll Payroll runs, newest first payroll.view
POST /payroll/runs Start a payroll run for a month payroll.run
GET /payroll/runs/{run} A run with its payslips and their lines payroll.view
POST /payroll/runs/{run}/recalculate Work the payslips out again (draft runs) payroll.run
POST /payroll/runs/{run}/post Post the run to the books payroll.post
POST /payroll/runs/{run}/pay Pay the net salaries payroll.post
POST /payroll/runs/{run}/void Void a posted or paid run payroll.void
DELETE /payroll/runs/{run} Delete a draft run payroll.run
GET /payroll/runs/{run}/payslips/{payslip} One payslip payroll.view
GET /payroll/employees Employees payroll.view
POST /payroll/employees Add an employee payroll.manage
GET /payroll/employees/{employee} An employee with their allowances and deductions payroll.view
PUT /payroll/employees/{employee} Change an employee (PATCH also accepted) payroll.manage
DELETE /payroll/employees/{employee} Delete an employee without payslips payroll.manage
GET /payroll/components Allowances and deductions payroll.view
POST /payroll/components Add an allowance or deduction payroll.manage
PUT /payroll/components/{component} Change a component (PATCH also accepted) payroll.manage
DELETE /payroll/components/{component} Delete a component that is not in use payroll.manage
GET /payroll/grades Salary grades payroll.view
POST /payroll/grades Add a salary grade payroll.manage
GET /payroll/grades/{grade} A grade with its allowances payroll.view
PUT /payroll/grades/{grade} Change a grade (PATCH also accepted) payroll.manage
DELETE /payroll/grades/{grade} Delete a grade nobody is on payroll.manage
POST /payroll/grades/{grade}/assign Put a group of employees on the grade payroll.manage
POST /payroll/bulk/components Give a component to a group of employees, or take it away payroll.manage
GET /payroll/employees/{employee}/revisions The salary history of an employee payroll.view
POST /payroll/employees/{employee}/revisions Revise an employee's salary from a date payroll.manage
POST /payroll/revisions/preview Preview a raise for a group (nothing is saved) payroll.manage
POST /payroll/revisions Apply a raise to a group payroll.manage
GET /payroll/arrears The arrears register with totals payroll.view
POST /payroll/arrears/preview Work arrears out without saving payroll.run
POST /payroll/arrears Create arrears (drafts) from the posted payslips payroll.run
POST /payroll/arrears/approve-all Approve every arrears draft payroll.post
GET /payroll/arrears/{arrear} One arrears record with its months payroll.view
POST /payroll/arrears/{arrear}/approve Approve arrears payroll.post
POST /payroll/arrears/{arrear}/cancel Cancel arrears not yet in a run payroll.post
GET /payroll/attendance The attendance sheet of a month payroll.view
POST /payroll/attendance Save the attendance sheet of a month payroll.manage
GET /payroll/leave-types Leave types payroll.view
POST /payroll/leave-types Add a leave type payroll.manage
PUT /payroll/leave-types/{leaveType} Change a leave type (PATCH also accepted) payroll.manage
DELETE /payroll/leave-types/{leaveType} Delete a leave type nobody took payroll.manage
GET /payroll/leaves Leaves, newest first payroll.view
GET /payroll/leaves/balances Leave balances of a year payroll.view
POST /payroll/leaves Record leave payroll.manage
POST /payroll/leaves/{leave}/cancel Cancel a leave payroll.manage
GET /payroll/loans Loans and advances payroll.view
POST /payroll/loans Record a loan or advance with its instalment schedule payroll.manage
GET /payroll/loans/{loan} A loan with its schedule payroll.view
POST /payroll/loans/{loan}/disburse Pay the loan out payroll.post
POST /payroll/loans/{loan}/settle The employee pays back what is left in cash payroll.post
POST /payroll/loans/{loan}/skip Move the next instalment to the end of the schedule payroll.manage
POST /payroll/loans/{loan}/cancel Cancel a loan not yet paid out payroll.manage
GET /payroll/schemes Contribution schemes (EOBI, PESSI/SESSI, provident fund) payroll.view
POST /payroll/schemes Add a contribution scheme payroll.manage
GET /payroll/schemes/{scheme} A scheme payroll.view
PUT /payroll/schemes/{scheme} Change a scheme (PATCH also accepted) payroll.manage
DELETE /payroll/schemes/{scheme} Delete a scheme payroll.manage
POST /payroll/schemes/{scheme}/assign Give a scheme to employees or take it away payroll.manage
GET /payroll/runs/{run}/bank-file/{format} The bank salary file of a posted run payroll.post
GET /payroll/runs/{run}/payslips/{payslip}/print A payslip as a printable page payroll.view
GET /payroll/runs/{run}/payslips/{payslip}/pdf A payslip as a PDF payroll.view
POST /payroll/runs/{run}/payslips/{payslip}/email E-mail one payslip to the employee payroll.manage
POST /payroll/runs/{run}/email-payslips E-mail every payslip of a posted run payroll.manage
POST /payroll/runs/{run}/submit HR submits a draft run to finance payroll.run
POST /payroll/runs/{run}/withdraw Take a submitted or approved run back to draft payroll.run
POST /payroll/runs/{run}/approve Finance approves a submitted run payroll.approve
POST /payroll/runs/{run}/reject Finance sends a submitted run back to HR payroll.approve
GET /payroll/adjustments Bonuses and other one-off pay of a month payroll.view
POST /payroll/adjustments Add a bonus, extra allowance or fine for one employee and month payroll.manage
POST /payroll/adjustments/bulk The same bonus for every active employee (or the ones named) payroll.manage
POST /payroll/adjustments/{adjustment}/cancel Cancel an adjustment no run has taken up payroll.manage
GET /payroll/settlements Final settlements payroll.view
POST /payroll/settlements/preview Work a settlement out without saving payroll.manage
POST /payroll/settlements Save a settlement as a draft payroll.manage
GET /payroll/settlements/{settlement} One settlement payroll.view
DELETE /payroll/settlements/{settlement} Delete a draft settlement payroll.manage
POST /payroll/settlements/{settlement}/post Post a settlement to the books payroll.post
POST /payroll/settlements/{settlement}/pay Pay a posted settlement payroll.post
POST /payroll/settlements/{settlement}/void Void a settlement payroll.void
GET /payroll/reports Month comparison, cost centers and headcount payroll.view
GET /payroll/reports/{report}/export/{format} Download a payroll report payroll.view
GET /payroll/tax Salary and tax withheld per employee for a tax year payroll.view
GET /payroll/tax/annual/{format} Download the annual salary tax statement payroll.view
GET /payroll/tax/certificate/{employee} An employee's salary tax certificate payroll.view

Receivables & payables

Method Endpoint What it does Permission
GET /parties Customers and suppliers parties.view
POST /parties Create a customer or supplier parties.create
GET /parties/{party} A party with its balance and open items parties.view
PUT /parties/{party} Change a party (PATCH also accepted) parties.update
DELETE /parties/{party} Delete a party without documents or payments parties.delete
GET /parties/{party}/statement Statement of account with a running balance parties.view
GET /party-documents Invoices, bills, credit and debit notes party-documents.view
POST /party-documents Create a draft document party-documents.create
GET /party-documents/{partyDocument} A document with its lines party-documents.view
PUT /party-documents/{partyDocument} Change a draft (PATCH also accepted) party-documents.update
DELETE /party-documents/{partyDocument} Delete a draft party-documents.delete
POST /party-documents/{partyDocument}/post Number and post a draft party-documents.post
POST /party-documents/{partyDocument}/void Void a posted document (its entry is reversed) party-documents.void
POST /party-documents/{partyDocument}/apply Apply a credit or debit note to invoices or bills party-documents.post
GET /party-payments Receipts and payments party-payments.view
POST /party-payments Record a receipt from a customer or a payment to a supplier party-payments.create
GET /party-payments/{partyPayment} A payment with its allocations party-payments.view
POST /party-payments/{partyPayment}/allocate Allocate (more of) a payment to documents party-payments.create
POST /party-payments/{partyPayment}/void Void a payment (entry reversed, allocations released) party-payments.void
DELETE /party-allocations/{partyAllocation} Undo an allocation party-payments.create
GET /receivables/aging Ageing by customer or supplier, checked against the control account party-documents.view
GET /receivables/aging/export/{format} Export the ageing party-documents.view

Settings

Method Endpoint What it does Permission
GET /features Every optional module and payroll feature with its switch accounting.manage-settings
PUT /features Switch features on or off accounting.manage-settings
GET /account-roles Which account plays which part for the modules (cash, bank, salary expense, receivables …) accounting.manage-settings
PUT /account-roles Give roles other accounts accounting.manage-settings

WhatsApp

Method Endpoint What it does Permission
GET /whatsapp/webhook Meta's webhook verification
POST /whatsapp/webhook Meta's events: message statuses (sent, delivered, read, failed) and customer replies
GET /whatsapp Messages, the set-up checks (what is set in .env and what is missing) and the configured templates whatsapp.view
POST /whatsapp/messages Send a message: an approved template with its parameters, or free text whatsapp.send
POST /whatsapp/messages/{message}/retry Send a failed message again whatsapp.send
POST /whatsapp/messages/{message}/simulate Sandbox only: move a message on as WhatsApp would whatsapp.send
POST /whatsapp/simulate-reply Sandbox only: a customer writes back (opens the 24-hour window for free text) whatsapp.send

List endpoints support ?filter[field]=value (or the short ?field=value; see Filtering and searching lists), ?sort=field / -field, ?page=N and ?per_page=N (capped by ACCOUNTING_API_MAX_PER_PAGE, default 100). Journal entries also filter by filter[approval_status]=pending|approved|rejected, filter[voucher_number] (partial), filter[source_document_number] (partial), filter[source_document_type], filter[voucher_type_id] and filter[entry_date_from] / filter[entry_date_to]. Ledger-style reports (general ledger, account statement, cash flow, bank & cash book) are paginated and return totals for the whole filter, not just the page.

Configuration

php artisan vendor:publish --tag=accounting-config
// config/accounting.php (abridged)
return [
    'ui_driver'              => env('ACCOUNTING_UI_DRIVER', 'inertia'),   // 'inertia' | 'blade' | 'api'
    'route_prefix'           => env('ACCOUNTING_ROUTE_PREFIX', 'accounting'),
    'route_name_prefix'      => env('ACCOUNTING_ROUTE_NAME_PREFIX', 'accounting'),
    'settings_route_prefix'  => env('SETTINGS_ROUTE_PREFIX', 'settings'),
    'api_prefix'             => env('ACCOUNTING_API_PREFIX', 'api/v1/accounting'),
    'api_middleware'         => env('ACCOUNTING_API_MIDDLEWARE', 'api,auth:sanctum'), // comma separated
    'api_enabled'            => env('ACCOUNTING_API_ENABLED', true),
    'api_rate_limit'         => env('ACCOUNTING_API_RATE_LIMIT', 120),     // per minute per user; 0 = off
    'api_max_per_page'       => env('ACCOUNTING_API_MAX_PER_PAGE', 100),
    'users_table'            => env('ACCOUNTING_USERS_TABLE', 'users'),
    'multi_company' => [
        'enabled'              => env('ACCOUNTING_MULTI_COMPANY', false),
        'header'               => env('ACCOUNTING_COMPANY_HEADER', 'X-Company'),
        'default_company_code' => env('ACCOUNTING_DEFAULT_COMPANY', 'MAIN'),
    ],
    'export_max_rows'        => ['xlsx' => 50000, 'pdf' => 2000],        // ACCOUNTING_EXPORT_MAX_*_ROWS; CSV is unlimited
    'approvals' => [
        'enabled'             => env('ACCOUNTING_APPROVALS_ENABLED', false),
        'threshold'           => env('ACCOUNTING_APPROVAL_THRESHOLD', '0'),     // base currency; 0 = all entries
        'allow_self_approval' => env('ACCOUNTING_ALLOW_SELF_APPROVAL', false),
    ],
    'webhooks' => [
        'urls'    => env('ACCOUNTING_WEBHOOK_URLS', ''),        // comma separated
        'secret'  => env('ACCOUNTING_WEBHOOK_SECRET'),
        'timeout' => env('ACCOUNTING_WEBHOOK_TIMEOUT', 10),
        'tries'   => env('ACCOUNTING_WEBHOOK_TRIES', 5),
    ],
    'defaults' => [
        'currency_code'                  => env('ACCOUNTING_BASE_CURRENCY', 'PKR'),
        'cash_account_code'              => env('ACCOUNTING_CASH_ACCOUNT_CODE'),            // null: the account with the "cash" role
        'bank_account_code'              => env('ACCOUNTING_BANK_ACCOUNT_CODE'),
        'retained_earnings_account_code' => env('ACCOUNTING_RETAINED_EARNINGS_ACCOUNT_CODE'),
        'rounding_account_code'          => env('ACCOUNTING_ROUNDING_ACCOUNT_CODE'),
    ],
    'aging' => [
        'receivable_account_codes' => null,   // null: the accounts with the "aging.receivables" role
        'payable_account_codes'    => null,
    ],
    'chart_preset' => env('ACCOUNTING_CHART_PRESET', 'erpnext'), // 'erpnext', 'general' or 'school'
    'permissions'  => [/* … */],
    'roles'        => [/* super-admin, admin, accountant, approver, auditor, viewer */],
];

The defaults.*_account_code and aging.* values are optional overrides: leave them out and the accounts that play each role in the company's chart (see Account roles) are used. An account that plays cash, bank, retained earnings or rounding (by role or by one of these overrides) is protected: its code cannot change and it cannot be deactivated, deleted or merged away.

The web routes always use ['web', 'auth', 'verified'] plus a per-route can: permission check.

Artisan Commands

Command Description
accounting:install Full setup: publish all assets, migrate, seed, sync, verify (--chart=erpnext|general|school)
accounting:update Re-publish assets + sync DB after package upgrade
accounting:seed Seed account types, currencies, COA, permissions, periods (--chart=, --company=)
accounting:sync-db-objects Sync database views, triggers, stored procedures
accounting:verify Verify accounting data integrity
accounting:health-check Run accounting health checks
accounting:rebuild-snapshots Rebuild account balance snapshots
accounting:close-fiscal-year Close the current fiscal year
accounting:close-period Close an accounting period (snapshots, totals, net income)
accounting:open-period Reopen a closed accounting period
accounting:roles Print the role × permission matrix; fails if a role can both create and approve entries
accounting:prune-exports Delete background exports older than accounting.exports.keep_days with their files
accounting:run-recurring Generate the recurring entries that are due (--date=, --company=, --dry-run); scheduled daily
accounting:account-roles Show which account plays which role (cash, bank, salary expense …) for a company, set a role, or seed the roles of a chart (--company=, --overwrite)
accounting:chart-templates List the industry chart templates, preview (--dry-run) or add one to a company (--company=)
accounting:create-company Create a company with its own chart, fiscal year, cost centers and tax codes (--fiscal-start, --user, --empty, --template=)

Models & Tables

Model Table Description
Company accounting_companies Companies; accounting_company_user holds who may use each
AccountType accounting_account_types Asset, Liability, Equity, Revenue, Expense (shared)
ChartOfAccount accounting_chart_of_accounts Hierarchical account tree
Currency accounting_currencies Currencies and exchange rates
AccountingPeriod accounting_periods Fiscal periods with open/close state
JournalEntry accounting_journal_entries Entry header (draft / posted / void), voucher type and number
VoucherType accounting_voucher_types Voucher types (JV, CPV, CRV, BPV, BRV, …) and their number format
VoucherSequence accounting_voucher_sequences Next number per voucher type and fiscal year / month
JournalEntryLine accounting_journal_entry_lines Debit/credit lines
BankAccount accounting_bank_accounts Bank account register
Reconciliation accounting_reconciliations Bank reconciliation records
TaxCode accounting_tax_codes Tax code definitions
TaxRate accounting_tax_rates Tax rates per code
AccountingAuditLog accounting_audit_logs Full change audit trail
AccountBalanceSnapshot accounting_account_balance_snapshots Period-end snapshots
CostCenter accounting_cost_centers Departmental cost centers

Roles & permissions

Six roles are seeded, designed around segregation of duties (the person who records an entry is not the person who approves it):

Role Who Can Cannot
super-admin Owner Everything Approve its own entries under maker-checker
admin IT / user admin Users, roles, permissions, companies and who can use them, voucher types Record, post, approve or close anything
accountant Maker Record, edit, post (below the approval threshold), reverse, void drafts, close periods, bank & reconciliations, tax, FX rates Approve, reopen periods, manage users/roles
approver Checker Approve or reject entries; read ledger & reports Create, edit or post entries
auditor Internal / external audit Read everything incl. the audit trail Change anything
viewer Management Read ledger & reports Change anything
php artisan accounting:roles            # who-can-what matrix from the database + SoD check
php artisan accounting:roles --config   # the shipped defaults

accounting:roles exits non-zero if any role other than super-admin can both create and approve journal entries — handy as a deployment check.

Safe to customise: change roles in the Settings → Roles screen. Re-running accounting:seed (e.g. after an upgrade) never removes permissions you changed — it only adds newly introduced ones.

Privilege-escalation protection: a user can only grant roles/permissions they hold themselves, only a super-admin can manage super-admin users or the super-admin role, changing a user's roles requires user.assign-role (direct permissions: user.assign-permission), role management requires accounting.manage-settings.

Full role × permission matrix (defaults)
Permission super-admin admin accountant approver auditor viewer
accounting.view ✔ ✔ ✔ ✔ ✔ ✔
accounting.manage-settings ✔ ✔
companies.manage ✔ ✔
reports.consolidated.view ✔ ✔ ✔ ✔
user.view ✔ ✔
user.create ✔ ✔
user.update ✔ ✔
user.delete ✔
user.assign-role ✔ ✔
user.assign-permission ✔ ✔
account-types.view ✔ ✔ ✔
account-types.create ✔
account-types.update ✔
account-types.delete ✔
currencies.view ✔ ✔ ✔
currencies.create ✔
currencies.update ✔ ✔
currencies.delete ✔
periods.view ✔ ✔ ✔
periods.create ✔
periods.update ✔
periods.delete ✔
periods.close ✔ ✔
periods.reopen ✔
chart-of-accounts.view ✔ ✔ ✔ ✔ ✔ ✔
chart-of-accounts.create ✔
chart-of-accounts.update ✔
chart-of-accounts.import ✔
chart-of-accounts.restructure ✔
chart-templates.apply ✔
recurring-entries.view ✔ ✔ ✔ ✔ ✔
recurring-entries.create ✔ ✔
recurring-entries.update ✔ ✔
recurring-entries.delete ✔ ✔
recurring-entries.run ✔ ✔
parties.view ✔ ✔ ✔ ✔ ✔
parties.create ✔ ✔
parties.update ✔ ✔
parties.delete ✔ ✔
party-documents.view ✔ ✔ ✔ ✔ ✔
party-documents.create / update / delete / post / void ✔ ✔
party-payments.view ✔ ✔ ✔ ✔ ✔
party-payments.create / void ✔ ✔
tax-returns.view ✔ ✔ ✔ ✔ ✔
tax-returns.file ✔ ✔
tax-entries.create ✔ ✔
budgets.view ✔ ✔ ✔ ✔ ✔
budgets.create ✔ ✔
budgets.update ✔ ✔
budgets.delete ✔ ✔
budgets.approve ✔ ✔
budgets.override ✔
fixed-assets.view ✔ ✔ ✔ ✔ ✔
fixed-assets.create ✔ ✔
fixed-assets.update ✔ ✔
fixed-assets.delete ✔ ✔
fixed-assets.depreciate ✔ ✔
fixed-assets.dispose ✔ ✔
inventory.view ✔ ✔ ✔ ✔ ✔
inventory.manage ✔ ✔
inventory.move ✔ ✔
payroll.view ✔ ✔ ✔ ✔ ✔
payroll.manage ✔ ✔
payroll.run ✔ ✔
payroll.post ✔ ✔
payroll.void ✔ ✔
whatsapp.view ✔ ✔ ✔ ✔ ✔
whatsapp.send ✔ ✔
bank-statements.view ✔ ✔ ✔ ✔ ✔
bank-statements.import ✔ ✔
bank-statements.match ✔ ✔
fx-revaluation.view ✔ ✔ ✔ ✔ ✔
fx-revaluation.run ✔ ✔
fx-revaluation.rates ✔ ✔
chart-of-accounts.delete ✔
cost-centers.view ✔ ✔ ✔
cost-centers.create ✔ ✔
cost-centers.update ✔ ✔
cost-centers.delete ✔
journal-entries.view ✔ ✔ ✔ ✔ ✔ ✔
journal-entries.create ✔ ✔
journal-entries.update ✔ ✔
journal-entries.delete ✔
journal-entries.post ✔ ✔
journal-entries.reverse ✔ ✔
journal-entries.void ✔ ✔
journal-entries.approve ✔ ✔
bank-accounts.view ✔ ✔ ✔
bank-accounts.create ✔ ✔
bank-accounts.update ✔ ✔
bank-accounts.delete ✔
reconciliations.view ✔ ✔ ✔
reconciliations.create ✔ ✔
reconciliations.update ✔ ✔
reconciliations.delete ✔
tax-codes.view ✔ ✔ ✔ ✔
tax-codes.create ✔ ✔
tax-codes.update ✔ ✔
tax-codes.delete ✔
tax-rates.view ✔ ✔ ✔ ✔
tax-rates.create ✔ ✔
tax-rates.update ✔ ✔
tax-rates.delete ✔
voucher-types.view ✔ ✔ ✔ ✔ ✔
voucher-types.create ✔ ✔
voucher-types.update ✔ ✔
voucher-types.delete ✔ ✔
control-accounts.manage ✔ ✔
report-mapping.manage ✔ ✔
control-accounts.post-manual ✔
attachments.view ✔ ✔ ✔ ✔ ✔
attachments.create ✔ ✔
attachments.delete ✔ ✔
account-balance-snapshots.view ✔ ✔ ✔ ✔ ✔
reports.general-ledger.view ✔ ✔ ✔ ✔ ✔
reports.trial-balance.view ✔ ✔ ✔ ✔ ✔ ✔
reports.balance-sheet.view ✔ ✔ ✔ ✔ ✔ ✔
reports.financial-statements.view ✔ ✔ ✔ ✔ ✔ ✔
reports.income-statement.view ✔ ✔ ✔ ✔ ✔ ✔
reports.cash-flow.view ✔ ✔ ✔ ✔ ✔
reports.aged-receivables.view ✔ ✔ ✔ ✔ ✔
reports.aged-payables.view ✔ ✔ ✔ ✔ ✔
reports.account-statement.view ✔ ✔ ✔ ✔ ✔
reports.account-balances.view ✔ ✔ ✔ ✔ ✔
reports.bank-book.view ✔ ✔ ✔ ✔ ✔
reports.cash-book.view ✔ ✔ ✔ ✔ ✔
audit-logs.view ✔ ✔ ✔

Select2 Integration

All <select> elements use Select2 4.1.0 served from public/vendor/accounting/:

public/vendor/accounting/jquery.min.js       — jQuery 3.7.1
public/vendor/accounting/select2.min.js      — Select2 4.1.0
public/vendor/accounting/select2.min.css     — Select2 CSS

Falls back to CDN if assets not published.

Journal entry line dropdowns use Select2 with full Livewire compatibility:

  • Destroyed and re-initialized on every livewire:updated event
  • Native change event fired after Select2 selection to sync with Livewire state

To re-publish:

php artisan vendor:publish --tag=accounting-assets --force

Double-Entry Workflow

Status Description
Draft Editable, not included in balances or reports
Posted Locked (immutable in the database), included in balances; the period must be open
Void Cancelled draft — posted entries cannot be voided, they must be reversed

Reversal: reversing a posted entry posts a mirror entry (debits ↔ credits). Both stay posted and net to zero (GAAP); the original is flagged via reversed_by_entry_id — use $entry->isReversed() or JournalEntry::reversed(). A reversal cannot itself be reversed.

Integrity is enforced in layers:

  1. UI — Save is disabled until debits equal credits.
  2. Application — PostJournalEntryAction checks, in exact cents: ≥ 2 lines, each line either debit or credit, active posting (non-group) accounts, account currency, debits = credits, open period (locked so a concurrent period close cannot interleave; concurrent postings do not block each other).
  3. Database — triggers reject any change to posted entries/lines (amounts, accounts, dates, status, deletes), posting to group accounts, lines on another company's accounts and invalid account trees (see Chart of accounts rules); PostgreSQL also has CHECK constraints (one-sided lines, positive FX rates, single base currency).

Periods — month-end and year-end close

Open Accounting → Periods (React or Blade). Use yearly periods, or click Monthly periods to create twelve months for a fiscal year (POST /periods/generate-monthly); the last month of the fiscal year is marked year end.

Close workspace — Month-end close / Year-end close on a period opens a checklist (GET /periods/{id}/close-checklist[?year_end=1]):

Check Blocks closing?
No entries waiting for approval ✕ yes
No draft entries dated in the period ✕ yes
Trial balance is balanced ✕ yes
Earlier periods are closed ⚠ warns at month end, ✕ blocks at year end
Bank lines reconciled ⚠ warns
Retained earnings account is set up (year end) ✕ yes

Each item links to the screen that fixes it. The year-end workspace also shows the closing entry preview before anything is posted.

  • Month-end close (periods.close) writes balance snapshots (opening, movement, closing), stores the totals and net income, and locks the period against postings.
  • Year-end close posts a closing entry that brings every income and expense account to zero and moves the result to retained earnings (ACCOUNTING_RETAINED_EARNINGS_ACCOUNT_CODE), then closes the period. It uses each account's balance up to the year end, so it works with monthly or yearly periods and sweeps any earlier unclosed years too.
  • Reopen (periods.reopen) needs a reason, which is kept in the audit trail. Periods are reopened newest first. Reopening a year-end period reverses its closing entry automatically, so closing it again recalculates the result from the corrected figures.
  • Periods may not overlap. A period's dates cannot change, and it cannot be deleted, once it contains entries.

Chart of accounts rules

  • No cycles; a parent must be a group account of the same account type.
  • Once an account has journal lines its code, type, normal balance and group flag are locked (rename or deactivate instead).
  • Accounts with children or journal lines cannot be deleted; system accounts cannot be deleted.
  • Omitted is_active / is_group on update are left unchanged; normal_balance defaults to the account type's.
  • Journal lines can only use posting (non-group) accounts of the entry's own company — rejected when the entry is saved (422), when it is posted, and by the database.

Enforced by the database too. These rules hold even for writes that bypass the application (raw SQL, imports, other apps on the same database): triggers on MySQL/MariaDB, PostgreSQL and SQLite reject

Write Rejected when
Insert / update an account the parent is not a group account of the same type and company; the move creates a cycle
Update an account it has journal lines and its type, normal balance or group flag changes; it is a group with children and becomes a posting account; its type changes away from its children's; it moves to another company
Insert / update a journal line the account belongs to another company than the entry
Post a journal entry a line uses a group account

The account code is not locked at the database level (lines reference the account id, so history is kept); the application still locks it for used accounts. php artisan migrate adds the triggers to existing installs.

Seeding

accounting:seed only creates missing records — it never renames, re-activates or re-rates existing accounts/currencies and never reopens closed periods. ACCOUNTING_CHART_PRESET=erpnext (default) seeds the ERPNext standard chart; general seeds the package's industry-neutral chart; school seeds education-specific names (--chart= overrides it for one run). With a base currency other than PKR only the base currency is seeded.

Security

The package is reviewed against the OWASP top ten for what it controls; see tests/Feature/Accounting/SecurityRoutesTest.php, SecurityHardeningTest.php and the cross-company test in ApiAccessTest.php.

  • Authorisation: every route carries a can: ability and authentication; a test fails the build if a new route is added without one, or if a GET route changes data.
  • Company isolation: a record of another company is never served, changed or deleted by id, on any API resource (tested for every route).
  • Uploads: attachments are limited to a whitelist of document types, stored on a private disk and downloaded with nosniff; Excel imports reject DOCTYPE/entity tricks and oversized (zip-bomb) parts.
  • Exports: CSV cells starting with =, +, - or @ are prefixed so spreadsheets never run them as formulas.
  • Audit: postings, voids, role and permission changes are written to the audit trail.

A UAT checklist for accountants is in docs/uat.md.

Testing

composer test                     # Pest on in-memory SQLite
DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_DATABASE=coa_test DB_USERNAME=postgres composer test
composer analyse                  # Larastan level 5
composer format:check             # Pint

Production checklist

  • php artisan accounting:install --admin-email=… — confirm who holds super-admin.
  • User model has HasRoles (and HasApiTokens for the API); keep verified email enforcement on.
  • php artisan accounting:roles passes (no role can both create and approve).
  • Maker-checker on for material amounts: ACCOUNTING_APPROVALS_ENABLED=true, ACCOUNTING_APPROVAL_THRESHOLD=….
  • A queue worker is running if you use webhooks (php artisan queue:work).
  • API behind Sanctum (or your guard) with the rate limit on (ACCOUNTING_API_RATE_LIMIT).
  • php artisan config:cache route:cache in deploys; php artisan accounting:update after every upgrade.
  • Back up before closing a fiscal year; give periods.reopen to as few people as possible.
  • Multi-company: give each user access only to their companies; review accounting:roles (roles apply in every company).
  • Known limitations: customer refunds are not built yet, and large Excel exports are built in memory (not streamed); all companies share one base currency; consolidation does not eliminate intercompany balances; seeded exchange rates are samples — set your own.

FAQ

Q: How do I change the URL from /accounting/ to /settings/? A: Add ACCOUNTING_ROUTE_PREFIX=settings to .env. Route names stay accounting.* so no view changes needed.

Q: Does this work with Jetstream (Livewire)? A: Yes. Set ACCOUNTING_UI_DRIVER=blade in .env before accounting:install.

Q: How do I update after a package upgrade? A: Run php artisan accounting:update.

Q: Can I create GL entries without the UI? A: Yes — use JournalEntry::record(description, debitCode, creditCode, amount, post: true).

Q: Why do I get a 422 "Posted journal entries are immutable"? A: Posted entries can't be edited or deleted — reverse them and post a corrected entry.

Q: Why is the Save button disabled? A: Debits and credits must be equal before saving. Enter matching amounts in the debit/credit columns.

Q: What is accounting:update? A: Refreshes package assets, runs new migrations and re-syncs DB objects without touching your config. Run after every composer update.

Q: I only need the API — how do I keep it light? A: Set ACCOUNTING_UI_DRIVER=api. No web routes, views or Livewire components are loaded; only the API routes.

Q: How do I require a second person to approve entries? A: ACCOUNTING_APPROVALS_ENABLED=true and a threshold — see Maker-checker approvals. Give makers the accountant role and checkers approver.

Q: The accounting pages have no sidebar/menu in my React app. A: Add the menu link, and on an Inertia 2 starter kit the small app.tsx addition — see React: add the menu link under Installation.

Q: My XLSX/PDF export returns 422. A: The report has more rows than the XLSX/PDF limit. Narrow the dates/account, export CSV (unlimited), or raise ACCOUNTING_EXPORT_MAX_XLSX_ROWS / ACCOUNTING_EXPORT_MAX_PDF_ROWS.

Q: Can I run several companies? A: Yes — ACCOUNTING_MULTI_COMPANY=true, then php artisan accounting:create-company. See Multi-company.

Q: Is Laravel 14 supported? A: Laravel 14 is expected in Q1 2027 and requires PHP 8.4. CI already runs the suite against Laravel's development branch; the version constraint will be widened as soon as 14.0 and the test tooling (Pest, Testbench) are released.

Changelog

See CHANGELOG.md for full version history and Releases for release notes.

Releasing (maintainers): add a ## [x.y.z] - date section to CHANGELOG.md, merge, then push the tag (git tag vx.y.z && git push origin vx.y.z) — or run Actions → Release → Run workflow with the tag. The workflow creates the tag if needed and publishes a GitHub Release with that CHANGELOG section; Packagist picks the tag up automatically.

License

MIT — see LICENSE.

Contact & Support

Keywords

laravel accounting · laravel chart of accounts · laravel double-entry bookkeeping · laravel journal entries · laravel general ledger · laravel trial balance · laravel balance sheet · laravel income statement · laravel cash flow · laravel bank reconciliation · laravel ERP · laravel GAAP · laravel IFRS · double entry bookkeeping php · accounting package for laravel · laravel COA · laravel multi-currency · laravel financial reports · laravel accounting module