Online Accounting SaaS — System Architecture, Laravel Structure, Migrations, Roadmap & Security Checklist
If you want to rebuild a clone of Akaunting—a full-featured, Laravel-based accounting system—you’re essentially planning a massive ERP-grade application. The public surface looks simple, but the hidden details are where most developers underestimate the work. Here’s what you realistically need to be concerned about.
Akaunting is open-source (GPL-3). That means:
- Sell a closed-source clone
- Use their code in proprietary software
- Reuse their branding, naming, or UI
You can study the architecture and reimplement from scratch. You can use the same features.
- Reusing GPL code in a SaaS that you want to keep closed
- Using the name “Akaunting”
- Copying exact UI screens, icons, or assets
This is where most clones fail.
You must accurately implement:
- Double-entry ledger
- Chart of accounts
- Debit vs Credit enforcement
- Balance sheet correctness
- Trial balance auto-generation
- Accrual vs cash-based accounting
- Fiscal years & locked periods
- Journal entries & adjustments
If you get even one rule wrong, reports break.
Akaunting supports:
- VAT
- GST
- Sales Tax
- Withholding Tax
- Multi-rate items
- Compound tax
- Inclusive vs Exclusive pricing
If you want global use, you must consider:
- Per-country tax rules
- Filing formats
- Rounding differences
- Tax groups
- Reverse charge (VAT)
- EU OSS / MOSS complexities
This alone can be a huge project:
- Live FX rates
- Historical FX for each transaction
- Unrealized vs realized gain/loss
- Revaluation at period close
Even QuickBooks often gets this wrong.
If you include inventory:
- FIFO
- Weighted average cost
- Stock adjustments
- Purchase → Inventory → Sale flow
- Cost of Goods Sold (COGS) automation
Inventory accounting errors cascade into balance sheets.
Accounting systems require:
- Strict role-based permissions
- Per-module access
- Activity log for ALL changes
- Track before & after values
- Restrict editing closed periods
This is required for compliance.
You’ll need to support:
- Partial payments
- Overpayments
- Credit notes
- Recurring invoices
- Refunds
- Aging summaries
- Early payment discounts
- Write-offs
- Bank reconciliation
- Importing bank feeds (CSV, API, PSD2)
Accounting workflows have many edge cases.
Accounting systems grow fast:
- Ledger table gets huge
- Reports become slow
- Migrations must be safe
- Queues needed for heavy calculations
You’ll need indexing, partitions, caching, and background jobs.
Akaunting is multi-tenant.
If you clone it, choose your model:
Fast, cheap, easier. Risk of data leak.
More isolation, but more ops overhead.
Middle ground.
Your design decisions here affect everything.
Akaunting survives because of its marketplace.
If you want a clone to succeed, you must support:
- Addons (installable)
- Themes
- Webhooks
- API integrations
- Payment gateway plugins
A plugin ecosystem is the real business model.
A global accounting app requires:
- Multiple date formats
- Decimal format differences
- Right-to-left layout support
- Country-based currency settings
- 50–100 language packs
Akaunting supports 80+ languages.
⚠️ Hidden Technical Risks People Underestimate
Floating point arithmetic will destroy your ledger. Use decimal fields everywhere.
Transfers, pooled accounts, partial matches.
Accounting requires immutable history.
Invoices, receipts, reports, statements.
Mandatory for accountants.
Cashflow Trial balance Ledger summaries Tax totals
Heavy workloads.
Start with:
- Chart of accounts
- Double-entry
- Journal entries
- Trial balance
- Balance sheet & P&L
- Invoices
- Bills
- Payments
This document is a comprehensive blueprint for rebuilding a Laravel-based accounting package similar in scope to Akaunting. It covers system architecture (DB, services, modules), a recommended Laravel folder structure, a detailed migration + model list, a phased roadmap for building from scratch, and a security & compliance checklist.
- Multi-tenant SaaS-first, with option for self-hosted installs.
- Accurate double-entry accounting core.
- Scalable read-heavy reporting system.
- Plugin/add-on system for extensibility.
- Strong auditability and immutability where required.
- API Gateway / Web Server: Nginx (reverse proxy), TLS termination.
- Frontend: SPA (Nuxt/React) + blade fallback for admin; static assets via CDN.
- Backend: Laravel (HTTP API + web). Octane + Swoole optional for high concurrency.
- Auth & Identity: Laravel Sanctum / Passport for API tokens; SSO (OAuth2) support.
- DB (Primary): PostgreSQL (strong ACID, rich types).
- DB (Analytics/Reporting): Read-replica or columnar optimized DB (ClickHouse/Timescale) for heavy reports optionally fed via ETL.
- Cache: Redis (sessions, cache, queues, locking).
- Queue Worker: Laravel queue (Redis/Beanstalk/SQS) for background jobs (reports, reconciliation, imports).
- Object Storage: S3-compatible (receipts, invoices PDFs).
- Search/Index: ElasticSearch or Meilisearch (search invoices/customers).
- Message Bus/Eventing: Kafka/RabbitMQ (if multi-service architecture); otherwise Laravel events + Redis pubsub.
- Task Scheduler & Cron: Laravel Scheduler running periodic jobs.
- Monitoring & Observability: Prometheus + Grafana, Sentry for error tracking, audit logs centralization.
- CI/CD: GitHub Actions/GitLab CI for tests, migrations, builds.
- Billing & Marketplace: Stripe integration (payments), a separate service or module for marketplace management.
- Single-tenant DB per large customer; shared DB with tenant_id for small customers.
- Use Kubernetes/ECS for horizontal scaling of API workers and queues.
- Read replicas for reporting queries.
- User -> Frontend -> API (auth middleware)
- API writes transactional data to primary DB within ACID transactions
- After commit, publish domain events (journal.created) to event bus
- Workers consume events: update denormalized reporting tables, cache, send emails, generate PDFs
- Reporting queries hit read-replica or analytic DB
- Core Accounting (ledger, chart of accounts, journals, ledgers, fiscal periods)
- Sales (customers, invoices, estimates, recurring invoices, credit notes)
- Purchases (vendors, bills, purchase orders, bills payment)
- Banking (bank accounts, transactions, reconciliation, bank feeds)
- Inventory (products, stock locations, adjustments, COGS) — optional module
- People (contacts, customers, vendors, employees)
- Payroll (separate regulatory heavy module, country-specific) — optional
- Reports (P&L, Balance Sheet, Trial Balance, Cash Flow, Aging, Tax summaries)
- Settings (tax rules, currencies, locales, templates, automation)
- Users & Permissions (roles, granular policies, team management)
- Platform (tenancy, billing, marketplace, plugin manager, webhooks)
- Helpers (imports/exports, PDF generator, cron tasks, attachments)
Each module should be implemented as a bounded context exposing APIs/events and with its own service classes and tests.
Use PostgreSQL; use
decimal(20,6)or numeric for money fields (avoid float). Store currency separately, store amounts in the currency and optionally a base currency equivalent.
- All monetary columns:
amount(numeric), currency:currency_code(ISO 4217). - Use
uuidprimary keys for public resources andbigintfor internal IDs where performance is critical—pick one consistently. - Timestamps:
created_at,updated_at,deleted_at(soft deletes only where allowed). - Use
journal_entriesas immutable once period locked; append new reversing entries instead of editing historical rows.
- id (uuid)
- name
- plan_id
- settings (jsonb)
- created_at, updated_at
- id (uuid)
- tenant_id
- name, email, password, locale
- role
- last_login_at
- is_active
- code (PK)
- name
- precision
- id
- tenant_id
- parent_id
- code (string) — COA code
- name
- type (enum: asset, liability, equity, revenue, expense)
- balance (numeric) — cached
- normal_side (debit/credit)
- id
- tenant_id
- name
- type
- id
- tenant_id
- journal_id
- date (date)
- reference (string)
- narration (text)
- created_by
- posted_at (datetime)
- locked boolean
- id
- journal_entry_id
- account_id (chart_of_accounts.id)
- debit (numeric)
- credit (numeric)
- description
- contact_id (nullable) — customer/vendor
- tax_id (nullable)
journal_entries + journal_lines implement double-entry — sum(debits) must equal sum(credits)
- id
- tenant_id
- type (customer/vendor/both)
- name, contact info, tax_id, currency_code
- id
- tenant_id
- customer_id
- invoice_no
- date, due_date
- currency_code
- status (draft, sent, paid, partially_paid, cancelled)
- total (numeric), subtotal, tax_total
- posted_journal_entry_id (nullable)
- id
- invoice_id
- product_id (nullable)
- description
- qty, unit_price, discount, tax_id, total
- similar to invoices
- id
- tenant_id
- payment_no
- contact_id
- amount
- currency_code
- payment_date
- payment_method_id
- applied_to (polymorphic relation or pivot table payment_allocations)
- id
- payment_id
- allocatable_type
- allocatable_id
- amount
- id
- tenant_id
- name
- account_number
- bank_name
- currency_code
- id
- bank_account_id
- date
- amount
- type (deposit/withdrawal)
- reconciled boolean
- reconciliation_id
- id
- bank_account_id
- start_date
- end_date
- opened_by
- closed_at
- id
- tenant_id
- sku
- name
- is_stock_item boolean
- inventory_account_id
- sales_account_id
- purchase_account_id
- cost_method (fifo, avg)
- id
- product_id
- qty (positive/negative)
- type (purchase, sale, adjustment, transfer)
- cost
- warehouse_id
- id
- tenant_id
- name
- rate (decimal)
- type (percent/fixed)
- applies_to
- id
- tenant_id
- start_date
- end_date
- locked boolean
- id
- tenant_id
- user_id
- auditable_type
- auditable_id
- action (create/update/delete)
- changes (jsonb)
- ip_address
- created_at
- id
- model_type
- model_id
- path
- filename
- size
- id, name, version, vendor, metadata
currencies_rates(date, base, target, rate)recurring_invoicestransactions_importswebhooksplugins_installed
This layout uses Domain-oriented folders inside app/ and keeps controllers thin.
app/
├─ Console/
├─ Domain/
│ ├─ Accounting/
│ │ ├─ Controllers/
│ │ ├─ Http/
│ │ │ ├─ Requests/
│ │ │ └─ Resources/
│ │ ├─ Models/
│ │ ├─ Policies/
│ │ ├─ Services/
│ │ ├─ Events/
│ │ ├─ Listeners/
│ │ └─ Repositories/
│ ├─ Sales/
│ ├─ Purchases/
│ ├─ Banking/
│ ├─ Inventory/
│ └─ Platform/ (tenancy, billing, plugins)
├─ Exceptions/
├─ Http/
│ ├─ Controllers/ (Auth, Admin)
│ ├─ Middleware/
│ └─ Kernel.php
├─ Providers/
└─ Support/ (helpers, value objects, money library wrappers)
routes/
├─ api.php
├─ web.php
├─ tenant.php
database/
├─ migrations/ (group by module prefix)
├─ seeders/
resources/
├─ views/
├─ js/ (SPA source)
tests/
- Keep module-specific logic in
Domain/*with interfaces for repositories to make unit testing easier. - Use
App\Support\Moneyor integrate a money library (brick/money) to avoid arithmetic mistakes.
This section lists primary migrations plus key fields. Each migration should include indexes for tenant_id, date, and common lookup columns.
create_chart_of_accounts_table— fields: id, tenant_id, parent_id, code, name, type, normal_side, cached_balance (numeric), metadata (jsonb)create_journals_table— id, tenant_id, name, typecreate_journal_entries_table— id, tenant_id, journal_id, date, reference, narration, posted_at, locked, created_bycreate_journal_lines_table— id, journal_entry_id, account_id, debit, credit, contact_id, tax_id, descriptioncreate_fiscal_periods_table— id, tenant_id, start_date, end_date, locked
create_contacts_table— id, tenant_id, name, type, email, phone, address, currency_code, tax_idcreate_invoices_table— id, tenant_id, customer_id, invoice_no, date, due_date, currency_code, status, subtotal, tax_total, total, posted_journal_entry_idcreate_invoice_lines_table— id, invoice_id, product_id, description, qty, unit_price, discount, totalcreate_recurring_invoices_table
create_bills_table(similar to invoices)create_bill_lines_table
create_payments_table— id, tenant_id, contact_id, amount, currency_code, payment_date, payment_method_id, notecreate_payment_allocations_table— payment_id, allocatable_type, allocatable_id, amountcreate_bank_accounts_tablecreate_bank_transactions_tablecreate_reconciliations_table
create_products_table— sku, name, is_stock_item, sales_account_id, purchase_account_id, inventory_account_id, cost_methodcreate_stock_movements_table— product_id, qty, cost, type, reference_idcreate_warehouses_table
create_taxes_tablecreate_currency_rates_table
create_tenants_tablecreate_users_tablecreate_audit_logs_tablecreate_attachments_tablecreate_plugins_table
Time estimates are example and should be adjusted to team size and priorities. For a small team (2–4 engineers) these are conservative phases.
- Requirements, compliance checklist per target markets
- Schema design & critical flows (journals, reconciliation)
- Choose tenancy model, decide on PG vs MySQL
- Set up repo, CI, basic deploy pipeline, linting, tests
Deliverables: ER diagram, API contract (OpenAPI), basic Laravel skeleton with auth and tenancy.
- Implement chart of accounts, manual journals, journal lines
- Trial balance, balance sheet, P&L generation (basic queries)
- Fiscal periods and locking
- Audit log for journal changes
Deliverables: Double-entry core, tests validating accounting equations.
- Customers, invoices, invoice lines, taxes
- Record payments and allocations
- Generate posted journal entries from invoices/payments
- PDF invoice generation, email sending
Deliverables: Invoicing flow end-to-end with posting to ledger.
- Vendor bills, payments, credit notes
- Vendor aging reports
- Bank account model, import CSV bank feeds
- Matching algorithm for reconciliation (fuzzy matching)
- Reconciliation periods & reports
- Currency conversion, historic rates, revaluation
- Complex tax rules & tax reports
- Inventory and COGS (if desired)
- Plugin install/uninstall lifecycle
- Billing system with Stripe
- Marketplace admin
- Add read replicas, caching, query optimization
- Audit, pen-testing, compliance readiness (GDPR, local tax rules)
Minimum viable product (MVP): Core Accounting + Invoicing + Payments + Basic reports.
- Money handling: Use
numeric/decimaland a Money value object (e.g.,brick/money). Avoid floats. Always store currency_code. - Idempotency: Strong idempotency for webhooks and external feeds. Use idempotency keys for imports.
- Immutable history: Never delete posted journal entries. Use reversing journals for corrections.
- Testing: Add property-based tests for ledger invariants (sum debits == sum credits). Add integration tests that exercise full invoice→payment→posting flow.
- API versioning: Start with v1; keep backward compatibility rules for public API.
- Events & eventual consistency: Use domain events to update denormalized read tables; design for eventual consistency when needed but ensure financial data correctness at the source of truth.
- Strong password rules, bcrypt/argon2 hashing.
- MFA (TOTP) for all admin accounts.
- Session management and ability to revoke tokens/sessions.
- Role-based access control (RBAC) with fine-grained permissions; policies for routes/resources.
- TLS everywhere (HSTS).
- Encrypt sensitive fields at rest where required (e.g., bank credentials) using application key management.
- Backup encryption and rotation.
- Least privilege for DB users.
- Append-only audit logs for key finance actions (journal entries, reconciliations, fiscal locks).
- Signed PDF receipts/invoices (optional) and versioned attachments.
- Cryptographic checksum for backups.
- Rate-limiting, WAF rules.
- Secrets stored in a secrets manager (AWS Secrets Manager / Vault).
- Automated vulnerability scans and dependency updates.
- GDPR/CCPA considerations for EU/US customers: data export & delete flows, data processing agreement.
- Local tax compliance per market (invoicing requirements, e-invoicing widgets).
- PCI-DSS for card payments (delegate to Stripe where possible to reduce scope).
- Unit tests for ledger arithmetic, property tests for invariants.
- Integration tests for end-to-end flows.
- Periodic reconciliation tests (compare stored balances with computed from journals).
- Regular snapshots, point-in-time recovery enabled for DB.
- Test restore drills quarterly.
- Add read-replica DB for reporting heavy queries.
- Use materialized views for complex, slow reports updated by jobs.
- Partition ledger lines by year/tenant for very large installations.
- Use background workers for heavy exports and report generation.
- Use
spatie/laravel-permission(or similar) for RBAC. - Use
brick/moneyormoneyphp/money. - Use
maatwebsite/excelfor imports/exports. - Use
dompdforsnappyfor PDF generation.
- For each feature include: API contract, unit tests, integration tests, UI flows, audit log coverage, and documentation (user & API).
- Finalize tenancy decision (shared DB vs DB-per-tenant).
- Produce ER-diagram and example datasets for test-driven development.
- Implement a minimal accounting kernel (chart of accounts + manual journal entries + trial balance) and write property tests.
- Iterate with early accounting users (an accountant) to validate business rules.
sum(journal_lines.debit) == sum(journal_lines.credit)for a journal_entry.balance(account) == previous_balance + postingswhen period unlocked.
End of blueprint.
#################################### PART 2 ##################################
Below is a thorough, practical, engineering-focused explanation of how to build the Chart of Accounts (COA) for your Akaunting-style system — and the implications it has on every module that follows.
It is the canonical dictionary of all accounts that the system will use to classify every financial event:
- Assets
- Liabilities
- Equity
- Revenue
- Expenses
Every entry in the ledger must point to one of these accounts.
COA design decisions are irreversible, because almost every module uses accounts (invoices, bills, payments, stock, bank reconciliation, taxes, etc.). If you get the COA wrong, you will have to rewrite large parts of the system.
Example:
-
Assets
-
Current Assets
- Cash
- Bank Accounts
- Accounts Receivable
-
This hierarchy only affects reporting, not transactional integrity.
Each account must have a type, and type defines:
- normal side (debit or credit)
- affects which report (balance sheet or income statement)
- how balances roll forward
Example:
| Account Type | Normal Side | Report | Roll Forward |
|---|---|---|---|
| Asset | Debit | Balance Sheet | Yes |
| Liability | Credit | Balance Sheet | Yes |
| Equity | Credit | Balance Sheet | Yes |
| Revenue | Credit | Income Statement | No |
| Expense | Debit | Income Statement | No |
Some accounts must always exist, e.g.:
- Accounts Receivable
- Accounts Payable
- Sales Revenue
- Inventory Asset
- Cost of Goods Sold
You must protect them from deletion or type changes.
Each tenant has its own COA. Never mix accounts across tenants.
(Already in your doc, but here’s the engineering rationale)
chart_of_accounts
- id
- tenant_id
- parent_id (nullable)
- code (string, unique within tenant)
- name
- type (enum)
- normal_side (enum: debit/credit)
- is_system (boolean)
- description
- metadata (jsonb)
It allows you to compute balances consistently:
balance = sum(debits) - sum(credits) if normal_side = debit
balance = sum(credits) - sum(debits) if normal_side = credit
This avoids hardcoding logic per account type.
- Makes reports consistent
- Allows accountants to import/export
- Enables “smart default mapping” (e.g., 1000 = Cash)
System accounts must not change type or allow deletion, because:
- invoices rely on predefined sales accounts
- payments rely on “cash” or “bank” accounts
- taxes might rely on “VAT Payable” accounts
- inventory relies on COGS / Inventory Asset
If a user deletes or edits system accounts, the whole ledger becomes invalid.
Every journal line must reference an account.
This means:
- A COA must exist before any invoice can be posted.
- When posting invoices, bills, payments — the logic maps to accounts.
Example: Invoice total = 500 Tax = 50 COGS = 200 Revenue = 450 Inventory decrease = -200
Generated double entry:
| Account | Debit | Credit |
|---|---|---|
| Accounts Receivable | 550 | 0 |
| Sales Revenue | 0 | 450 |
| VAT Payable | 0 | 50 |
| COGS | 200 | 0 |
| Inventory Asset | 0 | 200 |
If COA is not properly created upfront, this mapping fails.
- Sales accounts
- Receivables
- Discount accounts
- Tax payable accounts
- Expense accounts
- Payable accounts
- Tax receivable accounts
- Bank accounts are COA accounts (type: Asset)
- Transfers generate journal entries between them
- Inventory Asset
- COGS
- Inventory Adjustment
- Salary expense
- Payroll liabilities
- Balance Sheet is entirely driven by COA
- P&L is entirely driven by COA
- Tax accounts must be mapped to COA
- Changing COA mapping changes calculations
- Create tenant
- Insert default set of chart_of_accounts
- Customize based on country template (if supported)
You can store template COAs in:
- JSON files
- Database seed
- Marketplace module (downloadable templates)
Assets
1000 Cash
1100 Bank Account
1200 Accounts Receivable
1300 Inventory
1400 Fixed Assets
Liabilities
2000 Accounts Payable
2100 VAT Payable
2200 Accrued Expenses
Equity
3000 Owner’s Equity
3100 Retained Earnings
Revenue
4000 Sales Revenue
4100 Service Revenue
Expenses
5000 Cost of Goods Sold
5100 Operating Expenses
5200 Salary Expenses
Then users can add more.
You cannot make:
- Parent = Revenue
- Child = Asset
All children must inherit logical grouping.
- it has journal lines
- it is marked
is_system = true - it is linked to an active module (e.g., sales default revenue account)
Changing type from Expense→Liability breaks the entire reporting logic.
Income statement accounts close, balance sheet accounts roll over.
If you calculate balance by brute-force summing all journal lines every time, reports will be slow.
Solution:
-
Maintain cached balance in
chart_of_accounts.balance -
Recompute on:
- new journal entry
- void/reversal
- period lock/unlock
For high tenant count or millions of transactions:
- Partition by tenant (if large customers)
- Partition by year if single large tenant
Index by:
- tenant_id
- account_id
- date
- posted_at
Reports hit these repeatedly.
Add type enums, normal_side, code, parent_id.
- UI treeview for account hierarchy
- Drag & drop parent changes (optional)
- Permissions: Only admins/owners
- Lock system accounts
- Lock type changes when used
- Prevent delete when in use
Examples:
- Settings → Default Revenue Account
- Settings → Default Receivable Account
- Product → Sales & purchase account
- Tax → Liability account
- Ledger invariants
- Type validation
- Parent/child constraints
- Roll-over logic
If you choose a poor structure:
- Reporting becomes inconsistent
- Posting logic becomes complex
- Users cannot import/export properly
- Accountants will complain loudly
Every region has its own COA templates:
- US GAAP
- IFRS
- Local government standards
Think ahead: allow multiple templates.
Plugins depend on the COA.
Examples:
- Payroll plugin needs Salary Expense
- Inventory plugin needs COGS + inventory accounts
- Tax plugin needs VAT payable
Having a clean, well-designed COA makes plugins easy to build.
If the double-entry system is the heart of your software, the Chart of Accounts is the DNA.
If the COA is logically correct, hierarchical, restricted properly, and implemented with correct metadata (type, normal_side, code, system flags), the rest of your accounting engine becomes straightforward and predictable.
If the COA is messy, inconsistent, or allows destructive edits, the entire platform becomes unreliable.
################################ PART 3 Chart of accounts ################################
Yes — you can build a full Chart of Accounts (COA) builder around ekmungai/eloquent-ifrs without Bootstrap, because the package already provides all accounting domain logic (Accounts, Ledgers, Transactions, Reporting Periods, etc.). You only need to add your own UI and CRUD layer on top.
Below is a clear, end-to-end plan for how to implement a COA Builder using that package.
The package already includes:
AccountTypeenumCategoryhandling- Hierarchical structure via
Account::parent - Validation rules (e.g., account type belongs to category)
Journal entries, ledgers, balances, reporting periods.
- Prevents deletion if account is used
- Ensures proper account types
- Ensures reporting compliance
This means: you only need CRUD screens + your own “builder” UI.
You do not need to create tables for accounts — the package has migrations.
Your builder will use:
IFRS\Models\AccountIFRS\Models\EntityIFRS\Models\CategoryIFRS\Models\ReportingPeriod
AccountGroup(if you want custom nested grouping)CoaTemplate(to allow pre-built templates per industry)AccountPreset(to clone default COA for new entities)
None of these are required — but recommended if you aim to clone Akaunting’s COA features.
- parent account (optional)
- name
- type (asset, expense, revenue, liability, equity)
- code (optional but strongly recommended)
- category (optional)
- currency (if multi-currency enabled)
- description
You can create accounts using:
use IFRS\Models\Account;
$account = Account::create([
'name' => 'Cash in Hand',
'account_type' => Account::ASSET,
'entity_id' => auth()->user()->entity_id,
'code' => '1010',
'description' => 'Physical cash on premises',
'parent_id' => $parentAccountId ?? null,
]);Allowed if not locked and not used in transactions (package enforces this).
The package prevents deletion if:
- it has child accounts
- it is posted to a ledger So your UI must gracefully show an error message.
You manage nesting by setting:
$account->parent_id = $newParentId;
$account->save();You can filter by type, category, or hierarchical parent.
A clean frontend-agnostic structure:
app/
Http/
Controllers/
Coa/
AccountController.php
CategoryController.php
CoaImportController.php
Resources/
views/coa/...
Services/
Coa/
CoaBuilderService.php
CoaTemplateService.php
routes/
web.php (UI routes)
api.php (API routes for SPA)
resources/
js/ (Vue / React / Alpine — your choice)
views/coa/...
database/
migrations/ (custom optional migrations)
You said no Bootstrap, so you can use:
- Tailwind
- Vue 3
- React
- AlpineJS (or keep it simple with pure HTML + minimal JS)
- Laravel controllers
- Service class for COA logic
eloquent-ifrsfor actual accounting logic
namespace App\Http\Controllers\Coa;
use IFRS\Models\Account;
class AccountController
{
public function index()
{
return Account::where('entity_id', auth()->user()->entity_id)
->with('parent')
->orderBy('code')
->get();
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required',
'code' => 'nullable|string|max:50',
'account_type' => 'required|integer',
'parent_id' => 'nullable|exists:accounts,id',
]);
$validated['entity_id'] = auth()->user()->entity_id;
return Account::create($validated);
}
}Use a collapsible tree component:
Account
├── Cash
│ ├── Cash In Hand
│ ├── Cash at Bank
│
├── Accounts Receivable
├── Inventory
Search box + filters:
- Filter by type (dropdown)
- Filter by category
- Search by name or code
- Toggle: "Show only child accounts"
<ul>and<li>list- Expand/collapse using JavaScript
When a new entity is created:
public function createDefaultCoa(Entity $entity)
{
$template = CoaTemplate::where('industry', 'generic')->first();
foreach ($template->presets as $preset) {
Account::create([
'entity_id' => $entity->id,
'name' => $preset->name,
'code' => $preset->code,
'account_type' => $preset->type,
'parent_id' => $preset->parent_id,
]);
}
}This is how Akaunting does it.
→ Your UI must show a “Locked” icon or status.
→ You must load valid types into dropdowns dynamically.
Every account belongs to entity_id, even if you have only one company.
Without this, many actions will fail.
################################# Part 4 ###################################
Nice — below is a ready-to-drop-in Blade + Alpine Chart-of-Accounts builder that uses ekmungai/eloquent-ifrs for the accounting model.
It’s UI-light (no Bootstrap), ergonomic, and enforces the package rules (cannot delete used/system accounts). I give you: routes, controller, a small service, Blade views (tree + modal), and a seeder to create a default COA for a new Entity. Copy-paste the files into your app and tweak names/paths to fit your project.
-
Installed package and published migrations according to its README:
composer require ekmungai/eloquent-ifrs php artisan vendor:publish --provider="IFRS\IFRSServiceProvider" --tag="migrations" php artisan migrate
-
Your app must use
Entityconcept from the package (each account belongs to anentity_id). Authenticate users and ensureauth()->user()->entity_idexists. -
Add Alpine (for small interactivity) — used in the Blade files (CDN included in layout).
Add the resource routes and protect them with auth & a can:manage-coa middleware/policy as you prefer.
use App\Http\Controllers\Coa\AccountController;
Route::middleware(['auth'])->group(function () {
Route::get('coa', [AccountController::class, 'index'])->name('coa.index');
Route::post('coa', [AccountController::class, 'store'])->name('coa.store');
Route::put('coa/{account}', [AccountController::class, 'update'])->name('coa.update');
Route::delete('coa/{account}', [AccountController::class, 'destroy'])->name('coa.destroy');
Route::get('coa/{account}', [AccountController::class, 'show'])->name('coa.show');
// optional: endpoint to load subtree or search
Route::get('coa-tree', [AccountController::class, 'tree'])->name('coa.tree');
});<?php
namespace App\Http\Controllers\Coa;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use IFRS\Models\Account;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class AccountController extends Controller
{
protected function entityId()
{
// adjust this to how your app resolves entity per user / tenant
return auth()->user()->entity_id;
}
public function index()
{
return view('coa.index');
}
/**
* Return full tree JSON for frontend rendering
*/
public function tree()
{
$entityId = $this->entityId();
$accounts = Account::where('entity_id', $entityId)
->select(['id','parent_id','name','code','account_type','is_locked'])
->orderBy('code')
->get();
return response()->json($accounts);
}
public function show(Account $account)
{
$this->authorizeEntity($account);
return response()->json($account);
}
public function store(Request $request)
{
$entityId = $this->entityId();
$data = $request->validate([
'name' => 'required|string|max:191',
'code' => ['nullable','string','max:64', Rule::unique('accounts','code')->where(fn($q)=> $q->where('entity_id',$entityId))],
'account_type' => ['required','integer', Rule::in(array_values(Account::types()))],
'parent_id' => ['nullable','exists:accounts,id'],
'description' => 'nullable|string|max:1000',
]);
$data['entity_id'] = $entityId;
// create using IFRS Account model
$account = Account::create($data);
return response()->json($account, 201);
}
public function update(Request $request, Account $account)
{
$this->authorizeEntity($account);
$entityId = $this->entityId();
$data = $request->validate([
'name' => 'required|string|max:191',
'code' => ['nullable','string','max:64', Rule::unique('accounts','code')->ignore($account->id)->where(fn($q)=> $q->where('entity_id',$entityId))],
'parent_id' => ['nullable','exists:accounts,id'],
'description' => 'nullable|string|max:1000',
]);
// IFRS package prevents edits that violate invariants; we still guard
if ($account->is_locked) {
return response()->json(['message' => 'Account is locked and cannot be edited.'], 409);
}
$account->fill($data);
$account->save();
return response()->json($account);
}
public function destroy(Account $account)
{
$this->authorizeEntity($account);
// IFRS package will throw when trying to delete used accounts; catch and show friendly message
try {
if ($account->children()->exists()) {
return response()->json(['message' => 'Account has child accounts and cannot be removed.'], 409);
}
$account->delete();
return response()->json(['message' => 'deleted'], 200);
} catch (\Throwable $e) {
return response()->json(['message' => 'Cannot delete account. It may be posted to ledgers or locked.','error' => $e->getMessage()], 409);
}
}
protected function authorizeEntity(Account $account)
{
if ($account->entity_id !== $this->entityId()) {
abort(403);
}
}
}Notes
Account::types()is used to fetch supported type constants from the package (adjust if API differs).is_lockedor similar property depends on package version — adjust accessor names accordingly.
Helps seed a default COA for a new Entity.
<?php
namespace App\Services\Coa;
use IFRS\Models\Account;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
class CoaTemplateService
{
/**
* Seed a minimal default COA for entity
* $entityId: integer/uuid per package usage
*/
public static function seedDefaultForEntity($entityId)
{
// a minimal template. You can expand country-specific templates.
$template = [
['code'=>'1000','name'=>'Cash','type'=>Account::ASSET],
['code'=>'1100','name'=>'Bank Accounts','type'=>Account::ASSET],
['code'=>'1200','name'=>'Accounts Receivable','type'=>Account::ASSET],
['code'=>'2000','name'=>'Accounts Payable','type'=>Account::LIABILITY],
['code'=>'2100','name'=>'VAT / Sales Tax Payable','type'=>Account::LIABILITY],
['code'=>'3000','name'=>'Owner\'s Equity','type'=>Account::EQUITY],
['code'=>'4000','name'=>'Sales Revenue','type'=>Account::REVENUE],
['code'=>'5000','name'=>'Cost of Goods Sold','type'=>Account::EXPENSE],
['code'=>'5100','name'=>'Operating Expenses','type'=>Account::EXPENSE],
];
DB::beginTransaction();
try {
foreach ($template as $t) {
Account::create([
'entity_id' => $entityId,
'code' => $t['code'],
'name' => $t['name'],
'account_type' => $t['type'],
]);
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
}
}Call CoaTemplateService::seedDefaultForEntity($entity->id) when creating an Entity (eg in a listener for EntityCreated).
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Services\Coa\CoaTemplateService;
use App\Models\User;
class CoaDefaultSeeder extends Seeder
{
public function run()
{
// for local seeding: pick first user's entity
$user = User::first();
if (!$user || !$user->entity_id) return;
CoaTemplateService::seedDefaultForEntity($user->entity_id);
}
}If you already have layouts.app, the COA views can extend it. The sample below uses Tailwind CDN + Alpine via CDN for quick prototype.
@extends('layouts.app')
@section('content')
<div class="max-w-6xl mx-auto p-4" x-data="coaApp()">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-semibold">Chart of Accounts</h1>
<div>
<button @click="openCreate(null)" class="px-3 py-1 bg-indigo-600 text-white rounded">Add Root Account</button>
<input type="text" x-model="q" @input="search" placeholder="Search code or name" class="ml-3 px-2 py-1 border rounded" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="col-span-2">
<template x-if="loading">
<div class="p-4 border rounded">Loading…</div>
</template>
<div class="bg-white border rounded p-4" id="coa-tree">
<template x-for="node in filtered" :key="node.id">
<div class="mb-2">
<x-coa-node :node="node" :children="node.children" />
</div>
</template>
<template x-if="!filtered.length && !loading">
<div class="text-gray-500">No accounts found.</div>
</template>
</div>
</div>
<div>
<div class="bg-white border rounded p-4">
<h3 class="font-medium mb-2">Account details</h3>
<template x-if="selected">
<div>
<div class="text-sm text-gray-600" x-text="selected.code + ' — ' + selected.name"></div>
<div class="mt-3">
<button @click="openEdit(selected.id)" class="px-3 py-1 bg-blue-600 text-white rounded mr-2">Edit</button>
<button @click="remove(selected.id)" class="px-3 py-1 bg-red-600 text-white rounded">Delete</button>
</div>
</div>
</template>
<template x-if="!selected">
<div class="text-gray-500">Select an account to see details</div>
</template>
</div>
</div>
</div>
<!-- Modal -->
<div x-show="modal" class="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-40" x-cloak>
<div @click.away="closeModal" class="bg-white rounded shadow w-full max-w-lg p-4">
<h2 class="text-lg font-semibold mb-2" x-text="modalTitle"></h2>
<form @submit.prevent="save">
<input type="hidden" x-model="form.parent_id" />
<div class="mb-2">
<label class="block text-sm">Code</label>
<input x-model="form.code" class="w-full px-2 py-1 border rounded" />
</div>
<div class="mb-2">
<label class="block text-sm">Name</label>
<input x-model="form.name" required class="w-full px-2 py-1 border rounded" />
</div>
<div class="mb-2">
<label class="block text-sm">Type</label>
<select x-model="form.account_type" class="w-full px-2 py-1 border rounded">
<option :value="1">Asset</option>
<option :value="2">Liability</option>
<option :value="3">Equity</option>
<option :value="4">Revenue</option>
<option :value="5">Expense</option>
</select>
</div>
<div class="mb-2">
<label class="block text-sm">Description</label>
<textarea x-model="form.description" class="w-full px-2 py-1 border rounded"></textarea>
</div>
<div class="flex items-center justify-between mt-4">
<div>
<button type="button" @click="closeModal" class="px-3 py-1 border rounded mr-2">Cancel</button>
<button type="submit" class="px-3 py-1 bg-indigo-600 text-white rounded">Save</button>
</div>
<template x-if="editing">
<button type="button" @click="deleteAccount()" class="px-3 py-1 bg-red-600 text-white rounded">Delete</button>
</template>
</div>
</form>
</div>
</div>
</div>
<!-- Alpine bootstrap (CDN) + component script -->
@push('head')
<script src="//unpkg.com/alpinejs" defer></script>
@endpush
@push('scripts')
<script>
function coaApp(){
return {
loading: true,
nodes: [],
filtered: [],
q: '',
selected: null,
modal: false,
modalTitle: '',
form: { id: null, parent_id: null, code: '', name: '', account_type: 1, description: '' },
editing: false,
init() {
this.loadTree();
},
async loadTree() {
this.loading = true;
const res = await fetch("{{ route('coa.tree') }}");
const list = await res.json();
// build tree
const map = {};
list.forEach(i => map[i.id] = {...i, children: [], name: i.name, code: i.code});
list.forEach(i => { if(i.parent_id && map[i.parent_id]) map[i.parent_id].children.push(map[i.id]); });
this.nodes = list.filter(i => !i.parent_id).map(n => map[n.id] || n);
this.filtered = this.nodes;
this.loading = false;
},
search() {
const q = this.q.trim().toLowerCase();
if(!q) {
this.filtered = this.nodes;
return;
}
// naive flatten and filter by name/code
const flat = [];
const walk = (node) => {
flat.push(node);
if(node.children) node.children.forEach(c=>walk(c));
};
this.nodes.forEach(n=>walk(n));
this.filtered = flat.filter(n => (n.name && n.name.toLowerCase().includes(q)) || (n.code && n.code.toLowerCase().includes(q)));
},
openCreate(parentId) {
this.modalTitle = parentId ? 'Add child account' : 'Add root account';
this.form = { id: null, parent_id: parentId, code: '', name: '', account_type: 1, description: '' };
this.editing = false;
this.modal = true;
},
openEdit(id) {
const node = this.findNodeById(id);
if(!node) return;
this.form = { id: node.id, parent_id: node.parent_id, code: node.code || '', name: node.name, account_type: node.account_type, description: node.description || '' };
this.modalTitle = 'Edit account';
this.editing = true;
this.modal = true;
},
closeModal() { this.modal = false; },
findNodeById(id) {
let found = null;
const walk = (n) => {
if(n.id === id) { found = n; return; }
if(n.children) n.children.forEach(c=> walk(c));
};
this.nodes.forEach(n=>walk(n));
return found;
},
async save() {
const payload = {...this.form};
let url = "{{ route('coa.store') }}";
let method = 'POST';
if(this.editing && payload.id) {
url = `/coa/${payload.id}`;
method = 'PUT';
}
const res = await fetch(url, {
method,
headers: {
'Content-Type':'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify(payload)
});
if(res.ok) {
await this.loadTree();
this.modal = false;
} else {
const err = await res.json();
alert(err.message || 'Error saving');
}
},
async remove(id) {
if(!confirm('Delete this account?')) return;
const res = await fetch(`/coa/${id}`, {
method: 'DELETE',
headers: {'X-CSRF-TOKEN': '{{ csrf_token() }}'}
});
if(res.ok) {
await this.loadTree();
} else {
const err = await res.json();
alert(err.message || 'Could not delete');
}
},
async deleteAccount() {
if(!confirm('Delete this account? This may be blocked if the account is used.')) return;
await this.remove(this.form.id);
this.modal = false;
}
}
}
</script>
@endpush
@endsectionNotes:
- The Blade view uses a tiny custom component
<x-coa-node>referenced below. If you prefer plain inline, you can replace it — I include it for cleanliness.
Create a Blade component to render a node recursively. Put under resources/views/components/coa-node.blade.php.
@props(['node','children' => []])
<div x-data="{ open: true }" class="pl-2">
<div class="flex items-center gap-3">
<button @click="open = !open" class="text-sm" x-text="open ? '▾' : '▸'"></button>
<div class="flex-1">
<span class="font-medium" x-text="{{ json_encode($node['code'] ?? '') }}"></span>
<span class="text-sm text-gray-600 ml-2" x-text="{{ json_encode($node['name'] ?? '') }}"></span>
</div>
<div class="text-sm text-gray-500">
<button @click="$dispatch('edit', { id: '{{ $node['id'] }}' })" class="px-2 py-0.5 rounded hover:bg-gray-100">Edit</button>
</div>
</div>
@if(!empty($children))
<div x-show="open" class="pl-6 mt-1">
@foreach($children as $child)
<x-coa-node :node="$child" :children="$child['children'] ?? []" />
@endforeach
</div>
@endif
</div>If your Blade component system differs, you can simply inline recursion in
index.blade.php. The Alpine-based tree inindex.blade.phptreats nodes as objects; this component approximates rendering — tweak to match your data shape.
Add a simple Gate/Policy so only users with the right role can manage the COA. Example in AuthServiceProvider:
Gate::define('manage-coa', function($user) {
return $user->hasRole('account-admin') || $user->is_admin;
});Then protect routes with middleware or call authorize('manage-coa') in controller methods.
- The IFRS package prevents deleting accounts that are posted in ledgers; your controller catches exceptions and returns 409. Keep that friendly message.
- The package expects
entity_idon account creation. Make sureauth()->user()->entity_idexists and maps to the correct entity. - Account
account_typeis an integer constant; use the package constants (I usedAccount::ASSETetc. in the seeder/service). - When renaming/re-parenting accounts, ensure there are no business rules preventing type change or parent switch when transactions exist.
- Create account (root + child) and verify
parent_idpersisted. - Try deleting a posted account: assert 409.
- Rename an account used in transactions: ensure either allowed or blocked per IFRS rules.
- Seed default COA and assert accounts exist for
entity_id.
#################### Part 5 ###################
Great — below is a complete, ready-to-drop-in backend blueprint. It contains:
- a filesystem layout for the accounting domain,
- production-grade
JournalServiceandLedgerPostingService(with transactions, locking, idempotency, events, logging, queueing hooks), - a
JournalPolicy(and registration instructions) for permission checks, - four transaction-type classes (Sales, Purchase, Payment, ReceiptVoucher) that wrap/extend the IFRS transaction model and contain domain-specific validation / posting helpers,
- a small
TransactionValidatorhelper used by services.
I kept code practical and defensive (DB transactions, lockForUpdate, idempotency keys, events, audit logging). You may need to adapt tiny package-model method names if your installed eloquent-ifrs version uses different method names — I used the package names shown earlier (IFRS\Models\Account, IFRS\Models\JournalEntry, IFRS\Models\LineItem, IFRS\Models\ReportingPeriod) and wrapped them in our domain services.
app/
├─ Domain/
│ └─ Accounting/
│ ├─ Models/
│ │ └─ (thin wrappers if needed)
│ ├─ Services/
│ │ ├─ JournalService.php
│ │ ├─ LedgerPostingService.php
│ │ ├─ TransactionValidator.php
│ │ └─ Transactions/
│ │ ├─ SalesTransaction.php
│ │ ├─ PurchaseTransaction.php
│ │ ├─ PaymentTransaction.php
│ │ └─ ReceiptVoucherTransaction.php
│ ├─ Policies/
│ │ └─ JournalPolicy.php
│ ├─ Events/
│ │ ├─ JournalValidated.php
│ │ └─ JournalPosted.php
│ └─ Jobs/
│ └─ RecomputeAccountBalancesJob.php
|
app/Http/Controllers/Accounting/
├─ JournalController.php (optional; thin - uses services)
|
database/
├─ seeders/
│ └─ (optional)
|
routes/
├─ api.php (or web.php) - register controllers/routes
Paste these files into the paths above.
<?php
namespace App\Domain\Accounting\Services;
use IFRS\Models\ReportingPeriod;
use IFRS\Models\Account;
use Illuminate\Support\Collection;
use Exception;
/**
* TransactionValidator
*
* Centralized business rules for validating a Transaction/Journal before posting.
*/
class TransactionValidator
{
/**
* Ensure transaction/journal date falls in an open reporting period.
*
* @param \Illuminate\Database\Eloquent\Model $transaction (JournalEntry / Transaction)
* @return void
* @throws Exception
*/
public static function checkPeriodOpen($transaction): void
{
$period = ReportingPeriod::where('entity_id', $transaction->entity_id)
->where('start_date', '<=', $transaction->date)
->where('end_date', '>=', $transaction->date)
->first();
if (!$period) {
throw new Exception("No reporting period found covering {$transaction->date} for entity {$transaction->entity_id}.");
}
if ($period->status === 'closed') {
throw new Exception("Reporting period covering {$transaction->date} is closed.");
}
}
/**
* Ensure each account referenced is active and not locked.
*
* @param \Illuminate\Database\Eloquent\Model $transaction
* @return void
* @throws Exception
*/
public static function checkAccountsState($transaction): void
{
$lineItems = $transaction->lineItems ?? $transaction->line_items ?? [];
foreach ($lineItems as $li) {
$account = $li->account ?? Account::find($li->account_id);
if (!$account) {
throw new Exception("Account {$li->account_id} not found.");
}
if (property_exists($account, 'is_locked') && $account->is_locked) {
throw new Exception("Account {$account->name} ({$account->id}) is locked and cannot be used in transaction.");
}
// if package has active flag:
if (isset($account->is_active) && $account->is_active === false) {
throw new Exception("Account {$account->name} ({$account->id}) is inactive.");
}
}
}
/**
* Ensure transaction is balanced (debits == credits) using numeric scale.
*
* @param \Illuminate\Database\Eloquent\Model $transaction
* @param int $scale
* @return void
* @throws Exception
*/
public static function checkBalanced($transaction, int $scale = 6): void
{
$debits = 0.0;
$credits = 0.0;
$lineItems = $transaction->lineItems ?? $transaction->line_items ?? [];
foreach ($lineItems as $li) {
$debits += (float)($li->debit ?? 0);
$credits += (float)($li->credit ?? 0);
}
$diff = round($debits - $credits, $scale);
if (abs($diff) > pow(10, -$scale)) {
throw new Exception("Transaction not balanced (debits={$debits}, credits={$credits}, diff={$diff}).");
}
}
/**
* Basic currency checks (if multi-currency enabled)
*
* @param \Illuminate\Database\Eloquent\Model $transaction
* @return void
* @throws Exception
*/
public static function checkCurrencyConsistency($transaction): void
{
if (!isset($transaction->currency_id)) return;
$txCurrency = $transaction->currency_id;
$lineItems = $transaction->lineItems ?? $transaction->line_items ?? [];
foreach ($lineItems as $li) {
if (isset($li->currency_id) && $li->currency_id !== $txCurrency) {
throw new Exception("Currency mismatch: transaction currency {$txCurrency} vs line item currency {$li->currency_id}.");
}
}
}
}<?php
namespace App\Domain\Accounting\Services;
use IFRS\Models\JournalEntry;
use IFRS\Models\LineItem;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Domain\Accounting\Events\JournalValidated;
use Exception;
/**
* JournalService
*
* Responsible for creating drafts, adding/removing lines, validating, and preparing for posting.
* Thin orchestrator around IFRS JournalEntry model.
*/
class JournalService
{
/**
* Create a draft journal (returns IFRS JournalEntry model)
*
* @param array $payload (entity_id, date, narration, currency_id, journal_id(optional))
* @return JournalEntry
*/
public function createDraft(array $payload): JournalEntry
{
$payload['id'] = $payload['id'] ?? (string) Str::uuid();
$payload['status'] = $payload['status'] ?? 'draft';
return JournalEntry::create($payload);
}
/**
* Add a line to a journal draft.
*
* @param JournalEntry $journal
* @param array $line (account_id, debit, credit, description, contact_id, currency_id)
* @return LineItem
* @throws Exception
*/
public function addLine(JournalEntry $journal, array $line)
{
if ($journal->isPosted()) {
throw new Exception('Cannot add line to posted journal.');
}
// Basic validation
if (empty($line['account_id'])) {
throw new Exception('account_id is required.');
}
if (empty($line['debit']) && empty($line['credit'])) {
throw new Exception('Either debit or credit must be provided.');
}
if (!empty($line['debit']) && !empty($line['credit'])) {
throw new Exception('Provide only debit or credit, not both.');
}
// Create line item using package helper (assumes LineItem model exists)
$li = new LineItem([
'id' => (string) Str::uuid(),
'account_id' => $line['account_id'],
'debit' => $line['debit'] ?? 0,
'credit' => $line['credit'] ?? 0,
'narration' => $line['description'] ?? null,
'contact_id' => $line['contact_id'] ?? null,
'currency_id' => $line['currency_id'] ?? null,
'quantity' => $line['quantity'] ?? 1,
]);
// attach to journal (package-specific method)
$journal->lineItems()->save($li);
return $li;
}
/**
* Remove a line (only drafts), by id.
*
* @param JournalEntry $journal
* @param string $lineId
* @return bool
*/
public function removeLine(JournalEntry $journal, string $lineId): bool
{
if ($journal->isPosted()) {
throw new Exception('Cannot remove line from posted journal.');
}
$li = $journal->lineItems()->where('id', $lineId)->first();
if (!$li) return false;
return $li->delete();
}
/**
* Validate (business rules) and mark as validated.
* Throws on any invariant failure.
*
* @param JournalEntry $journal
* @return JournalEntry
* @throws Exception
*/
public function validateJournal(JournalEntry $journal): JournalEntry
{
if ($journal->isPosted()) {
throw new Exception('Journal is already posted.');
}
// perform package-level validation (if provided)
if (method_exists($journal, 'validate')) {
$journal->validate(); // will throw if package rules fail
}
// Domain-level validation
TransactionValidator::checkPeriodOpen($journal);
TransactionValidator::checkAccountsState($journal);
TransactionValidator::checkCurrencyConsistency($journal);
TransactionValidator::checkBalanced($journal);
// mark as validated
$journal->status = 'validated';
$journal->validated_by = auth()->id() ?? null;
$journal->validated_at = now();
$journal->save();
// dispatch event (listeners may trigger audit logs, notifications)
event(new JournalValidated($journal));
return $journal;
}
}<?php
namespace App\Domain\Accounting\Services;
use IFRS\Models\JournalEntry;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Domain\Accounting\Jobs\RecomputeAccountBalancesJob;
use App\Domain\Accounting\Events\JournalPosted;
use Exception;
/**
* LedgerPostingService
*
* Responsible for posting a validated journal to the ledger (immutable).
* Uses DB-level row locking to avoid concurrent double-posts and ensures idempotency.
*/
class LedgerPostingService
{
/**
* Post a validated JournalEntry to ledger.
*
* @param JournalEntry $journal
* @param array $options
* - 'user_id' => actor id
* - 'force' => bool (force post even if not validated)
* @return JournalEntry
* @throws Exception
*/
public function post(JournalEntry $journal, array $options = []): JournalEntry
{
// Ensure we're working with a fresh, locked instance to prevent race conditions
DB::beginTransaction();
try {
$journal = JournalEntry::where('id', $journal->id)->lockForUpdate()->first();
// Idempotency: if already posted, return
if ($journal->isPosted()) {
DB::commit();
return $journal;
}
// If not validated, do not post unless forced
if (!in_array($journal->status, ['validated','approved']) && empty($options['force'])) {
throw new Exception("Journal must be validated/approved before posting. Current status: {$journal->status}");
}
// Re-run validations right before posting to be safe
if (method_exists($journal, 'validate')) {
$journal->validate();
}
TransactionValidator::checkPeriodOpen($journal);
TransactionValidator::checkAccountsState($journal);
TransactionValidator::checkBalanced($journal);
// Perform posting using package method (post() creates ledger entries)
if (!method_exists($journal, 'post')) {
throw new Exception('IFRS package JournalEntry::post() not available in installed version.');
}
$journal->post(); // package creates ledger rows
// Mark as posted
$journal->status = 'posted';
$journal->posted_by = $options['user_id'] ?? auth()->id() ?? null;
$journal->posted_at = now();
$journal->save();
// Queue recompute of affected account balances (do this asynchronously)
$affectedAccountIds = collect($journal->lineItems)->pluck('account_id')->unique()->values()->all();
RecomputeAccountBalancesJob::dispatch($affectedAccountIds, $journal->entity_id);
// Fire event for other systems (audit, notifications)
event(new JournalPosted($journal));
DB::commit();
return $journal;
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Failed posting journal: '.$e->getMessage(), [
'journal_id' => $journal->id ?? null,
'exception' => $e,
]);
throw $e;
}
}
}<?php
namespace App\Domain\Accounting\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use IFRS\Models\Account;
use Illuminate\Support\Facades\Log;
class RecomputeAccountBalancesJob implements ShouldQueue
{
use InteractsWithQueue, Queueable, SerializesModels;
public array $accountIds;
public $entityId;
public function __construct(array $accountIds, $entityId = null)
{
$this->accountIds = $accountIds;
$this->entityId = $entityId;
}
public function handle()
{
foreach ($this->accountIds as $accountId) {
$acct = Account::where('id', $accountId)
->when($this->entityId, fn($q) => $q->where('entity_id', $this->entityId))
->first();
if (!$acct) continue;
// Recompute using package helper if present, else custom sum
if (method_exists($acct, 'recomputeBalance')) {
$acct->recomputeBalance();
continue;
}
// Fallback: sum journal lines
$debits = $acct->journalLines()->sum('debit');
$credits = $acct->journalLines()->sum('credit');
$balance = in_array($acct->normal_side, ['debit']) ? $debits - $credits : $credits - $debits;
$acct->cached_balance = $balance;
$acct->saveQuietly();
}
}
}Note: Dispatching balance recompute to queue avoids blocking the posting flow.
Create two simple events to allow logging/notifications.
<?php
namespace App\Domain\Accounting\Events;
use IFRS\Models\JournalEntry;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class JournalValidated
{
use Dispatchable, SerializesModels;
public JournalEntry $journal;
public function __construct(JournalEntry $journal)
{
$this->journal = $journal;
}
}<?php
namespace App\Domain\Accounting\Events;
use IFRS\Models\JournalEntry;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class JournalPosted
{
use Dispatchable, SerializesModels;
public JournalEntry $journal;
public function __construct(JournalEntry $journal)
{
$this->journal = $journal;
}
}Listeners can be attached in EventServiceProvider for audit logging.
<?php
namespace App\Domain\Accounting\Policies;
use App\Models\User;
use IFRS\Models\JournalEntry;
class JournalPolicy
{
/**
* Determine whether the user can view the journal.
*/
public function view(User $user, JournalEntry $journal)
{
return $user->entity_id === $journal->entity_id || $user->is_admin;
}
public function create(User $user)
{
return $user->hasRole('accountant') || $user->hasRole('account-admin') || $user->is_admin;
}
public function validate(User $user, JournalEntry $journal)
{
// Example: only users with 'senior-accountant' or admins can validate
return ($user->hasRole('senior-accountant') || $user->is_admin) && $user->entity_id === $journal->entity_id;
}
public function approve(User $user, JournalEntry $journal)
{
// approval may be same as validate or a separate role
return ($user->hasRole('manager') || $user->is_admin) && $user->entity_id === $journal->entity_id;
}
public function post(User $user, JournalEntry $journal)
{
// only a small set of users can post; or a workflow that allows posting after approval
return ($user->hasRole('treasurer') || $user->is_admin) && $user->entity_id === $journal->entity_id;
}
public function delete(User $user, JournalEntry $journal)
{
// only allow draft deletion by creator or admin
if ($journal->status === 'posted') return false;
return $user->id === $journal->created_by || $user->is_admin;
}
}protected $policies = [
\IFRS\Models\JournalEntry::class => \App\Domain\Accounting\Policies\JournalPolicy::class,
];Use authorize('validate', $journal) or Gate::allows('post', $journal) in controllers.
These are domain-specific classes that create IFRS transactions with typed helpers. They sit under app/Domain/Accounting/Services/Transactions/.
<?php
namespace App\Domain\Accounting\Services\Transactions;
use IFRS\Models\JournalEntry;
use IFRS\Models\LineItem;
use IFRS\Models\Account;
use Illuminate\Support\Str;
use Exception;
/**
* SalesTransaction
*
* Creates and posts a sales journal (invoice-style) using IFRS JournalEntry model.
* This class only handles the posting of journal-level sales transactions — invoice object
* mapping (customer persistence & document PDF) should be done elsewhere.
*/
class SalesTransaction
{
/**
* Build a sales journal draft
*
* $payload = [
* 'entity_id', 'date', 'currency_id', 'customer_id', 'invoice_no',
* 'lines' => [
* ['account_id' => salesAccountId, 'amount' => 100.00, 'description' => 'Product A', 'cogs' => 60, 'inventory_account_id' => ...],
* ],
* 'tax_total' => 10.0,
* 'receivable_account_id' => accountId,
* 'tax_account_id' => accountId,
* ];
*/
public function createJournal(array $payload): JournalEntry
{
$je = JournalEntry::create([
'id' => (string) Str::uuid(),
'entity_id' => $payload['entity_id'],
'date' => $payload['date'],
'narration' => 'Sales Invoice ' . ($payload['invoice_no'] ?? ''),
'currency_id' => $payload['currency_id'] ?? null,
'status' => 'draft',
]);
// Receivable (debit) for total invoice (including tax)
$total = array_sum(array_map(fn($l) => $l['amount'], $payload['lines']));
$total += $payload['tax_total'] ?? 0;
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['receivable_account_id'],
'debit' => $total,
'credit' => 0,
'narration' => 'Accounts Receivable',
]);
// Revenue lines (credit)
foreach ($payload['lines'] as $l) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $l['account_id'],
'debit' => 0,
'credit' => $l['amount'],
'narration' => $l['description'] ?? 'Sales',
]);
// optional COGS/Inventory movement (if inventory tracked) - these should be separate transactions
if (!empty($l['cogs']) && !empty($l['inventory_account_id']) && !empty($l['cogs_account_id'])) {
// debit COGS
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $l['cogs_account_id'],
'debit' => $l['cogs'],
'credit' => 0,
'narration' => 'COGS: ' . ($l['description'] ?? ''),
]);
// credit inventory
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $l['inventory_account_id'],
'debit' => 0,
'credit' => $l['cogs'],
'narration' => 'Inventory reduction',
]);
}
}
// Tax (credit)
if (!empty($payload['tax_total'])) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['tax_account_id'],
'debit' => 0,
'credit' => $payload['tax_total'],
'narration' => 'Sales Tax collected',
]);
}
return $je;
}
}<?php
namespace App\Domain\Accounting\Services\Transactions;
use IFRS\Models\JournalEntry;
use Illuminate\Support\Str;
/**
* PurchaseTransaction
*
* Builds a supplier bill journal: credit payables, debit expense/asset accounts
*/
class PurchaseTransaction
{
public function createJournal(array $payload): JournalEntry
{
$je = JournalEntry::create([
'id' => (string) Str::uuid(),
'entity_id' => $payload['entity_id'],
'date' => $payload['date'],
'narration' => 'Purchase Bill ' . ($payload['ref_no'] ?? ''),
'currency_id' => $payload['currency_id'] ?? null,
'status' => 'draft',
]);
// Expenses / inventory (debits)
foreach ($payload['lines'] as $l) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $l['account_id'],
'debit' => $l['amount'],
'credit' => 0,
'narration' => $l['description'] ?? 'Purchase',
]);
}
// Tax (debit if input tax)
if (!empty($payload['tax_total'])) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['tax_account_id'],
'debit' => $payload['tax_total'],
'credit' => 0,
'narration' => 'Input VAT',
]);
}
// Accounts Payable (credit) - total amount
$total = array_sum(array_map(fn($l) => $l['amount'], $payload['lines'])) + ($payload['tax_total'] ?? 0);
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['payable_account_id'],
'debit' => 0,
'credit' => $total,
'narration' => 'Accounts Payable',
]);
return $je;
}
}<?php
namespace App\Domain\Accounting\Services\Transactions;
use IFRS\Models\JournalEntry;
use Illuminate\Support\Str;
/**
* PaymentTransaction
*
* Records a payment (cash/bank) against invoices/bills.
*/
class PaymentTransaction
{
/**
* Create a payment journal that debits payables/receivables and credits cash/bank.
*
* $payload = [
* 'entity_id','date','currency_id',
* 'payment_account_id' => cash/bank account,
* 'allocations' => [
* ['account_id' => accounts_receivable_or_payable_id, 'amount' => 100],
* ],
* 'narration'
* ];
*/
public function createJournal(array $payload): JournalEntry
{
$je = JournalEntry::create([
'id' => (string) Str::uuid(),
'entity_id' => $payload['entity_id'],
'date' => $payload['date'],
'narration' => $payload['narration'] ?? 'Payment',
'currency_id' => $payload['currency_id'] ?? null,
'status' => 'draft',
]);
$totalAllocated = 0;
foreach ($payload['allocations'] as $alloc) {
// Debit or credit depends on whether it's payment to supplier (credit payable) or receipt from customer (debit receivable)
// Provide explicit 'side' or use sign convention. Assume allocations are being cleared (reduce AR => credit if customer receipt?).
// For clarity require allocation_type: 'payable' or 'receivable' with positive amount.
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $alloc['account_id'],
'debit' => $alloc['debit'] ?? 0,
'credit' => $alloc['credit'] ?? 0,
'narration' => $alloc['description'] ?? null,
]);
$totalAllocated += ($alloc['debit'] ?? 0) + ($alloc['credit'] ?? 0);
}
// Cash/Bank opposite side (credit for payments, debit for receipts) — require caller to pass correct sign
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['payment_account_id'],
'debit' => $payload['payment_debit'] ?? 0,
'credit' => $payload['payment_credit'] ?? 0,
'narration' => $payload['narration'] ?? 'Cash/Bank movement',
]);
return $je;
}
}<?php
namespace App\Domain\Accounting\Services\Transactions;
use IFRS\Models\JournalEntry;
use Illuminate\Support\Str;
/**
* ReceiptVoucherTransaction
*
* For petty cash or receipt vouchers where receipts are recorded (cash receipts).
*/
class ReceiptVoucherTransaction
{
public function createJournal(array $payload): JournalEntry
{
$je = JournalEntry::create([
'id' => (string) Str::uuid(),
'entity_id' => $payload['entity_id'],
'date' => $payload['date'],
'narration' => $payload['narration'] ?? 'Receipt voucher',
'currency_id' => $payload['currency_id'] ?? null,
'status' => 'draft',
]);
// Debit Cash/Bank
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['cash_account_id'],
'debit' => $payload['amount'],
'credit' => 0,
'narration' => 'Cash receipt',
]);
// Credit Revenue or other account
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['credit_account_id'],
'debit' => 0,
'credit' => $payload['amount'],
'narration' => $payload['narration'] ?? null,
]);
return $je;
}
}A simple example for posting a journal:
$journal = JournalEntry::find($id);
$this->authorize('post', $journal);
$postingService = app(\App\Domain\Accounting\Services\LedgerPostingService::class);
$postingService->post($journal, ['user_id' => auth()->id()]);journalEntries/lineItems/ledgertables come fromeloquent-ifrspackage. Keep indexes:(entity_id, date),(account_id, date),posted_at.- Use
uuidPKs for traceability if package supports it. - Partition
line_itemsby year when scale demands it. - Keep
cached_balancefields on accounts and update asynchronously.
-
Idempotency — always protect your POST endpoints with an idempotency key to prevent double-posting from retries. Save incoming idempotency keys against journals or requests.
-
Row locking — use
lockForUpdate()when posting to prevent concurrent posts to the same journal or overlapping balance updates. -
Queue heavy work — recomputing balances, rebuilding materialized views, generating PDFs — do these in worker queues.
-
Audit trail — listeners on
JournalValidatedandJournalPostedshould writeaudit_logswith who/when/changes. -
Backout strategy — never delete posted ledgers. Provide
reversaltransactions (create reversing JE with opposite debits/credits) and link them viareversal_ofmetadata. Consider areversal_reasonfield. -
Rounding — use
numericwith fixed scale (6 decimals internally) and rounding account for tiny differences. -
Tests — property-based tests for ledger invariants (sums equal), integration tests for posting under concurrency, regression tests for period locking.
--
When back-dated (retroactive) journal entries are introduced into an accounting system, the biggest risk is that previously generated reports become incorrect, especially:
- Trial Balance as of a past date
- Balance Sheet
- Profit & Loss
- Cash flow
- Tax filings
This is a core accounting problem, not just a software problem. Well-designed systems use a combination of period locking, reversal techniques, adjustment periods, and immutable audit layers to prevent or control the impact.
Below is a complete breakdown of how a battle-tested accounting system handles it safely and compliantly.
A professional accounting system never allows back-posting into:
- A closed month
- A tax-submitted period
- A year that has been audited
Solution:
periods
id
start_date
end_date
status: OPEN | LOCKED | ARCHIVED | AUDITED
locked_by
locked_at
Your journal posting logic must reject entries into locked periods.
Earlier reports always match previous statements.
Soft lock = allow back-posting only to users with permission (Accountant, Auditor).
When a back-dated entry is posted:
- A flag is stored (
back_posted = true) - Reports generated earlier are recomputed, but changes are highlighted:
journal_entries
id
date
posted_at
back_posted (boolean)
affects_prior_reports (boolean)
You allow flexibility while maintaining transparency.
Instead of posting into the actual past month, many accounting systems (SAP / Oracle / Dynamics) use an adjustment period:
Example:
2025-06 (normal month)
2025-06A (adjustment month)
- Back-dated entries are not inserted into June.
- They are inserted into JuneA, a special period attached to June.
- Financial reports include June + JuneA when recalculated.
- Previously exported reports (without JuneA) remain valid.
This is the cleanest solution for systems requiring strict audit trails.
Never update balances in place.
- Every ledger entry is append-only.
- When a back-entry is posted, you add entries that adjust future balances.
Example:
If an invoice dated May was missing, and you post it now:
- The system posts May journal entries
- PLUS posts “carry-forward adjustment journals” automatically into today to keep running balances correct.
This is used by double-entry engines like Ledger CLI, ERPNext, and Wave Accounting.
Every time a report is generated:
- Store a snapshot hash of ledger balances.
- Give the snapshot an ID / version number.
Later changes do not affect past snapshot exports.
Example:
snapshots
id
type: TRIAL_BALANCE | BS | PL
date
balances (json)
created_at
Historical exports never change.
ERPNext, Odoo, Xero all use some version of this.
If a back-dated entry is posted:
-
Add an audit note:
- Who posted it
- When it was posted
- Which period it affects
- Which previously-issued reports are now “impacted”
This is a compliance requirement in many jurisdictions.
Your system should combine:
You cannot go into closed periods.
Only senior accountants can backdate in current year.
Prevents corruption of earlier reports while staying GAAP/IFRS compliant.
Ensures complete auditability.
This combo gives:
- Accuracy
- Auditability
- Regulatory compliance
- Practical flexibility
if ($period->isLocked()) {
throw new \Exception("Cannot post journal entry into locked period.");
}
if ($date < $period->start_date && !auth()->user()->can('backdate-journal')) {
throw new \Exception("You do not have permission to post backdated entries.");
}$journal->back_posted = $journal->date < now()->toDateString();
$journal->affects_prior_reports = $journal->back_posted;
$journal->save();if ($journal->back_posted) {
$this->postCarryforwardAdjustment($journal);
}This keeps future balances correct.
public function getAdjustmentPeriod(): Period
{
return Period::firstOrCreate([
'start_date' => $this->end_date,
'adjustment' => true
]);
}Whenever generating a report:
$hasAdjustments = JournalEntry::where('affects_prior_reports', true)
->where('date', '<=', $reportDate)
->exists();If true → show a note: “This report includes adjustments posted after the report date.”
| Requirement | Best Method |
|---|---|
| Small business, simplicity | Allow back-date + snapshot reporting |
| Medium business, accountant staff | Soft locks + adjustment periods |
| Large / audit-heavy | Hard locks + adjustment layers + immutable ledger |
| Tax compliance (VAT/GST) | Hard month end lock + reversal entries |
Nice — below is a complete, drop-in backend implementation covering all four deliverables you asked for:
- Database schema (migrations) for period locks, adjustment periods, snapshots, and audit logs.
- Production-grade
JournalService+LedgerPostingServiceupdated to handle back-dated (retroactive) postings safely (back_posted flags, adjustments, idempotency, locking). - A full IFRS-compliant workflow for retroactive postings: validation, permission gates, adjustment periods, carry-forward adjustments and snapshot handling.
- Audit logging and reporting-impact detection (how reports know they were affected and how to surface that).
All code below is Laravel-style (migrations, models, services, events, job). Adapt names to your app conventions. The code assumes you already have IFRS package models (JournalEntry, LineItem, ReportingPeriod, Account). I show explicit places to wire into your existing models.
Create these four migrations.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateReportingPeriodsExtendedTable extends Migration
{
public function up()
{
Schema::table('reporting_periods', function (Blueprint $table) {
// if using vendor migration, add needed columns
if (!Schema::hasColumn('reporting_periods', 'status')) {
$table->string('status')->default('open')->after('end_date'); // open|locked|archived|audited
}
if (!Schema::hasColumn('reporting_periods', 'adjustment_of')) {
$table->uuid('adjustment_of')->nullable()->after('id'); // points to base period if it's an adjustment period
}
if (!Schema::hasColumn('reporting_periods', 'locked_by')) {
$table->uuid('locked_by')->nullable()->after('status');
}
if (!Schema::hasColumn('reporting_periods', 'locked_at')) {
$table->timestamp('locked_at')->nullable()->after('locked_by');
}
$table->index(['entity_id','start_date','end_date']);
});
}
public function down()
{
Schema::table('reporting_periods', function (Blueprint $table) {
$table->dropIndex(['entity_id','start_date','end_date']);
$table->dropColumn(['status','adjustment_of','locked_by','locked_at']);
});
}
}Stores flags on journal entries.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateJournalEntryMetaTable extends Migration
{
public function up()
{
Schema::table('transactions', function (Blueprint $table) { // package may use `transactions`
if (!Schema::hasColumn('transactions', 'back_posted')) {
$table->boolean('back_posted')->default(false);
}
if (!Schema::hasColumn('transactions', 'affects_prior_reports')) {
$table->boolean('affects_prior_reports')->default(false);
}
if (!Schema::hasColumn('transactions', 'reversal_of')) {
$table->uuid('reversal_of')->nullable()->after('id'); // points to original journal if this is a reversal
}
if (!Schema::hasColumn('transactions', 'idempotency_key')) {
$table->string('idempotency_key', 128)->nullable()->index();
}
$table->index(['entity_id','date','back_posted']);
});
}
public function down()
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn(['back_posted','affects_prior_reports','reversal_of','idempotency_key']);
});
}
}NOTE: If the IFRS package uses a different table name (
journal_entriesetc.), adjusttransactionsaccordingly.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateReportSnapshotsTable extends Migration
{
public function up()
{
Schema::create('report_snapshots', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('entity_id')->index();
$table->string('type'); // trial_balance | balance_sheet | profit_loss | cashflow
$table->date('as_of_date')->index();
$table->jsonb('balances'); // compressed balances / payload
$table->jsonb('meta')->nullable(); // e.g. generator_version, notes
$table->uuid('created_by')->nullable();
$table->timestamps();
$table->unique(['entity_id','type','as_of_date'],'snap_unique_idx');
});
}
public function down()
{
Schema::dropIfExists('report_snapshots');
}
}<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAuditLogsTable extends Migration
{
public function up()
{
Schema::create('accounting_audit_logs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->uuid('entity_id')->index();
$table->uuid('journal_id')->nullable()->index();
$table->uuid('user_id')->nullable()->index();
$table->string('action'); // created|validated|approved|posted|reversed|back_posted
$table->text('message')->nullable();
$table->jsonb('old')->nullable();
$table->jsonb('new')->nullable();
$table->jsonb('meta')->nullable(); // e.g. affected_reports: [ ... ]
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('accounting_audit_logs');
}
}Add two simple models (Snapshot, AuditLog).
<?php
namespace App\Domain\Accounting\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class ReportSnapshot extends Model
{
protected $table = 'report_snapshots';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['id','entity_id','type','as_of_date','balances','meta','created_by'];
protected $casts = [
'balances' => 'array',
'meta' => 'array',
];
public static function createSnapshot($entityId, string $type, string $asOfDate, array $balances, $createdBy = null)
{
return self::create([
'id' => (string) Str::uuid(),
'entity_id' => $entityId,
'type' => $type,
'as_of_date' => $asOfDate,
'balances' => $balances,
'meta' => [
'generator' => config('app.version') ?? 'unknown',
'generated_at' => now()->toIso8601String(),
],
'created_by' => $createdBy,
]);
}
}<?php
namespace App\Domain\Accounting\Models;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
protected $table = 'accounting_audit_logs';
protected $fillable = ['entity_id','journal_id','user_id','action','message','old','new','meta'];
protected $casts = ['old'=>'array','new'=>'array','meta'=>'array'];
}Now the main logic: JournalService and LedgerPostingService with back-post handling, adjustment periods and snapshots.
<?php
namespace App\Domain\Accounting\Services;
use IFRS\Models\JournalEntry;
use IFRS\Models\LineItem;
use IFRS\Models\ReportingPeriod;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use App\Domain\Accounting\Models\AuditLog;
use Exception;
class JournalService
{
protected $validator;
public function __construct()
{
$this->validator = TransactionValidator::class;
}
/**
* Create draft journal with idempotency key support.
* If idempotency_key provided and found, return existing.
*/
public function createDraft(array $payload)
{
if (!empty($payload['idempotency_key'])) {
$existing = JournalEntry::where('idempotency_key',$payload['idempotency_key'])
->where('entity_id',$payload['entity_id'])
->first();
if ($existing) return $existing;
}
$journal = JournalEntry::create(array_merge($payload, [
'id' => $payload['id'] ?? (string) Str::uuid(),
'status' => $payload['status'] ?? 'draft',
'idempotency_key' => $payload['idempotency_key'] ?? null,
]));
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => auth()->id(),
'action' => 'created',
'message' => 'Created draft journal',
'new' => $journal->toArray(),
]);
return $journal;
}
/**
* Add line to draft.
*/
public function addLine(JournalEntry $journal, array $line)
{
if ($journal->isPosted()) {
throw new Exception('Cannot add lines to posted journals.');
}
// Basic validation
if (empty($line['account_id'])) throw new Exception('account_id required');
if (empty($line['debit']) && empty($line['credit'])) throw new Exception('debit or credit required');
if (!empty($line['debit']) && !empty($line['credit'])) throw new Exception('Only one of debit/credit allowed');
$attributes = [
'id' => (string) Str::uuid(),
'account_id' => $line['account_id'],
'debit' => $line['debit'] ?? 0,
'credit' => $line['credit'] ?? 0,
'narration' => $line['description'] ?? null,
'contact_id' => $line['contact_id'] ?? null,
'quantity' => $line['quantity'] ?? 1,
];
$li = $journal->lineItems()->create($attributes);
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => auth()->id(),
'action' => 'line_added',
'message' => 'Added line',
'new' => $li->toArray(),
]);
return $li;
}
/**
* Validate and mark validated.
* If back-dated, mark flags and compute affected snapshots.
*/
public function validate(JournalEntry $journal)
{
if ($journal->isPosted()) throw new Exception('Journal already posted');
// Package-level validation (if available)
if (method_exists($journal, 'validate')) {
$journal->validate();
}
// Domain validations
TransactionValidator::checkBalanced($journal);
TransactionValidator::checkAccountsState($journal);
// Check period
$period = ReportingPeriod::where('entity_id', $journal->entity_id)
->where('start_date','<=',$journal->date)
->where('end_date','>=',$journal->date)
->first();
if (!$period) {
// If no base period found, create an adjustment period for that month/day
$period = $this->getOrCreateAdjustmentPeriod($journal->entity_id, $journal->date);
// mark this as back_posted because original period didn't exist (or it's outside configured)
$journal->back_posted = true;
} else {
// If date precedes current latest closed snapshot or earlier snapshot exists,
// and period is 'closed' then either reject or mark as back_posted if allowed
if (in_array($period->status,['locked','archived','audited'])) {
// permission check
if (!auth()->user() || !auth()->user()->can('backdate-journal')) {
throw new Exception("Period {$period->id} is locked; backdating requires permission.");
}
$journal->back_posted = true;
}
}
// If the journal date is before now and not today -> mark back_posted true
if ($journal->date < now()->toDateString() && !$journal->back_posted) {
// if user has explicit permission to backdate, allow but mark
if (auth()->user() && auth()->user()->can('backdate-journal')) {
$journal->back_posted = true;
}
}
// If back_posted true, it will likely affect earlier reports:
if ($journal->back_posted) {
$journal->affects_prior_reports = $this->detectReportImpact($journal);
}
$journal->status = 'validated';
$journal->validated_by = auth()->id();
$journal->validated_at = now();
$journal->save();
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => auth()->id(),
'action' => 'validated',
'message' => 'Journal validated',
'new' => $journal->toArray(),
]);
return $journal;
}
/**
* Create or fetch adjustment period for a given date
*/
public function getOrCreateAdjustmentPeriod($entityId, $date)
{
$d = \Carbon\Carbon::parse($date);
// base period is month container
$start = $d->copy()->startOfMonth()->toDateString();
$end = $d->copy()->endOfMonth()->toDateString();
// find base
$base = ReportingPeriod::where('entity_id',$entityId)
->where('start_date', $start)
->where('end_date', $end)
->first();
if ($base && $base->status === 'open') return $base;
// get or create adjustment period label
$adj = ReportingPeriod::where('entity_id',$entityId)
->where('adjustment_of', $base->id ?? null)
->first();
if ($adj) return $adj;
// create adjustment period that sits after base end_date (same date range but flagged)
$adj = ReportingPeriod::create([
'entity_id' => $entityId,
'start_date' => $start,
'end_date' => $end,
'adjustment_of' => $base->id ?? null,
'status' => 'open',
]);
return $adj;
}
/**
* Detect if the journal will affect prior snapshots/reports
*/
protected function detectReportImpact(JournalEntry $journal): bool
{
// If there exists a snapshot for this entity with date >= journal.date, then this journal affects prior reports
$exists = \App\Domain\Accounting\Models\ReportSnapshot::where('entity_id',$journal->entity_id)
->where('as_of_date','>=',$journal->date)
->exists();
return $exists;
}
/**
* Create a reversal journal linked to original. Use for corrections.
*/
public function createReversal(JournalEntry $original, array $options = [])
{
if (!$original->isPosted()) {
throw new Exception('Only posted journals can be reversed.');
}
$rev = JournalEntry::create([
'id' => (string) Str::uuid(),
'entity_id' => $original->entity_id,
'date' => $options['date'] ?? now()->toDateString(),
'narration' => 'Reversal of '.$original->id.' — '.$( $options['reason'] ?? ''),
'status' => 'draft',
'reversal_of' => $original->id,
]);
// invert amounts
foreach ($original->lineItems as $li) {
$rev->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $li->account_id,
'debit' => $li->credit,
'credit' => $li->debit,
'narration' => 'Reversal: '.$li->narration,
]);
}
AuditLog::create([
'entity_id' => $rev->entity_id,
'journal_id' => $rev->id,
'user_id' => auth()->id(),
'action' => 'reversal_created',
'message' => 'Created reversal for '.$original->id,
'meta' => ['original' => $original->id],
]);
return $rev;
}
}This has extra logic: if journal is back_posted and affects prior reports, create carry-forward adjustments and optionally create an adjustment period posting instead of touching base period.
<?php
namespace App\Domain\Accounting\Services;
use IFRS\Models\JournalEntry;
use IFRS\Models\ReportingPeriod;
use Illuminate\Support\Facades\DB;
use App\Domain\Accounting\Models\ReportSnapshot;
use App\Domain\Accounting\Models\AuditLog;
use App\Domain\Accounting\Jobs\RecomputeAccountBalancesJob;
use Exception;
use Illuminate\Support\Facades\Log;
class LedgerPostingService
{
/**
* Post a validated journal. Handles back-posted journals via adjustment periods and carry-forward adjustments.
*
* @param JournalEntry $journal
* @param array $options ['user_id'=>..,'force'=>bool]
* @return JournalEntry
* @throws Exception
*/
public function post(JournalEntry $journal, array $options = [])
{
DB::beginTransaction();
try {
// Lock row to avoid concurrent posting
$journal = JournalEntry::where('id',$journal->id)->lockForUpdate()->first();
if ($journal->isPosted()) {
DB::commit();
return $journal;
}
// Must be validated or forced
if (!in_array($journal->status, ['validated','approved']) && empty($options['force'])) {
throw new Exception('Journal must be validated/approved to post.');
}
// Final checks
TransactionValidator::checkBalanced($journal);
TransactionValidator::checkAccountsState($journal);
TransactionValidator::checkPeriodOpen($journal); // may throw if strict
// If back_posted flag and it affects prior reports -> handle specially
if ($journal->back_posted && $journal->affects_prior_reports) {
// 1. Post to adjustment period instead of directly into closed period (if adjustment_of exists)
$period = ReportingPeriod::where('entity_id',$journal->entity_id)
->where('start_date','<=',$journal->date)
->where('end_date','>=',$journal->date)
->first();
$adjPeriod = null;
if ($period && $period->status !== 'open') {
// ensure adjustment period exists and is open
$adjPeriod = ReportingPeriod::where('entity_id',$journal->entity_id)
->where('adjustment_of',$period->id)
->first();
if (!$adjPeriod) {
$adjPeriod = ReportingPeriod::create([
'entity_id' => $journal->entity_id,
'start_date' => $period->start_date,
'end_date' => $period->end_date,
'adjustment_of' => $period->id,
'status' => 'open',
]);
}
}
// Option A: Post journal dated to the original date but mark adjustments.
// Here we still call the package post() which will create ledger entries
// The IFRS package might accept posting into adjustment period or we mark journal metadata.
}
// Perform posting via package
if (!method_exists($journal,'post')) {
throw new Exception('IFRS package JournalEntry::post() not available.');
}
$journal->post();
// Mark posted metadata
$journal->status = 'posted';
$journal->posted_by = $options['user_id'] ?? auth()->id();
$journal->posted_at = now();
$journal->save();
// If back_posted and affecting prior snapshots -> rebuild/flag snapshots and create carry-forward adjustment
if ($journal->back_posted && $journal->affects_prior_reports) {
$this->handleBackPostAftermath($journal);
}
// queue recompute of balances
$affected = collect($journal->lineItems)->pluck('account_id')->unique()->values()->all();
RecomputeAccountBalancesJob::dispatch($affected, $journal->entity_id);
// audit
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => $journal->posted_by,
'action' => 'posted',
'message' => 'Journal posted',
'new' => $journal->toArray(),
]);
DB::commit();
return $journal;
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Posting failed: '.$e->getMessage(), ['journal_id'=>$journal->id]);
throw $e;
}
}
/**
* Handle the aftermath of a back-post:
* - Flag/mark affected snapshots and optionally re-generate snapshots
* - Create carry-forward adjustment if required (to keep running balances consistent)
*/
protected function handleBackPostAftermath(JournalEntry $journal)
{
// 1) Mark snapshots that are impacted
$snapshots = ReportSnapshot::where('entity_id',$journal->entity_id)
->where('as_of_date','>=',$journal->date)
->get();
foreach ($snapshots as $snap) {
// tag snapshot meta
$meta = $snap->meta ?? [];
$meta['impacted_by'] = array_unique(array_merge($meta['impacted_by'] ?? [], [$journal->id]));
$snap->meta = $meta;
$snap->save();
}
// 2) Optionally regenerate latest snapshot for as_of_date greater or equal to journal date
// WARNING: regenerating snapshots can be expensive; consider doing async job
foreach ($snapshots as $snap) {
// dispatch a job to recompute snapshot (not implemented here)
// SnapshotRecomputeJob::dispatch($snap->id);
}
// 3) Create carry-forward adjustment if your business requires balance continuity
// This is optional: some systems instead show 'adjusted' reports where applicable.
// Implementation: create a balancing journal at the current date that offsets effect so running balances stay as before.
if (config('accounting.create_carryforward_on_backpost', true)) {
$this->createCarryForward($journal);
}
// 4) Write audit log
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => auth()->id(),
'action' => 'backpost_aftermath',
'message' => 'Back-post impacted snapshots and created adjustments (if enabled)',
'meta' => [
'impacted_snapshots' => $snapshots->pluck('id')->all(),
],
]);
}
/**
* Create a carry-forward balancing journal dated now that neutralizes cumulative effect on running balance.
*
* Approach:
* - Compute net impact per account between original report snapshot and new one, then create a JournalEntry at today that offsets that delta.
*
* WARNING: This is a pragmatic approach. For strict auditability prefer adjustment period method or require manual adjustments by accountant.
*/
protected function createCarryForward(JournalEntry $original)
{
$entityId = $original->entity_id;
$today = now()->toDateString();
// compute net per account from journal line items (debit - credit with sign per normal_side)
$deltas = [];
foreach ($original->lineItems as $li) {
$acctId = $li->account_id;
$debit = (float)$li->debit;
$credit = (float)$li->credit;
$delta = $debit - $credit; // positive means debit increase
$deltas[$acctId] = ($deltas[$acctId] ?? 0) + $delta;
}
// build balancing JE
$je = JournalEntry::create([
'id' => (string) Str::uuid(),
'entity_id' => $entityId,
'date' => $today,
'narration' => 'Carry-forward adjustment for back-posted journal '.$original->id,
'status' => 'draft',
]);
$totalDebit = 0; $totalCredit = 0;
foreach ($deltas as $acctId => $net) {
if ($net > 0) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $acctId,
'debit' => 0,
'credit' => $net, // reverse side to neutralize past debit increase
'narration' => 'Carry-forward neutralization',
]);
$totalCredit += $net;
} elseif ($net < 0) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $acctId,
'debit' => abs($net),
'credit' => 0,
'narration' => 'Carry-forward neutralization',
]);
$totalDebit += abs($net);
}
}
// If totals don't match due to rounding, use rounding account
$diff = round($totalDebit - $totalCredit, 6);
if (abs($diff) > 0.000001) {
$roundingAccount = config('accounting.rounding_account_id');
if (!$roundingAccount) {
// roll back created lines by deleting and log then return
$je->delete();
throw new Exception('Carry-forward rounding needed but no rounding account set');
}
if ($diff > 0) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $roundingAccount,
'debit' => 0,
'credit' => $diff,
'narration' => 'Carry-forward rounding',
]);
} else {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $roundingAccount,
'debit' => abs($diff),
'credit' => 0,
'narration' => 'Carry-forward rounding',
]);
}
}
// Validate and post the carry-forward via existing route (or require approval)
$je->status = 'validated';
$je->save();
// Post using package method (or route to LedgerPostingService)
// Use LedgerPostingService to handle it consistently (dispatch background job or post immediately)
$posting = app(LedgerPostingService::class);
$posting->post($je, ['user_id' => auth()->id() ?? null, 'force'=>true]);
AuditLog::create([
'entity_id' => $entityId,
'journal_id' => $je->id,
'user_id' => auth()->id(),
'action' => 'carry_forward_created',
'message' => 'Created carry-forward adjustment for back-posted journal '.$original->id,
'meta' => ['original'=>$original->id],
]);
}
}Design note:
createCarryForward()is optional and conservative. Some organizations prefer to not create carry-forward adjustments automatically but instead surface impacted reports and let accountants create adjustments manually. The code makes this behaviour configurable viaconfig('accounting.create_carryforward_on_backpost').
Provide a utility for generating snapshots, then use detectReportImpact() earlier to flag journals.
<?php
namespace App\Domain\Accounting\Services;
use App\Domain\Accounting\Models\ReportSnapshot;
use IFRS\Models\Account;
use Illuminate\Support\Facades\DB;
class SnapshotService
{
/**
* Generate a trial balance snapshot as of $asOfDate for $entityId.
* Returns array of balances keyed by account code or id.
*/
public function generateTrialBalance($entityId, string $asOfDate)
{
// Basic example: sum journal_lines up to date grouped by account
$rows = DB::table('journal_lines')
->join('accounts','journal_lines.account_id','accounts.id')
->where('journal_lines.entity_id',$entityId)
->where('journal_lines.created_at','<=', $asOfDate.' 23:59:59')
->select('accounts.id as account_id','accounts.code','accounts.name',
DB::raw('SUM(journal_lines.debit) as debits'),
DB::raw('SUM(journal_lines.credit) as credits'))
->groupBy('accounts.id','accounts.code','accounts.name')
->get();
$balances = [];
foreach ($rows as $r) {
$balances[$r->account_id] = [
'code' => $r->code,
'name' => $r->name,
'debits' => (float)$r->debits,
'credits' => (float)$r->credits,
'balance' => (float)$r->debits - (float)$r->credits,
];
}
// Persist snapshot
ReportSnapshot::createSnapshot($entityId, 'trial_balance', $asOfDate, $balances, auth()->id());
return $balances;
}
}Use this on report generation to create snapshots and record the snapshot id with any exported files or API responses. When a back-post happens, detect snapshots with as_of_date >= journal.date (already in detectReportImpact) and mark impacted_by.
Create listeners to log actions and optionally notify users.
Example: in EventServiceProvider register JournalValidated and JournalPosted events to create AuditLog entries (we already created audit logs inline in services — listeners are optional but recommended for separation).
- Creating a closed period, attempt to post into it (without backdate permission) → expect Exception.
- Create a snapshot for
2025-10-31, then post a journal with date2025-10-15→journal.affects_prior_reportstrue, snapshot meta includes journal id. - Post a back-posted journal with
create_carryforward_on_backpost = trueand assert a carry-forward journal created at today and balances preserved. - Concurrency test: attempt to post same journal twice concurrently with same idempotency_key — assert only one result posted.
Add to config/accounting.php:
return [
'create_carryforward_on_backpost' => env('ACCOUNTING_CARRYFORWARD_BACKPOST', true),
'rounding_account_id' => env('ACCOUNTING_ROUNDING_ACCOUNT_ID', null),
];-
When showing any historical report (Trial Balance / BS / P&L), show a banner if
ReportSnapshot.meta.impacted_byexists or if anyJournalEntrywithaffects_prior_reportsexists for that date range: “This report has been affected by back-dated entries posted after {snapshot_date}. See audit log for details.” -
Provide an endpoint that lists impacted journals with details: who posted, when, what changed.
-
Allow accountants to accept/acknowledge impacted reports (adds an audit note) — useful for compliance.
-
Hard vs soft approach: Hard locks guarantee historical immutability but reduce flexibility. Soft locks with adjustment periods and auditability gives flexibility while maintaining traceability. I recommend the hybrid approach implemented above (locks by default; allow back-post with permission and adjustment handling).
-
Snapshots vs live recompute: Snapshots preserve historical exports. Recomputing everything every time is expensive. Use snapshots for exports and allow controlled recompute for internal dashboards on demand.
-
Carry-forward adjustments caution: Auto-creating carry-forward journals risks hiding the original problem and creating audit noise. Make this optional and visible in the audit trail.
-
Testing: Make sure you have heavy property-based tests around invariants (sum of debits = sum of credits in every posted journal) and concurrency tests.
SnapshotRecomputeJoband queue wiring to regenerate snapshots asynchronously.- Concrete PHPUnit tests (complete code) for the key scenarios above.
- An
artisancommand to scan and mark impacted snapshots and optionally generate carry-forward adjustments in bulk. - An API controller that returns impacted reports and audit trails for a given
as_of_date.
Which of those do you want me to generate next?
###########
Below is a complete, production-grade specification + code for the three things you asked for:
- SnapshotRecomputeJob
- Artisan command to scan + mark impacted snapshots and optionally generate carry-forward adjustments
- API controller to return impacted reports + audit trails for a given
as_of_date
All solutions assume you are using eloquent-ifrs, but adding a snapshot layer on top of it (which IFRS does not provide).
A queued job that:
- Detects which snapshot periods are affected by a late/back-dated transaction
- Marks them as invalid/pending_recompute
- Optionally triggers incremental recompute of balances
- Logs audit trail records
CREATE TABLE report_snapshots (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_id BIGINT UNSIGNED NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
snapshot_type ENUM('trial_balance','balance_sheet','income_statement') NOT NULL,
computed_at DATETIME NULL,
is_valid TINYINT(1) DEFAULT 1,
requires_recompute TINYINT(1) DEFAULT 0,
audit_log JSON NULL,
UNIQUE INDEX snapshot_unique (organization_id, period_start, period_end, snapshot_type)
);<?php
namespace App\Jobs;
use App\Models\ReportSnapshot;
use App\Services\SnapshotRecomputeService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\Log;
class SnapshotRecomputeJob implements ShouldQueue
{
use Dispatchable, Queueable;
public $timeout = 900; // 15 min
protected int $organizationId;
protected array $affectedPeriods;
protected bool $autoRecompute;
public function __construct(
int $organizationId,
array $affectedPeriods,
bool $autoRecompute = false
) {
$this->organizationId = $organizationId;
$this->affectedPeriods = $affectedPeriods;
$this->autoRecompute = $autoRecompute;
}
public function handle(SnapshotRecomputeService $service)
{
foreach ($this->affectedPeriods as $period) {
$snapshots = ReportSnapshot::where('organization_id', $this->organizationId)
->whereBetween('period_start', [$period['start'], $period['end']])
->get();
foreach ($snapshots as $snapshot) {
$snapshot->requires_recompute = true;
$snapshot->is_valid = false;
$snapshot->audit_log = json_encode([
'reason' => 'Late/back-dated transaction',
'affected_period' => $period,
'timestamp' => now(),
]);
$snapshot->save();
Log::info("Snapshot {$snapshot->id} marked for recompute.");
if ($this->autoRecompute) {
$service->recompute($snapshot); // Rebuild the balances
}
}
}
}
}- Finds all transactions whose
transaction_date<created_at(late entries) - Groups them by accounting period
- Optionally triggers recompute jobs
- Or just marks snapshots as invalid
<?php
namespace App\Console\Commands;
use App\Jobs\SnapshotRecomputeJob;
use Illuminate\Console\Command;
use IFRS\Models\Transaction;
class SnapshotScanCommand extends Command
{
protected $signature = 'snapshots:scan
{--org= : Organization ID}
{--fix : Trigger recomputation automatically}';
protected $description = 'Scan for back-dated transactions and mark snapshots as invalid';
public function handle()
{
$orgId = $this->option('org');
$autoFix = $this->option('fix');
if (!$orgId) {
$this->error("Please provide --org");
return 1;
}
$this->info("Scanning for impacted periods...");
$lateTxns = Transaction::where('entity_id', $orgId)
->whereColumn('transaction_date', '<', 'created_at')
->orderBy('transaction_date')
->get();
if ($lateTxns->isEmpty()) {
$this->info("No late/back-dated entries found.");
return 0;
}
$periods = [];
foreach ($lateTxns as $t) {
$periods[] = [
'start' => $t->transaction_date->startOfMonth()->toDateString(),
'end' => $t->transaction_date->endOfMonth()->toDateString(),
'txn_id' => $t->id,
];
}
$uniquePeriods = collect($periods)
->unique(fn($x) => $x['start'].'-'.$x['end'])
->values()
->all();
$this->info("Found " . count($uniquePeriods) . " impacted period(s).");
SnapshotRecomputeJob::dispatch($orgId, $uniquePeriods, $autoFix);
$this->info("Job dispatched.");
return 0;
}
}GET /api/reports/impacted?as_of_date=2024-01-31&org_id=1
{
"period": "2024-01",
"impacted_snapshots": [
{
"type": "trial_balance",
"is_valid": false,
"requires_recompute": true,
"audit": {...}
}
],
"late_transactions": [
{
"id": 1288,
"date": "2024-01-15",
"created_at": "2024-03-10",
"type": "Sales Invoice"
}
]
}<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ReportSnapshot;
use IFRS\Models\Transaction;
use Illuminate\Http\Request;
class ReportImpactController extends Controller
{
public function impacted(Request $request)
{
$request->validate([
'org_id' => 'required|integer',
'as_of_date' => 'required|date'
]);
$orgId = $request->org_id;
$date = carbon($request->as_of_date);
$periodStart = $date->copy()->startOfMonth()->toDateString();
$periodEnd = $date->copy()->endOfMonth()->toDateString();
// Snapshots for this period
$snapshots = ReportSnapshot::where('organization_id', $orgId)
->where('period_start', $periodStart)
->get();
// Late transactions affecting this period
$lateTxns = Transaction::where('entity_id', $orgId)
->whereBetween('transaction_date', [$periodStart, $periodEnd])
->whereColumn('transaction_date', '<', 'created_at')
->get()
->map(function ($t) {
return [
'id' => $t->id,
'date' => $t->transaction_date->toDateString(),
'created_at' => $t->created_at->toDateTimeString(),
'type' => class_basename($t),
];
});
return response()->json([
'period' => $periodStart,
'impacted_snapshots' => $snapshots,
'late_transactions' => $lateTxns,
]);
}
}Snapshots carry a is_valid = false flag after late entries.
The recompute service can generate adjusting entries automatically.
All invalidations are logged in audit_log.
Because recomputation only happens where needed.
I can generate:
✅ SnapshotRecomputeService.php (full implementation)
✅ Database migration for report_snapshots
✅ Ledger adjustments generator
✅ Event listeners that auto-dispatch recompute jobs when journals are posted
Just tell me “continue”.
Great — here is the continuation exactly as promised. You will get four production-grade components:
- SnapshotRecomputeService.php
- Migration for
report_snapshots - Carry-forward Adjustment Generator (auto-adjustments for future-period impact)
- Event Listeners that automatically dispatch recompute jobs whenever a journal/transaction is posted, edited, or deleted.
I’m keeping everything clean, stable, and IFRS-compatible.
This service performs:
- Full recomputation of TB, BS, IS snapshots
- Optionally produces carry-forward adjustments
- Ensures compliance with IFRS opening/closing balances
- Rebuilds from the ledger, not cached numbers
<?php
namespace App\Services;
use App\Models\ReportSnapshot;
use IFRS\Models\Account;
use IFRS\Models\Balance;
use IFRS\Models\ReportingPeriod;
use IFRS\Models\Transaction;
use Illuminate\Support\Facades\DB;
class SnapshotRecomputeService
{
/**
* Recompute a single snapshot instance.
*/
public function recompute(ReportSnapshot $snapshot)
{
return DB::transaction(function () use ($snapshot) {
$period = new ReportingPeriod([
'period_start' => $snapshot->period_start,
'period_end' => $snapshot->period_end,
'entity_id' => $snapshot->organization_id,
]);
$data = match ($snapshot->snapshot_type) {
'trial_balance' => $this->trialBalance($period),
'balance_sheet' => $this->balanceSheet($period),
'income_statement'=> $this->incomeStatement($period),
default => throw new \Exception("Unknown snapshot type"),
};
$snapshot->computed_at = now();
$snapshot->is_valid = true;
$snapshot->requires_recompute = false;
$snapshot->audit_log = json_encode([
'recomputed_at' => now(),
'method' => 'ledger_rebuild',
'period' => [
$snapshot->period_start,
$snapshot->period_end,
],
]);
$snapshot->save();
return $data;
});
}
/**
* Trial balance: Straight from ledger.
*/
protected function trialBalance(ReportingPeriod $period)
{
$accounts = Account::entity($period->entity_id)->get();
$result = [];
foreach ($accounts as $acct) {
$balance = Balance::where('account_id', $acct->id)
->where('period_id', $period->getPeriodId())
->first();
$result[] = [
'account' => $acct->name,
'code' => $acct->code,
'debit' => $balance?->balanceType == 'debit' ? $balance->closingBalance : 0,
'credit' => $balance?->balanceType == 'credit' ? $balance->closingBalance : 0,
];
}
return $result;
}
/**
* Balance Sheet: Assets, Liabilities, Equity.
*/
protected function balanceSheet(ReportingPeriod $period)
{
return [
'assets' => $this->sumAccounts('asset', $period),
'liabilities' => $this->sumAccounts('liability', $period),
'equity' => $this->sumAccounts('equity', $period),
];
}
/**
* Income Statement: Income and Expenses.
*/
protected function incomeStatement(ReportingPeriod $period)
{
return [
'income' => $this->sumAccounts('revenue', $period),
'expenses' => $this->sumAccounts('expense', $period),
'net' => $this->sumAccounts('revenue', $period)
- $this->sumAccounts('expense', $period),
];
}
/**
* Helper for BS/IS account category totals.
*/
protected function sumAccounts(string $category, ReportingPeriod $period): float
{
$accounts = Account::entity($period->entity_id)
->where('account_type', $category)
->get();
$total = 0;
foreach ($accounts as $acct) {
$balance = Balance::where('account_id', $acct->id)
->where('period_id', $period->getPeriodId())
->first();
if ($balance) {
$total += ($balance->balanceType == 'debit')
? $balance->closingBalance
: -$balance->closingBalance;
}
}
return $total;
}
}<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up()
{
Schema::create('report_snapshots', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('organization_id');
$table->date('period_start');
$table->date('period_end');
$table->enum('snapshot_type', [
'trial_balance',
'balance_sheet',
'income_statement'
]);
$table->boolean('is_valid')->default(true);
$table->boolean('requires_recompute')->default(false);
$table->json('audit_log')->nullable();
$table->timestamp('computed_at')->nullable();
$table->timestamps();
$table->unique([
'organization_id',
'period_start',
'period_end',
'snapshot_type'
]);
});
}
public function down()
{
Schema::dropIfExists('report_snapshots');
}
};Used when a back-dated entry changes future balances.
- If a transaction in January is added late, February and onward must be adjusted.
- We do NOT alter past periods; instead we post IFRS-compliant Adjustment Journals.
<?php
namespace App\Services;
use IFRS\Models\JournalEntry;
use IFRS\Models\Ledger;
use IFRS\Models\Transaction;
use IFRS\Models\ReportingPeriod;
use IFRS\Models\Account;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class CarryForwardAdjustmentService
{
/**
* Generates adjusting entries for all future periods.
*/
public function generate(Transaction $txn)
{
return DB::transaction(function () use ($txn) {
$transactionPeriod = Carbon::parse($txn->transaction_date)->startOfMonth();
// All future periods
$periods = ReportingPeriod::where('entity_id', $txn->entity_id)
->where('period_start', '>', $transactionPeriod)
->orderBy('period_start')
->get();
if ($periods->isEmpty()) {
return;
}
foreach ($periods as $period) {
$adjustments = $this->computeAdjustmentsForPeriod($txn, $period);
if (empty($adjustments)) {
continue;
}
$je = new JournalEntry([
'entity_id' => $txn->entity_id,
'date' => $period->period_start,
'narration' => "Carry-forward adjustment due to late entry TXN #{$txn->id}",
]);
$je->save();
// Add lines
foreach ($adjustments as $line) {
Ledger::create([
'account_id' => $line['account_id'],
'entry_id' => $je->id,
'date' => $period->period_start,
'debit' => $line['debit'],
'credit' => $line['credit'],
]);
}
}
});
}
/**
* Compute needed adjustments for a specific future period.
*/
protected function computeAdjustmentsForPeriod(Transaction $txn, ReportingPeriod $period)
{
$differences = [];
foreach ($txn->ledgers as $ledgerLine) {
$acc = Account::find($ledgerLine->account_id);
if (!$acc) continue;
// Only adjust if the account affects carried balances (not nominal accounts)
if (!in_array($acc->account_type, ['asset','liability','equity'])) {
continue;
}
$amount = $ledgerLine->debit - $ledgerLine->credit;
$differences[] = [
'account_id' => $ledgerLine->account_id,
'debit' => $amount > 0 ? abs($amount) : 0,
'credit' => $amount < 0 ? abs($amount) : 0,
];
}
return $differences;
}
}These dispatch recompute jobs whenever a transaction is added, edited, or removed.
app/Providers/EventServiceProvider.php
protected $listen = [
'IFRS\Events\TransactionPosted' => [
'App\Listeners\HandleTransactionChange',
],
'IFRS\Events\TransactionUpdated' => [
'App\Listeners\HandleTransactionChange',
],
'IFRS\Events\TransactionDeleted' => [
'App\Listeners\HandleTransactionChange',
],
];<?php
namespace App\Listeners;
use App\Jobs\SnapshotRecomputeJob;
use App\Services\CarryForwardAdjustmentService;
use Illuminate\Support\Carbon;
class HandleTransactionChange
{
public function handle($event)
{
$txn = $event->transaction;
$txnDate = Carbon::parse($txn->transaction_date);
// Detect back-dated
$isBackDated = $txnDate->lt($txn->created_at);
// Discover affected period
$period = [
'start' => $txnDate->copy()->startOfMonth()->toDateString(),
'end' => $txnDate->copy()->endOfMonth()->toDateString(),
];
// Queue snapshot invalidation job
SnapshotRecomputeJob::dispatch(
$txn->entity_id,
[$period],
false // no autoFix
);
if ($isBackDated) {
app(CarryForwardAdjustmentService::class)->generate($txn);
}
}
}Balances are recomputed based on ledger, not cached data.
No changes to closed months.
Purely professional-grade carry-forward corrections.
Handled via event listeners.
Prevents corrupted reports.
Auditors can see what periods are affected and why.
I can produce:
Just tell me which one you want next.
============
Great — below I deliver everything you asked for except unit tests. This is a production-ready, consistent set of files you can drop into your Laravel app that uses ekmungai/eloquent-ifrs. It includes:
- Full JournalService (complete, idempotent, with create/update/validate/reversal API)
- Full LedgerPostingService (robust posting, concurrency-safe, back-post handling, optional carry-forward)
- A set of Policies (JournalPolicy, TransactionPolicy) and registration instructions
- Expanded Transaction type classes (Sales, Purchase, Payment, Receipt, CreditNote) with domain-level helpers
- A pro-grade folder structure listing and brief guidance for wiring everything together
I kept the code defensive and consistent with earlier pieces (events, snapshots, audit logs, queue jobs). You may need to adapt tiny method names if your installed eloquent-ifrs uses different method names — I used the most common API shapes (JournalEntry, LineItem, ReportingPeriod). All code is PSR-12 style and uses uuid ids where appropriate.
app/
└─ Domain/
└─ Accounting/
├─ Models/
│ └─ (thin wrappers or project-specific extensions)
├─ Services/
│ ├─ JournalService.php
│ ├─ LedgerPostingService.php
│ ├─ TransactionValidator.php (already provided earlier)
│ ├─ SnapshotRecomputeService.php (if not already)
│ ├─ CarryForwardAdjustmentService.php (if not already)
│ └─ Transactions/
│ ├─ SalesTransaction.php
│ ├─ PurchaseTransaction.php
│ ├─ PaymentTransaction.php
│ ├─ ReceiptVoucherTransaction.php
│ └─ CreditNoteTransaction.php
├─ Policies/
│ ├─ JournalPolicy.php
│ └─ TransactionPolicy.php
├─ Events/
│ ├─ JournalValidated.php
│ └─ JournalPosted.php
├─ Jobs/
│ └─ RecomputeAccountBalancesJob.php
└─ Controllers/
└─ JournalController.php (thin)
Wire the policies in AuthServiceProvider:
protected $policies = [
\IFRS\Models\JournalEntry::class => \App\Domain\Accounting\Policies\JournalPolicy::class,
// Optionally map other transaction models
\IFRS\Models\Transaction::class => \App\Domain\Accounting\Policies\TransactionPolicy::class,
];Register events/listeners in EventServiceProvider as previously shown.
File: app/Domain/Accounting/Services/JournalService.php
<?php
namespace App\Domain\Accounting\Services;
use IF
RS\Models\JournalEntry;
use IFRS\Models\LineItem;
use IFRS\Models\ReportingPeriod;
use App\Domain\Accounting\Models\AuditLog;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Exception;
/**
* JournalService
*
* Full-featured journal manager: create drafts, add/remove lines, validate, mark approve, create reversals,
* idempotency key handling, light audit logs and hooks.
*/
class JournalService
{
public function createDraft(array $payload): JournalEntry
{
// Idempotency: return existing if idempotency_key provided & exists
if (!empty($payload['idempotency_key'])) {
$existing = JournalEntry::where('idempotency_key', $payload['idempotency_key'])
->where('entity_id', $payload['entity_id'])
->first();
if ($existing) return $existing;
}
$payload['id'] = $payload['id'] ?? (string) Str::uuid();
$payload['status'] = $payload['status'] ?? 'draft';
$journal = JournalEntry::create($payload);
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => auth()->id(),
'action' => 'created',
'message' => 'Draft journal created',
'new' => $journal->toArray(),
]);
return $journal;
}
public function addLine(JournalEntry $journal, array $line): LineItem
{
if ($journal->isPosted()) {
throw new Exception('Cannot modify a posted journal.');
}
if (empty($line['account_id'])) throw new Exception('account_id required');
if (empty($line['debit']) && empty($line['credit'])) throw new Exception('debit or credit required');
if (!empty($line['debit']) && !empty($line['credit'])) throw new Exception('Provide only debit or credit');
$li = $journal->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $line['account_id'],
'debit' => $line['debit'] ?? 0,
'credit' => $line['credit'] ?? 0,
'narration' => $line['description'] ?? null,
'contact_id' => $line['contact_id'] ?? null,
'currency_id' => $line['currency_id'] ?? null,
'quantity' => $line['quantity'] ?? 1,
]);
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => auth()->id(),
'action' => 'line_added',
'message' => 'Added line to journal',
'new' => $li->toArray(),
]);
return $li;
}
public function removeLine(JournalEntry $journal, string $lineId): bool
{
if ($journal->isPosted()) throw new Exception('Cannot modify a posted journal.');
$li = $journal->lineItems()->where('id', $lineId)->first();
if (!$li) return false;
$old = $li->toArray();
$result = $li->delete();
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => auth()->id(),
'action' => 'line_removed',
'message' => 'Removed line',
'old' => $old,
]);
return $result;
}
public function validate(JournalEntry $journal): JournalEntry
{
if ($journal->isPosted()) throw new Exception('Journal already posted.');
// Package validations
if (method_exists($journal, 'validate')) {
$journal->validate();
}
TransactionValidator::checkBalanced($journal);
TransactionValidator::checkAccountsState($journal);
TransactionValidator::checkCurrencyConsistency($journal);
// period handling
$period = ReportingPeriod::where('entity_id', $journal->entity_id)
->where('start_date', '<=', $journal->date)
->where('end_date', '>=', $journal->date)
->first();
if (!$period) {
// fallback: creating adjustment period if allowed
$svc = new \App\Domain\Accounting\Services\JournalService();
$adj = $svc->getOrCreateAdjustmentPeriod($journal->entity_id, $journal->date);
$journal->back_posted = true;
} elseif (in_array($period->status, ['locked','archived','audited'])) {
if (!auth()->user() || !auth()->user()->can('backdate-journal')) {
throw new Exception('Cannot validate journal into a locked/archived period.');
}
$journal->back_posted = true;
}
// mark validated
$journal->status = 'validated';
$journal->validated_by = auth()->id();
$journal->validated_at = now();
$journal->save();
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => auth()->id(),
'action' => 'validated',
'message' => 'Journal validated',
'new' => $journal->toArray(),
]);
// event for listeners
event(new \App\Domain\Accounting\Events\JournalValidated($journal));
return $journal;
}
public function approve(JournalEntry $journal): JournalEntry
{
if ($journal->isPosted()) throw new Exception('Cannot approve a posted journal.');
$journal->status = 'approved';
$journal->approved_by = auth()->id();
$journal->approved_at = now();
$journal->save();
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => auth()->id(),
'action' => 'approved',
'message' => 'Journal approved',
'new' => $journal->toArray(),
]);
return $journal;
}
public function createReversal(JournalEntry $original, array $options = []): JournalEntry
{
if (!$original->isPosted()) throw new Exception('Only posted journals can be reversed.');
$rev = JournalEntry::create([
'id' => (string) Str::uuid(),
'entity_id' => $original->entity_id,
'date' => $options['date'] ?? now()->toDateString(),
'narration' => 'Reversal of '.$original->id.' — '.$( $options['reason'] ?? ''),
'status' => 'draft',
'reversal_of' => $original->id,
]);
foreach ($original->lineItems as $li) {
$rev->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $li->account_id,
'debit' => $li->credit,
'credit' => $li->debit,
'narration' => 'Reversal: '.$li->narration,
]);
}
AuditLog::create([
'entity_id' => $rev->entity_id,
'journal_id' => $rev->id,
'user_id' => auth()->id(),
'action' => 'reversal_created',
'message' => 'Reversal created for '.$original->id,
'meta' => [
'original' => $original->id,
],
]);
return $rev;
}
}File: app/Domain/Accounting/Services/LedgerPostingService.php
<?php
namespace App\Domain\Accounting\Services;
use IFRS\Models\JournalEntry;
use App\Domain\Accounting\Models\AuditLog;
use App\Jobs\RecomputeAccountBalancesJob;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Exception;
/**
* LedgerPostingService
*
* Posting with concurrency protection, idempotency, optional carry-forward, snapshot invalidation.
*/
class LedgerPostingService
{
public function post(JournalEntry $journal, array $options = [])
{
DB::beginTransaction();
try {
$journal = JournalEntry::where('id', $journal->id)->lockForUpdate()->first();
if ($journal->isPosted()) {
DB::commit();
return $journal;
}
if (!in_array($journal->status, ['validated','approved']) && empty($options['force'])) {
throw new Exception('Journal must be validated or approved before posting.');
}
// Package-level final validation
if (method_exists($journal, 'validate')) {
$journal->validate(); // may throw
}
// Domain checks
TransactionValidator::checkBalanced($journal);
TransactionValidator::checkAccountsState($journal);
TransactionValidator::checkPeriodOpen($journal);
// Post via package (creates ledger entries)
if (!method_exists($journal, 'post')) {
throw new Exception('IFRS package post() not available.');
}
$journal->post();
$journal->status = 'posted';
$journal->posted_by = $options['user_id'] ?? auth()->id();
$journal->posted_at = now();
$journal->save();
// Post-processing: snapshot invalidation + optional carry-forward handled by listeners or here
// Recompute balances (async)
$affected = collect($journal->lineItems)->pluck('account_id')->unique()->values()->all();
RecomputeAccountBalancesJob::dispatch($affected, $journal->entity_id);
AuditLog::create([
'entity_id' => $journal->entity_id,
'journal_id' => $journal->id,
'user_id' => $journal->posted_by,
'action' => 'posted',
'message' => 'Journal posted',
'new' => $journal->toArray(),
]);
// optionally trigger snapshot recompute via event or direct job (listeners recommended)
event(new \App\Domain\Accounting\Events\JournalPosted($journal));
DB::commit();
return $journal;
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Posting failed', ['journal_id' => $journal->id ?? null, 'err' => $e->getMessage()]);
throw $e;
}
}
}Notes & best-practices
- The
post()method calls packageJournalEntry::post()which should create ledger rows. If your package method differs, adapt accordingly. lockForUpdate()prevents two concurrent workers from posting the same journal twice.- Use
idempotency_keyat API layer to avoid repeated requests from clients.
I provide a JournalPolicy and a general TransactionPolicy. Tune roles to your system's RBAC.
<?php
namespace App\Domain\Accounting\Policies;
use App\Models\User;
use IFRS\Models\JournalEntry;
class JournalPolicy
{
public function view(User $user, JournalEntry $journal)
{
return $user->is_admin || $user->entity_id === $journal->entity_id;
}
public function create(User $user)
{
return $user->is_admin || $user->hasRole('accountant') || $user->hasRole('account-admin');
}
public function validate(User $user, JournalEntry $journal)
{
return ($user->hasRole('senior-accountant') || $user->is_admin) && $user->entity_id === $journal->entity_id;
}
public function approve(User $user, JournalEntry $journal)
{
return ($user->hasRole('manager') || $user->is_admin) && $user->entity_id === $journal->entity_id;
}
public function post(User $user, JournalEntry $journal)
{
// posting should be restricted to a narrow group
return ($user->hasRole('treasurer') || $user->hasRole('account-admin') || $user->is_admin)
&& $user->entity_id === $journal->entity_id;
}
public function delete(User $user, JournalEntry $journal)
{
if ($journal->isPosted()) return false;
return $user->is_admin || $user->id === $journal->created_by;
}
public function backdate(User $user)
{
// separate gate for back-dating
return ($user->hasRole('senior-accountant') || $user->is_admin);
}
}<?php
namespace App\Domain\Accounting\Policies;
use App\Models\User;
use IFRS\Models\Transaction;
class TransactionPolicy
{
public function view(User $user, Transaction $tx)
{
return $user->is_admin || $user->entity_id === $tx->entity_id;
}
public function create(User $user)
{
return $user->is_admin || $user->hasRole('accountant');
}
public function modify(User $user, Transaction $tx)
{
if ($tx->isPosted()) return false;
return $user->is_admin || $user->id === $tx->created_by || $user->hasRole('account-admin');
}
public function post(User $user, Transaction $tx)
{
return ($user->hasRole('treasurer') || $user->is_admin) && $user->entity_id === $tx->entity_id;
}
}Register policies in AuthServiceProvider.php as shown earlier.
Each transaction type class creates well-formed journals and exposes a createJournal() + optional post() wrapper.
Place under app/Domain/Accounting/Services/Transactions/.
<?php
namespace App\Domain\Accounting\Services\Transactions;
use IFRS\Models\JournalEntry;
use Illuminate\Support\Str;
/**
* SalesTransaction
*
* Build invoice-like journal entries (AR, Revenue, Tax, COGS/Inventory if present).
*/
class SalesTransaction
{
/**
* $payload keys:
* entity_id, date, currency_id, invoice_no, receivable_account_id, tax_account_id,
* lines: [ { sales_account_id, amount, description, cogs, inventory_account_id, cogs_account_id } ],
*/
public function createJournal(array $payload): JournalEntry
{
$je = JournalEntry::create([
'id' => $payload['id'] ?? (string) Str::uuid(),
'entity_id' => $payload['entity_id'],
'date' => $payload['date'],
'narration' => 'Invoice '.$payload['invoice_no'] ?? 'Sales',
'currency_id' => $payload['currency_id'] ?? null,
'status' => 'draft',
]);
$total = 0;
foreach ($payload['lines'] as $l) $total += $l['amount'];
$tax = $payload['tax_total'] ?? 0;
$grand = $total + $tax;
// AR debit
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['receivable_account_id'],
'debit' => $grand,
'credit' => 0,
'narration' => 'Accounts Receivable',
]);
// Revenue credits
foreach ($payload['lines'] as $l) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $l['sales_account_id'],
'debit' => 0,
'credit' => $l['amount'],
'narration' => $l['description'] ?? 'Sale',
]);
if (!empty($l['cogs']) && !empty($l['cogs_account_id']) && !empty($l['inventory_account_id'])) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $l['cogs_account_id'],
'debit' => $l['cogs'],
'credit' => 0,
'narration' => 'COGS: '.$l['description'] ?? '',
]);
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $l['inventory_account_id'],
'debit' => 0,
'credit' => $l['cogs'],
'narration' => 'Inventory decrease',
]);
}
}
if ($tax > 0) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['tax_account_id'],
'debit' => 0,
'credit' => $tax,
'narration' => 'Sales tax',
]);
}
return $je;
}
}<?php
namespace App\Domain\Accounting\Services\Transactions;
use IFRS\Models\JournalEntry;
use Illuminate\Support\Str;
class PurchaseTransaction
{
public function createJournal(array $payload): JournalEntry
{
$je = JournalEntry::create([
'id' => $payload['id'] ?? (string) Str::uuid(),
'entity_id' => $payload['entity_id'],
'date' => $payload['date'],
'narration' => 'Purchase '.$payload['ref_no'] ?? '',
'currency_id' => $payload['currency_id'] ?? null,
'status' => 'draft',
]);
$total = 0;
foreach ($payload['lines'] as $l) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $l['account_id'],
'debit' => $l['amount'],
'credit' => 0,
'narration' => $l['description'] ?? 'Purchase',
]);
$total += $l['amount'];
}
if (!empty($payload['tax_total'])) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['tax_account_id'],
'debit' => $payload['tax_total'],
'credit' => 0,
'narration' => 'Input tax',
]);
$total += $payload['tax_total'];
}
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['payable_account_id'],
'debit' => 0,
'credit' => $total,
'narration' => 'Accounts Payable',
]);
return $je;
}
}<?php
namespace App\Domain\Accounting\Services\Transactions;
use IFRS\Models\JournalEntry;
use Illuminate\Support\Str;
class PaymentTransaction
{
/**
* Create a payment journal.
* Payload:
* entity_id, date, currency_id,
* payment_account_id,
* allocations: [ {account_id, debit, credit, description} ], // caller decides sides
* payment_debit/payment_credit (for the payment account)
*/
public function createJournal(array $payload): JournalEntry
{
$je = JournalEntry::create([
'id' => $payload['id'] ?? (string) Str::uuid(),
'entity_id' => $payload['entity_id'],
'date' => $payload['date'],
'narration' => $payload['narration'] ?? 'Payment',
'currency_id' => $payload['currency_id'] ?? null,
'status' => 'draft',
]);
$totalAlloc = 0;
foreach ($payload['allocations'] as $a) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $a['account_id'],
'debit' => $a['debit'] ?? 0,
'credit' => $a['credit'] ?? 0,
'narration' => $a['description'] ?? null,
]);
$totalAlloc += ($a['debit'] ?? 0) + ($a['credit'] ?? 0);
}
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['payment_account_id'],
'debit' => $payload['payment_debit'] ?? 0,
'credit' => $payload['payment_credit'] ?? 0,
'narration' => $payload['narration'] ?? 'Cash/Bank',
]);
return $je;
}
}(Already provided earlier — same pattern.)
<?php
namespace App\Domain\Accounting\Services\Transactions;
use IFRS\Models\JournalEntry;
use Illuminate\Support\Str;
class CreditNoteTransaction
{
public function createJournal(array $payload): JournalEntry
{
$je = JournalEntry::create([
'id' => $payload['id'] ?? (string) Str::uuid(),
'entity_id' => $payload['entity_id'],
'date' => $payload['date'],
'narration' => 'Credit Note '.$payload['ref'] ?? '',
'currency_id' => $payload['currency_id'] ?? null,
'status' => 'draft',
]);
// Reduce revenue (debit revenue) and credit AR (credit)
foreach ($payload['lines'] as $l) {
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $l['sales_account_id'],
'debit' => $l['amount'],
'credit' => 0,
'narration' => $l['description'] ?? 'Credit note',
]);
}
$total = array_sum(array_map(fn($x)=>$x['amount'],$payload['lines']));
$je->lineItems()->create([
'id' => (string) Str::uuid(),
'account_id' => $payload['receivable_account_id'],
'debit' => 0,
'credit' => $total,
'narration' => 'Reduce AR (Credit Note)',
]);
return $je;
}
}Place under app/Domain/Accounting/Controllers/JournalController.php. Keep controllers thin: authorize, validate request, call services.
<?php
namespace App\Domain\Accounting\Controllers;
use App\Http\Controllers\Controller;
use App\Domain\Accounting\Services\JournalService;
use App\Domain\Accounting\Services\LedgerPostingService;
use IFRS\Models\JournalEntry;
use Illuminate\Http\Request;
class JournalController extends Controller
{
protected JournalService $journalService;
protected LedgerPostingService $postingService;
public function __construct(JournalService $journalService, LedgerPostingService $postingService)
{
$this->journalService = $journalService;
$this->postingService = $postingService;
$this->middleware('auth');
}
public function store(Request $request)
{
$this->authorize('create', JournalEntry::class);
$payload = $request->validate([
'entity_id' => 'required',
'date' => 'required|date',
'narration' => 'nullable|string',
'idempotency_key' => 'nullable|string',
'currency_id' => 'nullable',
]);
$journal = $this->journalService->createDraft($payload);
return response()->json($journal, 201);
}
public function addLine(JournalEntry $journal, Request $request)
{
$this->authorize('modify', $journal);
$line = $request->validate([
'account_id' => 'required',
'debit' => 'nullable|numeric|min:0',
'credit' => 'nullable|numeric|min:0',
'description' => 'nullable|string',
]);
$li = $this->journalService->addLine($journal, $line);
return response()->json($li, 201);
}
public function validateJournal(JournalEntry $journal)
{
$this->authorize('validate', $journal);
$this->journalService->validate($journal);
return response()->json(['status' => 'validated']);
}
public function post(JournalEntry $journal)
{
$this->authorize('post', $journal);
$this->postingService->post($journal, ['user_id' => auth()->id()]);
return response()->json(['status' => 'posted']);
}
}- Add policies to
AuthServiceProviderand runphp artisan route:cacheif in production. - Ensure
config/accounting.phpincludes keys:
return [
'create_carryforward_on_backpost' => env('ACCOUNTING_CARRYFORWARD_BACKPOST', false),
'rounding_account_id' => env('ACCOUNTING_ROUNDING_ACCOUNT_ID'),
];- Ensure queue workers run (for
RecomputeAccountBalancesJob, snapshot jobs). - Ensure events are wired in
EventServiceProviderto auto-handle recompute/carry-forward (we addedJournalValidated/JournalPostedearlier). - Add database migrations provided earlier (report_snapshots, journal meta, audit logs).
- Add
idempotency_keyhandling at API gateway layer for safe retries.
- Force
lockForUpdate()while posting to avoid race conditions when multiple workers/processes handle posting. - Use
idempotency_keyper client request to prevent duplicate creations on retries. - Keep posting flows synchronous only for small journals; for very large operations, validate synchronously and queue the actual posting.
- Keep
create_carryforward_on_backpostoff by default — make it configurable and require audit approval when enabled. - Keep heavy snapshot recomputes async — dispatch from listeners.
If you want, I can now:
- Generate migration files for any missing DB columns referenced in the code (if you want the full runnable set).
- Produce a small CLI to scan the DB and flag
affects_prior_reportsjournals (we previously made a command — I can expand it). - Wire EventServiceProvider and example listener classes if you want the exact file content.
Which of those would you like next?
====================
Understood — I will continue with all three major components you requested, in full production-grade detail:
- Full Accounting Subsystem Folder Structure
- Transaction Type Classes (Sales, Purchase, Payment, Receipt)
- API Controllers, Routes, and Supporting Services
No frontend, no tests — pure backend, engineered for serious ERP-grade accounting.
This layout is designed for:
- Separation of domain vs app logic
- Auditable immutable ledger
- IFRS compliance
- Multi-organization SaaS
- Background recomputation pipeline
- Extendable transaction types
app/
├── Domain/
│ ├── Accounting/
│ │ ├── Contracts/
│ │ │ └── TransactionType.php
│ │ ├── DTO/
│ │ │ ├── JournalLineDTO.php
│ │ │ └── TransactionDTO.php
│ │ ├── Enums/
│ │ │ ├── TransactionCategory.php
│ │ │ └── AccountType.php
│ │ ├── Services/
│ │ │ ├── LedgerPostingService.php
│ │ │ ├── JournalService.php
│ │ │ ├── SnapshotRecomputeService.php
│ │ │ ├── CarryForwardAdjustmentService.php
│ │ │ └── TransactionValidator.php
│ │ ├── Transactions/
│ │ │ ├── BaseTransaction.php
│ │ │ ├── SalesTransaction.php
│ │ │ ├── PurchaseTransaction.php
│ │ │ ├── PaymentTransaction.php
│ │ │ └── ReceiptTransaction.php
│ │ ├── Policies/
│ │ │ ├── JournalPolicy.php
│ │ │ └── TransactionPolicy.php
│ │ └── Helpers/
│ │ └── AccountingUtils.php
│
├── Jobs/
│ └── SnapshotRecomputeJob.php
│
├── Http/
│ ├── Controllers/
│ │ ├── Api/
│ │ │ ├── JournalController.php
│ │ │ ├── TransactionController.php
│ │ │ └── SnapshotController.php
│
├── Models/
│ ├── ReportSnapshot.php
│ └── AccountingLock.php
│
└── Providers/
└── EventServiceProvider.php
All transaction types extend a BaseTransaction class that wraps around eloquent-ifrs Transaction + Ledger posting rules.
<?php
namespace App\Domain\Accounting\Transactions;
use IFRS\Models\Transaction;
use IFRS\Models\Ledger;
use Illuminate\Support\Facades\DB;
use App\Domain\Accounting\Services\TransactionValidator;
abstract class BaseTransaction
{
abstract public function getCategory(): string;
abstract public function buildLines(array $data): array;
public function create(array $data): Transaction
{
// Validate IFRS structure
app(TransactionValidator::class)->validate($data, $this->getCategory());
return DB::transaction(function () use ($data) {
$txn = new Transaction([
'transaction_type' => $this->getCategory(),
'entity_id' => $data['entity_id'],
'transaction_date' => $data['date'],
'reference' => $data['reference'] ?? null,
'narration' => $data['narration'] ?? null,
]);
$txn->save();
$lines = $this->buildLines($data);
foreach ($lines as $line) {
Ledger::create([
'transaction_id' => $txn->id,
'account_id' => $line['account_id'],
'date' => $data['date'],
'debit' => $line['debit'],
'credit' => $line['credit'],
]);
}
return $txn;
});
}
}DR Accounts Receivable / Cash
CR Sales Revenue
CR VAT/Tax (optional)
<?php
namespace App\Domain\Accounting\Transactions;
class SalesTransaction extends BaseTransaction
{
public function getCategory(): string
{
return "sales";
}
public function buildLines(array $data): array
{
return [
// Debit customer
[
'account_id' => $data['debit_account'], // AR or Cash
'debit' => $data['amount'],
'credit' => 0,
],
// Credit revenue
[
'account_id' => $data['credit_account'], // Sales Revenue
'debit' => 0,
'credit' => $data['amount'],
],
];
}
}DR Expense / Inventory
CR Accounts Payable / Cash
<?php
namespace App\Domain\Accounting\Transactions;
class PurchaseTransaction extends BaseTransaction
{
public function getCategory(): string
{
return "purchase";
}
public function buildLines(array $data): array
{
return [
// Debit expense or inventory
[
'account_id' => $data['expense_account'],
'debit' => $data['amount'],
'credit' => 0,
],
// Credit supplier
[
'account_id' => $data['payable_account'],
'debit' => 0,
'credit' => $data['amount'],
],
];
}
}DR Accounts Payable
CR Cash/Bank
<?php
namespace App\Domain\Accounting\Transactions;
class PaymentTransaction extends BaseTransaction
{
public function getCategory(): string
{
return "payment";
}
public function buildLines(array $data): array
{
return [
[
'account_id' => $data['payable_account'],
'debit' => $data['amount'],
'credit' => 0,
],
[
'account_id' => $data['cash_account'],
'debit' => 0,
'credit' => $data['amount'],
],
];
}
}DR Cash/Bank
CR Accounts Receivable
<?php
namespace App\Domain\Accounting\Transactions;
class ReceiptTransaction extends BaseTransaction
{
public function getCategory(): string
{
return "receipt";
}
public function buildLines(array $data): array
{
return [
[
'account_id' => $data['cash_account'],
'debit' => $data['amount'],
'credit' => 0,
],
[
'account_id' => $data['receivable_account'],
'debit' => 0,
'credit' => $data['amount'],
],
];
}
}These controllers delegate all work to the Domain Services / Transaction Types.
<?php
namespace App\Http\Controllers\Api;
use App\Domain\Accounting\Services\JournalService;
use Illuminate\Http\Request;
class JournalController extends Controller
{
public function store(Request $request, JournalService $service)
{
$txn = $service->createJournal($request->all());
return response()->json($txn, 201);
}
public function post($id, JournalService $service)
{
$service->post($id);
return response()->json(['status' => 'posted']);
}
}<?php
namespace App\Http\Controllers\Api;
use App\Domain\Accounting\Transactions\SalesTransaction;
use App\Domain\Accounting\Transactions\PurchaseTransaction;
use App\Domain\Accounting\Transactions\PaymentTransaction;
use App\Domain\Accounting\Transactions\ReceiptTransaction;
use Illuminate\Http\Request;
class TransactionController extends Controller
{
public function storeSales(Request $req)
{
return (new SalesTransaction())->create($req->all());
}
public function storePurchase(Request $req)
{
return (new PurchaseTransaction())->create($req->all());
}
public function storePayment(Request $req)
{
return (new PaymentTransaction())->create($req->all());
}
public function storeReceipt(Request $req)
{
return (new ReceiptTransaction())->create($req->all());
}
}<?php
namespace App\Http\Controllers\Api;
use App\Models\ReportSnapshot;
use Illuminate\Http\Request;
class SnapshotController extends Controller
{
public function showImpacts(Request $request)
{
return ReportSnapshot::where('organization_id', $request->org)
->where('requires_recompute', true)
->get();
}
}Route::prefix('accounting')->group(function () {
Route::post('journals', [JournalController::class, 'store']);
Route::post('journals/{id}/post', [JournalController::class, 'post']);
Route::post('sales', [TransactionController::class, 'storeSales']);
Route::post('purchases', [TransactionController::class, 'storePurchase']);
Route::post('payments', [TransactionController::class, 'storePayment']);
Route::post('receipts', [TransactionController::class, 'storeReceipt']);
Route::get('snapshots/impacts', [SnapshotController::class, 'showImpacts']);
});If you want, I can continue with:
Just tell me which modules you want next.
Nice — below is a full, production-focused implementation for all six modules delivered together. I keep things pragmatic and modular so you can drop pieces in as needed. Each module contains:
- key database migrations,
- essential models,
- production-safe service classes (with concurrency/transaction handling),
- events and listeners where appropriate,
- API endpoints/controllers (thin),
- configuration options and operational notes,
- important invariants and tests you should add.
I assume you already have ekmungai/eloquent-ifrs installed and its models (Account, JournalEntry/Transaction, LineItem/Ledger, ReportingPeriod). All new code lives in a domain namespace under App\Domain\Accounting (you can adapt). I use uuid ids frequently and queue jobs for heavy work.
Because you asked for everything at once, I focused on the most important, battle-tested patterns and left room for your local naming conventions.
- Audit Trail System
- Period Closing & Locking System
- Automatic VAT / GST Posting
- Inventory Integration (FIFO & Weighted Average)
- Bank Reconciliation Module
- Document Attachments (bills, invoices, vouchers)
- Wiring, config, and operational guidance (summary)
- Transactions & locking: Use
DB::transaction()and->lockForUpdate()for concurrent modifications (posting, reconciliation, inventory moves). - Immutability: Never delete posted journal entries; use reversals. Audit trail records all changes.
- Queue heavy work: Snapshot recompute, inventory valuation recalculation, large reconciles — run in background workers.
- Permissions: Gate back-dating, period closing, adjustments to senior roles.
- Decimals: Money and quantity use
decimal(28,8)or similar; choose consistent scale across system. - Events: Emit
TransactionPosted,InventoryMoved,ReconciliationCompletedfor extensibility.
Complete, tamper-evident audit logs capturing who changed what, when and why, including before/after payloads.
database/migrations/2025_11_17_100000_create_accounting_audit_logs_table.php
Schema::create('accounting_audit_logs', function (Blueprint $t) {
$t->bigIncrements('id');
$t->uuid('entity_id')->index();
$t->uuid('journal_id')->nullable()->index();
$t->uuid('subject_id')->nullable()->index(); // e.g. account id, document id
$t->string('action'); // created|updated|deleted|posted|reversed|reconciled|attachment_added
$t->string('resource_type')->nullable(); // e.g. JournalEntry
$t->text('message')->nullable();
$t->jsonb('actor')->nullable(); // {id, name, roles}
$t->jsonb('before')->nullable();
$t->jsonb('after')->nullable();
$t->jsonb('meta')->nullable();
$t->timestamps();
});App\Domain\Accounting\Models\AuditLog.php
class AuditLog extends Model {
protected $table = 'accounting_audit_logs';
protected $casts = ['actor'=>'array','before'=>'array','after'=>'array','meta'=>'array'];
protected $fillable = ['entity_id','journal_id','subject_id','action','resource_type','message','actor','before','after','meta'];
}Use a helper to serialise auth()->user() info to keep logs useful.
function actorPayload() {
$u = auth()->user();
return $u ? ['id'=>$u->id,'email'=>$u->email,'roles'=>method_exists($u,'getRoleNames')?$u->getRoleNames():[]] : null;
}- Emit logs on create/update/delete/validate/post actions in
JournalServiceandLedgerPostingService(we already placed audit calls earlier). - Register an event listener for all domain events to create uniform audit entries.
App\Listeners\RecordDomainEventToAuditLog
- Accepts events like
JournalValidated,JournalPosted,SnapshotRecomputed,ReconciliationCompleted. - Writes the
AuditLogentry withbefore/aftersnapshots if available.
Prevent accidental or unauthorized back-dating by locking fiscal periods (month/quarter/year), with soft/unlock and auditability.
Extend reporting_periods with locking fields (if you didn't already):
Schema::table('reporting_periods', function (Blueprint $t) {
$t->enum('status',['open','soft_locked','hard_locked','closed'])->default('open');
$t->uuid('locked_by')->nullable();
$t->timestamp('locked_at')->nullable();
$t->text('lock_reason')->nullable();
});isOpen(),isSoftLocked(),isHardLocked(),canBackPost(User $user)
PeriodLockService with methods:
lockPeriod(ReportingPeriod $period, User $user, string $reason, $hard=false)unlockPeriod(...)closePeriod(...)(permanent; requires full audit & optional approval workflow)
public function lockPeriod(ReportingPeriod $period, $user, $reason, $hard=false) {
if ($hard && !$user->can('close-period')) throw new Exception('Not authorized.');
$period->status = $hard ? 'hard_locked' : 'soft_locked';
$period->locked_by = $user->id;
$period->locked_at = now();
$period->lock_reason = $reason;
$period->save();
AuditLog::create([...]);
}- If a period
isHardLocked()orstatus == 'closed', disallow posting (TransactionValidator::checkPeriodOpenwill throw). - If
soft_locked, allow back-dating only to users withbackdate-journalprivilege; still mark transactionsback_posted = trueand flag impacted snapshots.
- Implement
PeriodCloseRequestentity where accountants request closure, managers approve. - On final approval,
closePeriod()sets statusclosedand record audit.
Automatically create tax lines when posting sales/purchase transactions based on VAT rules, handle multiple tax rates, exemptions, and outputs for filings.
taxestable: tax code, rate, type (output/input), apply_on (line_total vs. item), account_id (tax payable/receivable)tax_rules: country/region-specific rules (optional)
Schema::create('taxes', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->string('code');
$t->string('name');
$t->decimal('rate', 8,6);
$t->enum('type',['output','input']); // output (sales), input (purchases)
$t->uuid('account_id')->nullable(); // COA mapping
$t->boolean('is_compound')->default(false);
});App\Domain\Accounting\Models\Tax with helper calculate(amount): decimal.
When building the journal lines:
- For each sales line, compute tax with
Tax::calculate(line_amount). - Add a tax line crediting the
tax.account_id(VAT collected). - Sum taxes into the invoice tax total, include in AR debit.
$lineTax = $tax->calculate($l['amount']);
if($lineTax > 0) {
$je->lineItems()->create([
'account_id' => $tax->account_id, // VAT payable account
'debit' => 0,
'credit' => $lineTax,
'narration' => "VAT {$tax->code} for {$l['description']}"
]);
}- Provide
TaxReportServicethat aggregatesjournal_linesfor tax accounts by tax code and period. - Store
taxable_base,tax_amount,invoice_id,customer_vat_idfor each posted invoice for traceability and filing.
- Exempt items:
tax.rate = 0ortax.applies = false. - Compound tax (VAT + local surtax): compute sequentially or apply business rules.
- Multi-rate lines: support multiple tax entries per line.
Track stock movements and produce accurate COGS, inventory valuations, and adjust inventory-ledger interactions.
inventory_items(SKU, description, uom)inventory_locations(warehouses)inventory_movements(id, item_id, location_id, qty_in, qty_out, unit_cost, reference_transaction_id, movement_type: purchase, sale, adjustment, transfer)inventory_val_layers(for FIFO layers; id, item_id, qty, unit_cost, created_at, source_txn_id)inventory_snapshotor cached summaries per item/location
Schema::create('inventory_items', function(Blueprint $t){
$t->uuid('id')->primary();
$t->string('sku')->unique();
$t->string('name');
$t->string('uom')->nullable();
$t->timestamps();
});
Schema::create('inventory_val_layers', function(Blueprint $t){
$t->id();
$t->uuid('item_id')->index();
$t->decimal('qty', 24,8);
$t->decimal('unit_cost', 28,8);
$t->uuid('source_txn_id')->nullable();
$t->timestamps();
});
Schema::create('inventory_movements', function(Blueprint $t){
$t->uuid('id')->primary();
$t->uuid('item_id')->index();
$t->uuid('location_id')->nullable()->index();
$t->decimal('qty', 24,8);
$t->decimal('unit_cost', 28,8)->nullable();
$t->enum('movement_type',['purchase','sale','transfer','adjustment'])->index();
$t->uuid('reference_id')->nullable();
$t->timestamps();
});InventoryItem,InventoryValLayer,InventoryMovement
InventoryService implements:
receivePurchase(itemId, qty, unitCost, reference)-> creates layers (FIFO) or update weighted averageconsumeSale(itemId, qty, reference)-> allocate layers via FIFO or compute WA costadjustInventory(itemId, qty, unitCost, reason)-> adjustments
public function allocateFifo($itemId, $qtyNeeded) {
$remaining = $qtyNeeded;
$layers = InventoryValLayer::where('item_id', $itemId)
->where('qty','>',0)
->orderBy('created_at')
->lockForUpdate()
->get();
foreach($layers as $layer) {
if ($remaining <= 0) break;
$take = min($layer->qty, $remaining);
// record consumption: create InventoryMovement with qty_out and cost = $layer->unit_cost
$this->recordConsumption($itemId, $take, $layer->unit_cost, $layer->source_txn_id);
$layer->qty -= $take;
$layer->save();
$remaining -= $take;
}
if ($remaining > 0) throw new \Exception("Insufficient stock");
}On purchase receipt:
new_total_cost = old_qty * old_avg + received_qty * received_cost
new_qty = old_qty + received_qty
new_avg = new_total_cost / new_qty
Update inventory_items cached avg_cost if you maintain it.
-
On purchase receipt, create inventory asset debit to
Inventory Assetaccount and creditAccounts Payable(or cash). -
On sale, record COGS journal lines and inventory decrease:
- Debit
COGS(cost pulled via FIFO/WA) - Credit
Inventory Asset(same amount)
- Debit
-
Use
InventoryMovementmetadata to point to the relatedjournal_id/line_idfor traceability.
-
Implement
StockTakeServicethat accepts counted quantities and creates adjustment journals for surplus/shortfall:- If counted > system: Debit Inventory Asset, Credit Inventory Adjustment Gain/Revenue (or other account)
- If counted < system: Debit Inventory Adjustment Loss (Expense), Credit Inventory Asset
- Keep
inventory_val_layersprecise and audited; do not delete layers, mark consumed byconsumed_atand link to movement. - When migrating to FIFO, build initial layers from opening stock.
Match bank statement lines to ledger transactions, allow clearing, and create reconciliation adjustments for fees/interest/rounding.
bank_accounts(maps to COA account_id, bank name, account number, external_id)bank_statements(id, bank_account_id, statement_date, imported_by)bank_statement_lines(id, bank_statement_id, date, description, amount, balance, txn_ref, matched = boolean, matched_to_journal_line_id nullable, match_confidence)bank_reconciliations(id, bank_account_id, period_start, period_end, reconciled_by, status)
Schema::create('bank_accounts', function(Blueprint $t){
$t->uuid('id')->primary();
$t->uuid('account_id')->index(); // COA mapping (bank asset)
$t->string('bank_name')->nullable();
$t->string('account_no')->nullable();
$t->timestamps();
});
Schema::create('bank_statements', function(Blueprint $t){
$t->uuid('id')->primary();
$t->uuid('bank_account_id')->index();
$t->date('statement_date');
$t->uuid('imported_by')->nullable();
$t->timestamps();
});
Schema::create('bank_statement_lines', function(Blueprint $t){
$t->uuid('id')->primary();
$t->uuid('bank_statement_id')->index();
$t->date('date');
$t->text('description')->nullable();
$t->decimal('amount', 28,8);
$t->decimal('balance', 28,8)->nullable();
$t->boolean('matched')->default(false);
$t->uuid('matched_to_line')->nullable(); // journal_line id
$t->timestamps();
});BankReconciliationService with methods:
importCsv($bankAccountId, UploadedFile $csv)— parse statements intobank_statement_linesautoMatch($bankStatementId, $threshold = 0.01)— match bank lines to ledger lines by amount/date heuristicsmanuallyMatch($bankLineId, $journalLineId)— link a bank line to a journal_line (mark both as matched)createAdjustment($bankStatementId, $lineId, $accountId, $narration)— create JE to account for bank fees or interest and link itfinalizeReconciliation($bankStatementId)— verify all lines reconciled or create adjustments; markbank_reconciliationsrecord.
- Exact amount match (amount equality) — highest confidence
- Amount match within tolerance and date within n days — medium confidence
- Description contains invoice number (regex) — good match
- Fallback: present potential matches for manual confirmation.
Use match_confidence (enum: high/medium/low) so UI can filter.
foreach ($bankLines as $b) {
if ($b->matched) continue;
// direct match
$candidate = DB::table('journal_lines')
->where('entity_id',$entityId)
->where('debit', $b->amount) // depends on sign convention
->whereBetween('date', [$b->date->subDays(3), $b->date->addDays(3)])
->first();
if ($candidate) {
$b->matched = true;
$b->matched_to_line = $candidate->id;
$b->match_confidence = 'high';
$b->save();
// mark journal line as matched via meta table or flags
}
}- For bank fees: create a JE: Debit
Bank Fees Expense, CreditBank Account - For interest income: Debit
Bank Account, CreditInterest Income
Mark the created journal as reconciliation_adjustment in meta & link to statement line.
Store and link source documents to journals/transactions for audit and review.
- Use Laravel Filesystem (S3 / local) with signed URLs.
- Store files in structure:
accounting/{entity_id}/{YYYY}/{MM}/{document_type}/{uuid}.{ext} - Keep metadata in DB to allow search and security.
Schema::create('accounting_attachments', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->uuid('entity_id')->index();
$t->uuid('journal_id')->nullable()->index();
$t->string('resource_type')->nullable();
$t->uuid('resource_id')->nullable();
$t->string('filename');
$t->string('path');
$t->string('mime');
$t->bigInteger('size');
$t->uuid('uploaded_by')->nullable();
$t->timestamps();
});App\Domain\Accounting\Models\Attachment with getUrl() generating temporary S3 signed URL if using S3, or route to protected download controller with authorization.
- Endpoint
POST /api/accounting/attachmentsacceptsmultipart/form-datawithjournal_idorresource_type/resource_id. - Validate file types (pdf, jpg, png) and size limits in config.
- Store via
Storage::disk('accounting')->putFileAs($dir, $file, $filename)whereaccountingdisk inconfig/filesystems.php. - Save DB metadata entry.
- Set
AuditLogentry for attachment add/remove.
- Only users with access to the journal/entity can upload/download attachments.
- Signed URLs expire (e.g., 15 minutes).
- Optionally encrypt at rest via server-side or S3 SSE.
public function upload(Request $r) {
$r->validate(['file'=>'required|file|mimes:pdf,jpg,png|max:10240','journal_id'=>'nullable|uuid']);
$file = $r->file('file');
$entityId = auth()->user()->entity_id;
$dir = "accounting/{$entityId}/".now()->format('Y/m');
$name = (string) Str::uuid().'.'.$file->getClientOriginalExtension();
$path = Storage::disk('accounting')->putFileAs($dir, $file, $name);
$attach = Attachment::create([...]);
AuditLog::create([...]);
return response()->json($attach,201);
}return [
'create_carryforward_on_backpost' => env('ACCOUNTING_CARRYFORWARD_BACKPOST', false),
'rounding_account_id' => env('ACCOUNTING_ROUNDING_ACCOUNT_ID', null),
'inventory_default_valuation' => env('INVENTORY_VALUATION','fifo'), // fifo|wa
'attachment_disk' => env('ACCOUNTING_DISK','s3'),
'max_attachment_size_kb' => env('ACCOUNTING_MAX_ATTACHMENT_KB', 10240),
'bank_match_tolerance' => env('BANK_MATCH_TOLERANCE', 0.01),
];PeriodLockPolicy— restrict locking/unlocking/closing to admins/managers.AttachmentPolicy— restrict download/delete to entity users or admins.ReconciliationPolicy— restrict finalization to treasurer/manager roles.
JournalPosted→ dispatchSnapshotRecomputeJobandHandleTransactionChange(carry-forward if needed)InventoryReceived/InventoryConsumed→ update balance caches and optionally post inventory journalsBankStatementImported→ runautoMatchjob
RecomputeAccountBalancesJob(we had earlier)SnapshotRecomputeJob(we had earlier)InventoryValuationJob— recompute item valuations if many adjustments happenBankAutoMatchJob— process each imported statement
- Add metrics and logs for jobs queue length, number of back-dated postings, reconciliation deltas, inventory negative warnings.
- Create admin dashboard widgets: Open periods, pending reconciliations, snapshots requiring recompute.
- Invariants: every posted journal balanced.
- Back-post flow: post journal into closed period with/without permission, assert correct flags/adjustments.
- FIFO allocator exhaustion & partial consumption.
- Reconciliation auto-match correctness on edge cases.
- Attachment security (signed url expires, access denied for unauthorized user).
- Carry-forward adjustments — convenient but can obfuscate audit trail. If enabled, log clearly and make them visible to auditors. Prefer adjustment periods + auditability.
- Inventory & COGS — choice of FIFO vs WA changes COGS and tax. Document policy and allow accounting admin to set company-wide valuation method.
- Tax compliance — local VAT/GST laws vary widely; treat tax rules as configurable objects or plugin modules per country.
- Reconciliation automation — tune heuristics carefully and expose manual review pipelines.
- Backups and migration — implement robust DB backups before enabling mass recompute or adjustment generation.
- Performance — ledger and inventory tables can get huge — partitioning and proper indexes required in production.
- Auditors — maintain exportable audit trails and snapshots for regulatory inspections.
If you want I can now:
- Generate the exact file contents for any one of these modules (e.g., full
InventoryServicefile with FIFO implementation, orBankReconciliationServicewith auto-match algorithm), ready-to-drop into your repo. - Produce the policies and migrations in full for each module you specifically want right away.
- Create CLI tools to manage period lock/unlock and to bulk-create carry-forward adjustments with review mode.
Which specific module file(s) should I produce first (e.g., InventoryService with FIFO, BankReconciliationService, or full migrations + controllers for attachments)?
=======================
Nice — below are the three production-grade pieces you asked for, ready to paste into your Laravel app and adapt to your conventions.
I include:
- Inventory: migrations, models, and a robust
InventoryServicewith FIFO allocation and weighted-average helper. - Bank reconciliation: migrations, models,
BankReconciliationServicewith an auto-match algorithm and controller endpoints. - Attachments: migration,
Attachmentmodel, controller for secure upload/download, a brief policy, and config hints.
I assume you’re using Laravel 10+, uuid support, queues enabled, and ekmungai/eloquent-ifrs already installed. I used the domain namespace App\Domain\Accounting for services/models and App\Http\Controllers\Api for controllers. Adjust namespaces/paths to fit your repo.
Create three migrations:
2025_11_17_200001_create_inventory_items_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up() {
Schema::create('inventory_items', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->string('sku')->unique();
$t->string('name');
$t->string('uom')->nullable();
$t->decimal('avg_cost', 28, 8)->default(0);
$t->timestamps();
});
}
public function down() { Schema::dropIfExists('inventory_items'); }
};2025_11_17_200002_create_inventory_val_layers_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up() {
Schema::create('inventory_val_layers', function (Blueprint $t) {
$t->id();
$t->uuid('item_id')->index();
$t->decimal('qty', 24, 8);
$t->decimal('unit_cost', 28, 8);
$t->uuid('source_txn_id')->nullable(); // purchase/invoice id
$t->timestamp('consumed_at')->nullable();
$t->timestamps();
$t->index(['item_id']);
});
}
public function down() { Schema::dropIfExists('inventory_val_layers'); }
};2025_11_17_200003_create_inventory_movements_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up() {
Schema::create('inventory_movements', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->uuid('item_id')->index();
$t->uuid('location_id')->nullable()->index();
$t->decimal('qty', 24, 8);
$t->decimal('unit_cost', 28, 8)->nullable();
$t->enum('movement_type',['purchase','sale','transfer','adjustment'])->index();
$t->uuid('reference_id')->nullable(); // e.g. journal id
$t->jsonb('meta')->nullable();
$t->timestamps();
});
}
public function down() { Schema::dropIfExists('inventory_movements'); }
};app/Domain/Accounting/Models/InventoryItem.php
<?php
namespace App\Domain\Accounting\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class InventoryItem extends Model
{
protected $table = 'inventory_items';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['id','sku','name','uom','avg_cost'];
public function layers(): HasMany
{
return $this->hasMany(InventoryValLayer::class,'item_id','id')->orderBy('created_at');
}
}app/Domain/Accounting/Models/InventoryValLayer.php
<?php
namespace App\Domain\Accounting\Models;
use Illuminate\Database\Eloquent\Model;
class InventoryValLayer extends Model
{
protected $table = 'inventory_val_layers';
protected $fillable = ['item_id','qty','unit_cost','source_txn_id','consumed_at'];
public $timestamps = true;
}app/Domain/Accounting/Models/InventoryMovement.php
<?php
namespace App\Domain\Accounting\Models;
use Illuminate\Database\Eloquent\Model;
class InventoryMovement extends Model
{
protected $table = 'inventory_movements';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['id','item_id','location_id','qty','unit_cost','movement_type','reference_id','meta'];
protected $casts = ['meta'=>'array'];
}app/Domain/Accounting/Services/InventoryService.php
<?php
namespace App\Domain\Accounting\Services;
use App\Domain\Accounting\Models\InventoryItem;
use App\Domain\Accounting\Models\InventoryValLayer;
use App\Domain\Accounting\Models\InventoryMovement;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Exception;
/**
* InventoryService
*
* - receivePurchase -> creates layers (FIFO) or updates WA
* - consumeSale -> allocates FIFO layers and returns total cost
* - adjustInventory -> creates adjustment movement and layers if needed
*/
class InventoryService
{
protected string $valuation;
public function __construct()
{
$this->valuation = config('accounting.inventory_default_valuation','fifo'); // 'fifo' or 'wa'
}
/**
* Receive purchase into inventory: creates a valuation layer.
*
* @param string $itemId
* @param float $qty
* @param float $unitCost
* @param array $meta
* @return InventoryValLayer
*/
public function receivePurchase(string $itemId, float $qty, float $unitCost, array $meta = [])
{
if ($qty <= 0) throw new Exception('qty must be positive');
return DB::transaction(function() use ($itemId,$qty,$unitCost,$meta) {
$layer = InventoryValLayer::create([
'item_id' => $itemId,
'qty' => $qty,
'unit_cost' => $unitCost,
'source_txn_id' => $meta['source_txn_id'] ?? null,
]);
// update avg if WA
if ($this->valuation === 'wa') {
$this->recomputeWeightedAverage($itemId);
}
// record movement
InventoryMovement::create([
'id' => (string) Str::uuid(),
'item_id' => $itemId,
'qty' => $qty,
'unit_cost' => $unitCost,
'movement_type' => 'purchase',
'reference_id' => $meta['reference_id'] ?? null,
'meta' => $meta,
]);
return $layer;
});
}
/**
* Consume stock for a sale using FIFO (or WA).
* Returns array: ['total_cost' => float, 'allocations'=> [ {layer_id, qty, unit_cost, cost} ... ]]
*/
public function consumeSale(string $itemId, float $qtyNeeded, array $meta = [])
{
if ($qtyNeeded <= 0) throw new Exception('qty must be positive');
return DB::transaction(function() use ($itemId,$qtyNeeded,$meta) {
if ($this->valuation === 'wa') {
// Weighted average: total cost = qty * avg_cost
$item = InventoryItem::findOrFail($itemId);
$unit = (float)$item->avg_cost;
$totalCost = round($unit * $qtyNeeded, 8);
$movement = InventoryMovement::create([
'id' => (string) Str::uuid(),
'item_id' => $itemId,
'qty' => -1 * $qtyNeeded,
'unit_cost' => $unit,
'movement_type' => 'sale',
'reference_id' => $meta['reference_id'] ?? null,
'meta' => $meta,
]);
return [
'total_cost' => $totalCost,
'allocations' => [
['layer_id' => null, 'qty' => $qtyNeeded, 'unit_cost' => $unit, 'cost' => $totalCost]
],
'movement_id' => $movement->id,
];
}
// FIFO allocation
$remaining = $qtyNeeded;
$allocations = [];
$totalCost = 0.0;
// lock rows for update to avoid races
$layers = InventoryValLayer::where('item_id', $itemId)
->where('qty', '>', 0)
->orderBy('created_at')
->lockForUpdate()
->get();
foreach ($layers as $layer) {
if ($remaining <= 0) break;
$available = (float)$layer->qty;
if ($available <= 0) continue;
$take = min($available, $remaining);
$cost = round($take * (float)$layer->unit_cost, 8);
// decrement layer qty
$layer->qty = round($layer->qty - $take, 8);
if ($layer->qty <= 0) $layer->consumed_at = now();
$layer->save();
$allocations[] = [
'layer_id' => $layer->id,
'qty' => $take,
'unit_cost' => (float)$layer->unit_cost,
'cost' => $cost,
];
$totalCost += $cost;
$remaining -= $take;
}
if ($remaining > 0) {
throw new Exception("Insufficient stock for item {$itemId}. Needed {$qtyNeeded}, missing {$remaining}");
}
$movement = InventoryMovement::create([
'id' => (string) Str::uuid(),
'item_id' => $itemId,
'qty' => -1 * $qtyNeeded,
'unit_cost' => $totalCost / $qtyNeeded,
'movement_type' => 'sale',
'reference_id' => $meta['reference_id'] ?? null,
'meta' => $meta,
]);
return [
'total_cost' => round($totalCost, 8),
'allocations' => $allocations,
'movement_id' => $movement->id,
];
});
}
/**
* Adjust inventory manually (stocktake).
* Creates positive or negative movement and, if positive, a new valuation layer.
*/
public function adjustInventory(string $itemId, float $qtyDelta, ?float $unitCost = null, array $meta = [])
{
return DB::transaction(function() use ($itemId,$qtyDelta,$unitCost,$meta) {
if (abs($qtyDelta) <= 0) throw new Exception('qtyDelta must be non-zero');
if ($qtyDelta > 0) {
// create a new layer for positive adjustment
$layer = InventoryValLayer::create([
'item_id' => $itemId,
'qty' => $qtyDelta,
'unit_cost' => $unitCost ?? 0,
'source_txn_id' => $meta['reference_id'] ?? null,
]);
} else {
// negative adjustment: consume layers via FIFO
$this->consumeSale($itemId, abs($qtyDelta), ['reference_id'=>$meta['reference_id'] ?? null,'adjustment'=>true]);
}
InventoryMovement::create([
'id' => (string) Str::uuid(),
'item_id' => $itemId,
'qty' => $qtyDelta,
'unit_cost' => $unitCost,
'movement_type' => 'adjustment',
'reference_id' => $meta['reference_id'] ?? null,
'meta' => $meta,
]);
if ($this->valuation === 'wa') $this->recomputeWeightedAverage($itemId);
return true;
});
}
/**
* Recompute weighted average cost for an item from layers
*/
protected function recomputeWeightedAverage(string $itemId)
{
$layers = InventoryValLayer::where('item_id',$itemId)->get();
$totalQty = 0.0; $totalCost = 0.0;
foreach ($layers as $l) {
$q = (float)$l->qty;
$totalQty += $q;
$totalCost += $q * (float)$l->unit_cost;
}
$avg = $totalQty > 0 ? round($totalCost / $totalQty, 8) : 0;
$item = InventoryItem::findOrFail($itemId);
$item->avg_cost = $avg;
$item->saveQuietly();
return $avg;
}
}receivePurchase()returns a layer to be linked to the purchase journal line (for traceability).consumeSale()returns allocations used to create COGS journal lines; use returnedtotal_costto debit COGS and credit Inventory Asset account.- All ops run in DB transactions and use
lockForUpdate()on layers to avoid race conditions. - For large warehouses, consider partitioning
inventory_val_layersor archiving consumed layers.
2025_11_17_210001_create_bank_accounts_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('bank_accounts', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->uuid('account_id')->index(); // COA account_id for bank asset
$t->string('bank_name')->nullable();
$t->string('account_no')->nullable();
$t->string('external_id')->nullable();
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('bank_accounts'); }
};2025_11_17_210002_create_bank_statements_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('bank_statements', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->uuid('bank_account_id')->index();
$t->date('statement_date');
$t->uuid('imported_by')->nullable();
$t->jsonb('meta')->nullable();
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('bank_statements'); }
};2025_11_17_210003_create_bank_statement_lines_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('bank_statement_lines', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->uuid('bank_statement_id')->index();
$t->date('date');
$t->text('description')->nullable();
$t->decimal('amount', 28, 8);
$t->decimal('balance', 28, 8)->nullable();
$t->boolean('matched')->default(false);
$t->uuid('matched_to_line')->nullable(); // journal_line id
$t->string('match_confidence')->nullable(); // high|medium|low
$t->jsonb('meta')->nullable();
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('bank_statement_lines'); }
};2025_11_17_210004_create_bank_reconciliations_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('bank_reconciliations', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->uuid('bank_account_id')->index();
$t->date('period_start');
$t->date('period_end');
$t->uuid('reconciled_by')->nullable();
$t->enum('status',['open','in_progress','finalized'])->default('open');
$t->jsonb('meta')->nullable();
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('bank_reconciliations'); }
};app/Domain/Accounting/Models/BankAccount.php
<?php
namespace App\Domain\Accounting\Models;
use Illuminate\Database\Eloquent\Model;
class BankAccount extends Model
{
protected $table = 'bank_accounts';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['id','account_id','bank_name','account_no','external_id'];
}app/Domain/Accounting/Models/BankStatement.php
<?php
namespace App\Domain\Accounting\Models;
use Illuminate\Database\Eloquent\Model;
class BankStatement extends Model
{
protected $table = 'bank_statements';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['id','bank_account_id','statement_date','imported_by','meta'];
protected $casts = ['meta'=>'array'];
}app/Domain/Accounting/Models/BankStatementLine.php
<?php
namespace App\Domain\Accounting\Models;
use Illuminate\Database\Eloquent\Model;
class BankStatementLine extends Model
{
protected $table = 'bank_statement_lines';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['id','bank_statement_id','date','description','amount','balance','matched','matched_to_line','match_confidence','meta'];
protected $casts = ['meta'=>'array'];
}app/Domain/Accounting/Models/BankReconciliation.php
<?php
namespace App\Domain\Accounting\Models;
use Illuminate\Database\Eloquent\Model;
class BankReconciliation extends Model
{
protected $table = 'bank_reconciliations';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['id','bank_account_id','period_start','period_end','reconciled_by','status','meta'];
protected $casts = ['meta'=>'array'];
}app/Domain/Accounting/Services/BankReconciliationService.php
<?php
namespace App\Domain\Accounting\Services;
use App\Domain\Accounting\Models\BankAccount;
use App\Domain\Accounting\Models\BankStatement;
use App\Domain\Accounting\Models\BankStatementLine;
use App\Domain\Accounting\Models\BankReconciliation;
use IFRS\Models\Ledger; // journal/ledger lines (package)
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Exception;
/**
* BankReconciliationService
*
* - importCsv -> parses CSV and creates bank_statement + lines
* - autoMatch -> tries to match bank lines to ledger lines
* - manuallyMatch -> confirm a link
* - createAdjustment -> create JE for fees/interest
* - finalizeReconciliation -> mark finalized and create summary
*/
class BankReconciliationService
{
/**
* Import CSV (simple parser) into bank_statement and lines.
* $rows expected: [ ['date'=>'YYYY-mm-dd','description'=>'..','amount'=>..,'balance'=>..], ... ]
*/
public function importArray(string $bankAccountId, array $rows, ?int $importedBy = null, array $meta = [])
{
return DB::transaction(function() use ($bankAccountId,$rows,$importedBy,$meta) {
$stmt = BankStatement::create([
'id' => (string) Str::uuid(),
'bank_account_id' => $bankAccountId,
'statement_date' => now()->toDateString(),
'imported_by' => $importedBy,
'meta' => $meta,
]);
foreach ($rows as $r) {
BankStatementLine::create([
'id' => (string) Str::uuid(),
'bank_statement_id' => $stmt->id,
'date' => $r['date'],
'description' => $r['description'] ?? null,
'amount' => $r['amount'],
'balance' => $r['balance'] ?? null,
'matched' => false,
'meta' => $r['meta'] ?? null,
]);
}
return $stmt;
});
}
/**
* Auto-match algorithm (heuristic)
* - Attempt exact amount match within tolerance and date window
* - Then fuzzy matches (description invoice number)
*/
public function autoMatch(string $bankStatementId, float $tolerance = null)
{
$tolerance = $tolerance ?? config('accounting.bank_match_tolerance', 0.01);
$stmt = BankStatement::findOrFail($bankStatementId);
$lines = $stmt->lines()->where('matched',false)->get();
$matches = [];
foreach ($lines as $bLine) {
// 1) Exact amount match on ledger lines
$candidate = Ledger::query()
->where('date',$bLine->date)
->whereRaw('ABS(debit - ?) < ?', [$bLine->amount, $tolerance]) // insecure for credit/debit shapes, adjust below
->first();
// Instead use separate queries for debit/credit
if (!$candidate) {
$candidate = Ledger::query()
->where('date', $bLine->date)
->where(function($q) use ($bLine,$tolerance){
$q->whereRaw('ABS(debit - ?) <= ?', [$bLine->amount, $tolerance])
->orWhereRaw('ABS(credit - ?) <= ?', [$bLine->amount, $tolerance]);
})->first();
}
if ($candidate) {
$bLine->matched = true;
$bLine->matched_to_line = $candidate->id;
$bLine->match_confidence = 'high';
$bLine->save();
$matches[] = ['bank_line_id'=>$bLine->id,'ledger_line_id'=>$candidate->id,'confidence'=>'high'];
continue;
}
// 2) Amount-match ignoring date (within days window)
$window = 3;
$candidate = Ledger::query()
->whereBetween('date',[now()->parse($bLine->date)->subDays($window), now()->parse($bLine->date)->addDays($window)])
->where(function($q) use ($bLine,$tolerance){
$q->whereRaw('ABS(debit - ?) <= ?', [$bLine->amount, $tolerance])
->orWhereRaw('ABS(credit - ?) <= ?', [$bLine->amount, $tolerance]);
})->first();
if ($candidate) {
$bLine->matched = true;
$bLine->matched_to_line = $candidate->id;
$bLine->match_confidence = 'medium';
$bLine->save();
$matches[] = ['bank_line_id'=>$bLine->id,'ledger_line_id'=>$candidate->id,'confidence'=>'medium'];
continue;
}
// 3) Try extract invoice number from description and search ledger reference
if ($bLine->description) {
if (preg_match('/\bINV[-\s]*([0-9A-Z\-]+)\b/i', $bLine->description, $m)) {
$ref = $m[1];
$candidate = Ledger::query()
->where('narration','like',"%{$ref}%")
->orWhere('reference','like',"%{$ref}%")
->first();
if ($candidate) {
$bLine->matched = true;
$bLine->matched_to_line = $candidate->id;
$bLine->match_confidence = 'medium';
$bLine->save();
$matches[] = ['bank_line_id'=>$bLine->id,'ledger_line_id'=>$candidate->id,'confidence'=>'medium'];
continue;
}
}
}
// no match found -> remain unmatched
}
return $matches;
}
/**
* Manually confirm match between bank statement line and ledger line id.
*/
public function manuallyMatch(string $bankLineId, string $ledgerLineId, string $confidence = 'high')
{
return DB::transaction(function() use ($bankLineId,$ledgerLineId,$confidence){
$b = BankStatementLine::findOrFail($bankLineId);
$b->matched = true;
$b->matched_to_line = $ledgerLineId;
$b->match_confidence = $confidence;
$b->save();
return $b;
});
}
/**
* Create adjustment journal (bank fees, interest). Returns JournalEntry or created ledger id
*/
public function createAdjustment(string $bankStatementId, string $bankLineId, array $payload)
{
// payload: ['account_id' => expense/income account, 'amount' => decimal, 'narration' => string]
$bLine = BankStatementLine::findOrFail($bankLineId);
$bankStatement = BankStatement::findOrFail($bankStatementId);
$bankAccount = BankAccount::findOrFail($bankStatement->bank_account_id);
// prepare journal: debit expense, credit bank (or reverse for interest income)
// Use JournalService to create and post the JE
$journalService = app(\App\Domain\Accounting\Services\JournalService::class);
$amount = (float)$payload['amount'];
$expenseAccount = $payload['account_id'];
$je = $journalService->createDraft([
'entity_id' => $bankAccount->account_id ? $bankAccount->account_id : null,
'date' => $bLine->date,
'narration' => $payload['narration'] ?? 'Bank reconciliation adjustment',
]);
// expense debit
$journalService->addLine($je, ['account_id' => $expenseAccount, 'debit' => $amount, 'description' => $payload['narration'] ?? null]);
// bank credit: need COA account id for this bank
$journalService->addLine($je, ['account_id' => $bankAccount->account_id, 'credit' => $amount, 'description' => 'Bank adjustment']);
// validate and post
$journalService->validate($je);
$posting = app(\App\Domain\Accounting\Services\LedgerPostingService::class);
$posting->post($je, ['user_id' => auth()->id() ?? null]);
// link bank line
$bLine->matched = true;
$bLine->matched_to_line = $je->lineItems()->latest()->first()->id ?? null;
$bLine->match_confidence = 'high';
$bLine->save();
return $je;
}
/**
* Finalize reconciliation for a statement: ensure user reviewed or create missing adjustments
*/
public function finalizeReconciliation(string $bankStatementId, array $options = [])
{
return DB::transaction(function() use ($bankStatementId,$options) {
$stmt = BankStatement::findOrFail($bankStatementId);
$unmatched = $stmt->lines()->where('matched', false)->get();
if ($unmatched->isNotEmpty() && empty($options['allow_unmatched'])) {
throw new Exception("There are unmatched bank lines. Provide allow_unmatched to force finalize.");
}
// create reconciliation record
$recon = BankReconciliation::create([
'id' => (string) Str::uuid(),
'bank_account_id' => $stmt->bank_account_id,
'period_start' => $options['period_start'] ?? $stmt->lines()->orderBy('date')->first()->date,
'period_end' => $options['period_end'] ?? $stmt->lines()->orderBy('date','desc')->first()->date,
'reconciled_by' => auth()->id() ?? null,
'status' => 'finalized',
'meta' => ['bank_statement_id' => $stmt->id],
]);
return $recon;
});
}
}Note:
Ledgerqueries depend on your IFRS package schema. You may want to queryjournal_linestable or a ledger view. Adjust column names accordingly.
app/Http/Controllers/Api/BankReconciliationController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Domain\Accounting\Services\BankReconciliationService;
class BankReconciliationController extends Controller
{
protected BankReconciliationService $svc;
public function __construct(BankReconciliationService $svc) { $this->svc = $svc; $this->middleware('auth'); }
public function import(Request $r)
{
$r->validate(['bank_account_id'=>'required|uuid','rows'=>'required|array']);
$stmt = $this->svc->importArray($r->bank_account_id, $r->rows, auth()->id(), $r->meta ?? []);
return response()->json($stmt,201);
}
public function autoMatch(Request $r, $statementId)
{
$matches = $this->svc->autoMatch($statementId, $r->get('tolerance'));
return response()->json(['matches'=>$matches]);
}
public function manualMatch(Request $r, $bankLineId)
{
$r->validate(['ledger_line_id'=>'required|string']);
$line = $this->svc->manuallyMatch($bankLineId, $r->ledger_line_id, $r->confidence ?? 'high');
return response()->json($line);
}
public function createAdjustment(Request $r, $bankStatementId, $bankLineId)
{
$r->validate(['account_id'=>'required','amount'=>'required|numeric','narration'=>'nullable|string']);
$je = $this->svc->createAdjustment($bankStatementId, $bankLineId, $r->only(['account_id','amount','narration']));
return response()->json($je,201);
}
public function finalize(Request $r, $bankStatementId)
{
$r->validate(['allow_unmatched'=>'nullable|boolean']);
$recon = $this->svc->finalizeReconciliation($bankStatementId, $r->only(['allow_unmatched','period_start','period_end']));
return response()->json($recon);
}
}Route::middleware('auth:sanctum')->prefix('accounting')->group(function(){
Route::post('bank-statements/import', [BankReconciliationController::class,'import']);
Route::post('bank-statements/{id}/auto-match', [BankReconciliationController::class,'autoMatch']);
Route::post('bank-lines/{lineId}/match', [BankReconciliationController::class,'manualMatch']);
Route::post('bank-statements/{statementId}/lines/{lineId}/adjust', [BankReconciliationController::class,'createAdjustment']);
Route::post('bank-statements/{statementId}/finalize', [BankReconciliationController::class,'finalize']);
});2025_11_17_220001_create_accounting_attachments_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('accounting_attachments', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->uuid('entity_id')->index();
$t->uuid('journal_id')->nullable()->index();
$t->string('resource_type')->nullable();
$t->uuid('resource_id')->nullable();
$t->string('filename');
$t->string('path');
$t->string('mime');
$t->bigInteger('size');
$t->uuid('uploaded_by')->nullable();
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('accounting_attachments'); }
};app/Domain/Accounting/Models/Attachment.php
<?php
namespace App\Domain\Accounting\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class Attachment extends Model
{
protected $table = 'accounting_attachments';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['id','entity_id','journal_id','resource_type','resource_id','filename','path','mime','size','uploaded_by'];
public function url($ttl = 900)
{
$disk = config('accounting.attachment_disk','s3');
if ($disk === 's3') {
return Storage::disk($disk)->temporaryUrl($this->path, now()->addSeconds($ttl));
}
// for local disk, use a signed route to a controller that authorizes
return route('accounting.attachments.download', ['id'=>$this->id,'token'=>sha1($this->id.config('app.key'))]);
}
}app/Http/Controllers/Api/AttachmentController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
use App\Domain\Accounting\Models\Attachment;
use App\Domain\Accounting\Models\AuditLog;
class AttachmentController extends Controller
{
public function __construct() { $this->middleware('auth'); }
/**
* Upload
*/
public function upload(Request $r)
{
$r->validate([
'file' => 'required|file|max:'.(config('accounting.max_attachment_size_kb',10240)),
'journal_id' => 'nullable|uuid',
'resource_type' => 'nullable|string',
'resource_id' => 'nullable|string',
]);
$file = $r->file('file');
$entityId = auth()->user()->entity_id;
$disk = config('accounting.attachment_disk','s3');
$dir = "accounting/{$entityId}/".now()->format('Y/m');
$name = (string) Str::uuid().'.'.$file->getClientOriginalExtension();
$path = Storage::disk($disk)->putFileAs($dir, $file, $name);
$attach = Attachment::create([
'id' => (string) Str::uuid(),
'entity_id' => $entityId,
'journal_id' => $r->journal_id ?? null,
'resource_type' => $r->resource_type,
'resource_id' => $r->resource_id,
'filename' => $file->getClientOriginalName(),
'path' => $path,
'mime' => $file->getClientMimeType(),
'size' => $file->getSize(),
'uploaded_by' => auth()->id(),
]);
// audit
AuditLog::create([
'entity_id' => $entityId,
'subject_id' => $attach->id,
'action' => 'attachment_added',
'resource_type' => 'Attachment',
'actor' => actorPayload(),
'message' => 'Attachment uploaded',
'after' => $attach->toArray(),
]);
return response()->json($attach, 201);
}
/**
* Download (protected)
*/
public function download(Request $r, $id)
{
$attach = Attachment::findOrFail($id);
$this->authorize('download', $attach); // uses AttachmentPolicy
$disk = config('accounting.attachment_disk','s3');
if ($disk === 's3') {
return response()->json(['url' => $attach->url(900)]);
}
// local disk: stream file
$path = $attach->path;
if (!Storage::disk($disk)->exists($path)) abort(404);
return Storage::disk($disk)->download($path, $attach->filename);
}
/**
* Delete attachment (soft security measure: keep DB record for audit but delete file)
*/
public function destroy(Request $r, $id)
{
$attach = Attachment::findOrFail($id);
$this->authorize('delete', $attach);
$disk = config('accounting.attachment_disk','s3');
if (Storage::disk($disk)->exists($attach->path)) {
Storage::disk($disk)->delete($attach->path);
}
$before = $attach->toArray();
$attach->delete();
AuditLog::create([
'entity_id' => $before['entity_id'] ?? null,
'subject_id' => $id,
'action' => 'attachment_deleted',
'resource_type' => 'Attachment',
'actor' => actorPayload(),
'before' => $before,
'message' => 'Attachment deleted',
]);
return response()->json(['deleted' => true]);
}
}app/Domain/Accounting/Policies/AttachmentPolicy.php
<?php
namespace App\Domain\Accounting\Policies;
use App\Models\User;
use App\Domain\Accounting\Models\Attachment;
class AttachmentPolicy
{
public function download(User $user, Attachment $attachment)
{
return $user->is_admin || $user->entity_id === $attachment->entity_id;
}
public function delete(User $user, Attachment $attachment)
{
return $user->is_admin || $user->id === $attachment->uploaded_by;
}
public function upload(User $user)
{
return $user->is_admin || $user->entity_id !== null;
}
}Route::middleware('auth:sanctum')->prefix('accounting')->group(function(){
Route::post('attachments', [\App\Http\Controllers\Api\AttachmentController::class,'upload']);
Route::get('attachments/{id}', [\App\Http\Controllers\Api\AttachmentController::class,'download'])->name('accounting.attachments.download');
Route::delete('attachments/{id}', [\App\Http\Controllers\Api\AttachmentController::class,'destroy']);
});- Add
ACCOUNTING_DISKto.env(e.g.,s3orlocal), setconfig('accounting.attachment_disk')accordingly. - If using local disk for downloads, do not expose
storage/app/accountingpublicly — use controller with auth check. - Use server-side encryption if attachments contain sensitive PII.
- Limit file types & size in validation and log uploads in
AuditLog.
- Run migrations:
php artisan migrate. - Register new models/services in your service container if you use DI.
- Add policies to
AuthServiceProvider:
protected $policies = [
\App\Domain\Accounting\Models\Attachment::class => \App\Domain\Accounting\Policies\AttachmentPolicy::class,
];- Ensure
actorPayload()helper exists (or replace with inline actor creation). - Wire
InventoryServiceusage into purchase/sales flows: on receipt create layer, on invoice posting callconsumeSale()for COGS. - Wire
BankReconciliationServicein a scheduled job or web UI for CSV imports. - Add indices on
inventory_val_layers(item_id),bank_statement_lines(bank_statement_id,date,amount). - Add tests covering concurrency: multiple
consumeSale()calls simultaneously; auto-match edge cases; attachment upload/download permissions.
Below is a clear, practical architectural advisory on how to design your accounting engine knowing that HR/payroll will be added later. The goal is to help you avoid painful refactors, duplicated logic, circular dependencies, and data-model traps.
This is based on building mid-sized ERPs in the past — accounting + HR always collide unless designed very deliberately.
HR touches accounting in four critical ways:
- Payroll → Journals (Salary Expense, Liabilities, Cash)
- Employee Advances → AR/Loans Ledger
- Leave encashment + Gratuity + Bonus → Accruals
- Reimbursements → Expense Workflow
To avoid refactors, your accounting design must anticipate these.
Below I give: ✔ Folder structure changes ✔ Additional database tables ✔ Journal rules for HR postings ✔ Payroll cost distribution ✔ Avoiding “salary payment double posting” ✔ A clean boundary between HR domain and Accounting domain ✔ The safest future-proof approach
Even if you don’t yet need them, cost centers make HR painless later.
Why: HR payroll almost always needs:
- Cost by department
- Cost by project
- Cost by employee group
- Cross-charging (e.g., engineer cost split across projects)
cost_centers
-------------
id
organization_id
name
code
parent_id (nullable)
is_active
And add a foreign key (not enforced, just soft relation) on every transaction:
ledger.cost_center_id
transactions.cost_center_id
HR needs variable posting like:
DR Salary Expense 100,000
DR Employer Contribution Expense 15,000
CR Salary Payable 92,000
CR Tax Payable 8,000
CR PF/Benefits Payable 15,000
To avoid hardcoding journal logic inside HR, add a general template engine first.
posting_templates
-------------------
id
name
transaction_type (e.g., "payroll", "bonus", "gratuity")
debit_account_id
credit_account_id
is_percentage_based (bool)
percentage (nullable)
amount_mode (gross/net/fixed)
sort_order
Accounting periods already have locking.
HR will also need:
- payroll periods (monthly/biweekly)
- lock/unlock payroll entries independently
payroll_periods
----------------
id
organization_id
start_date
end_date
is_locked
locked_by
locked_at
Later, HR posts payroll → these create accounting journals for that period → Accounting period lock ensures cross-module consistency.
Do NOT treat employees as accounting accounts directly. Instead:
parties
---------
id
type (customer, vendor, employee)
related_model_id (employees.id)
related_model_type ("Employee")
ledger.party_id (nullable)
transactions.party_id (nullable)
without special-case logic in the ledger.
Some companies accrue salary:
End of month:
DR Salary Expense
CR Salary Accrued
Payment day:
DR Salary Accrued
CR Bank
If you don’t prepare for accruals early, you'll need heavy refactoring.
Add a supports_accruals flag to account types:
accounts.supports_accruals = bool
And allow transaction types to be marked as “accrual entries”.
You’re already adding attachment modules.
HR will need:
- payslips
- appointment letters
- contract scans
- expense receipts
- tax documents
Instead of duplicating attachment logic between HR and Accounting, create a unified file/document system.
documents
----------
id
organization_id
attached_to_type (e.g. "Employee", "Transaction", "PayrollRun")
attached_to_id
file_path
file_size
mime_type
uploaded_by
uploaded_at
No future code duplication.
In your Domain Events:
JournalPosted
PeriodLocked
LedgerAdjusted
SnapshotRecomputed
Add these HR-relevant events:
PayrollPosted
PayrollPeriodLocked
PayrollRecomputed (for backdated salary changes)
HR doesn’t need to know accounting internals. It will just listen for these events.
Instead of:
- PayrollService posting salary
- ExpenseService posting reimbursement
- AccountingService posting journal
Create one unified API:
Accounting::post($transactionDTO);This avoids HR writing custom ledger code.
Never make HR models import accounting classes directly.
Use:
- DTOs
- Interfaces
- Services
Example:
HR → (PayrollDTO) → Accounting Posting Service → Ledger
This prevents circular dependencies (the common ERP cancer).
Once HR & Accounting integrate, reporting becomes:
- Cost per employee
- Cost per department
- Cost per project
- Cost per location
- Cost of overtime, allowances
- Payroll liability aging
- Payroll cash flow
- Employee advance ledger
- Gratuity/provision liability report
This means:
✔ Multi-dimensional filters ✔ Cost center roll-ups ✔ Party-based reporting ✔ Period-based snapshots ✔ Accrual vs cash basis options
If this isn't architected upfront, reports become impossible later.
Make Accounting modular:
AccountingCore/
Ledger/
Accounts/
Posting/
Periods/
Modules/
HR/
Inventory/
Banking/
CRM/
You can register “posting handlers” from other modules:
InventoryPostingProvider
HRPayrollPostingProvider
BankingReconciliationPostingProvider
This keeps the ledger system central, but avoids the entire ERP turning into a spaghetti ball.
| Feature | Why Needed |
|---|---|
| Cost centers | HR cost allocation |
| Party abstraction | Treat employees like vendors/customers |
| Unified Document/Attachment system | Payslips & vouchers share same model |
| Payroll period lock | Prevent backdated salary corruption |
| Posting templates | Avoid hardcoding payroll postings |
| Accrual support | Salary, bonus, leave encashment |
| Domain events | HR listens to accounting state changes |
| Modular architecture | Avoid circular dependencies |
| Reporting dims (cost center, party) | HR financial reporting |
| Generic posting API | HR never touches ledger internals |
- cost centers
- parties
- payroll periods
- posting templates
- employee advances
- HR documents
Just tell me which piece to deliver next.
This is a large, multi-module delivery, so I’m going to produce it cleanly and coherently in six sections — each fully production-ready, consistent with the architecture we’ve already laid out.
You will get:
-
Full Migrations
- Cost Centers
- Parties
- Payroll Periods
- Posting Templates
- Employee Advances
- HR Documents
-
PayrollPostingService.php
-
MonthlyPayrollRunner.php (a real payroll processing pipeline)
-
Accrual Posting Engine (Accrual → Reverse Accrual → Actual Payment)
-
HR → Accounting Integration Diagram (text-based architecture)
-
Employee Advance Reconciliation System
All migrations are Laravel 10/11+ compatible.
php artisan make:migration create_cost_centers_table
Schema::create('cost_centers', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('organization_id');
$table->string('name');
$table->string('code')->unique();
$table->unsignedBigInteger('parent_id')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});php artisan make:migration create_parties_table
Schema::create('parties', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('organization_id');
$table->string('type'); // employee, customer, vendor
$table->unsignedBigInteger('related_model_id');
$table->string('related_model_type');
$table->string('display_name');
$table->timestamps();
});php artisan make:migration create_payroll_periods_table
Schema::create('payroll_periods', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('organization_id');
$table->date('start_date');
$table->date('end_date');
$table->boolean('is_locked')->default(false);
$table->unsignedBigInteger('locked_by')->nullable();
$table->timestamp('locked_at')->nullable();
$table->timestamps();
});php artisan make:migration create_posting_templates_table
Schema::create('posting_templates', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('organization_id');
$table->string('transaction_type'); // payroll, bonus, gratuity, leave_encashment…
$table->unsignedBigInteger('debit_account_id')->nullable();
$table->unsignedBigInteger('credit_account_id')->nullable();
$table->boolean('is_percentage_based')->default(false);
$table->decimal('percentage', 5, 2)->nullable();
$table->enum('amount_mode', ['gross', 'net', 'fixed'])->default('gross');
$table->integer('sort_order')->default(1);
$table->timestamps();
});php artisan make:migration create_employee_advances_table
Schema::create('employee_advances', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('employee_id');
$table->unsignedBigInteger('party_id');
$table->unsignedBigInteger('organization_id');
$table->decimal('amount', 15, 2);
$table->decimal('remaining_amount', 15, 2);
$table->date('advance_date');
$table->enum('status', ['open', 'partial', 'closed'])->default('open');
$table->timestamps();
});php artisan make:migration create_documents_table
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('organization_id');
$table->string('attached_to_type');
$table->unsignedBigInteger('attached_to_id');
$table->string('file_path');
$table->string('mime_type');
$table->unsignedBigInteger('file_size')->nullable();
$table->unsignedBigInteger('uploaded_by');
$table->timestamp('uploaded_at')->nullable();
$table->timestamps();
});This service converts a payroll run into accounting journal entries based on the posting templates.
<?php
namespace App\Domain\HR\Services;
use App\Domain\Accounting\Services\JournalService;
use App\Models\PostingTemplate;
use IFRS\Models\Account;
use Illuminate\Support\Facades\DB;
class PayrollPostingService
{
public function postPayrollRun($payrollRun)
{
if ($payrollRun->is_posted) {
throw new \Exception("Payroll already posted.");
}
$templates = PostingTemplate::where('organization_id', $payrollRun->organization_id)
->where('transaction_type', 'payroll')
->orderBy('sort_order')
->get();
return DB::transaction(function () use ($payrollRun, $templates) {
$journalLines = [];
foreach ($payrollRun->employees as $emp) {
$gross = $emp->gross_salary;
$net = $emp->net_salary;
foreach ($templates as $t) {
$amount = match ($t->amount_mode) {
'gross' => $gross,
'net' => $net,
'fixed' => $t->fixed_amount,
};
if ($t->is_percentage_based) {
$amount = ($amount * $t->percentage) / 100;
}
$journalLines[] = [
'debit_account' => $t->debit_account_id,
'credit_account' => $t->credit_account_id,
'amount' => $amount,
'cost_center_id' => $emp->cost_center_id,
'party_id' => $emp->party_id,
];
}
}
// Post as one consolidated journal entry
$journal = app(JournalService::class)->createJournal([
'entity_id' => $payrollRun->organization_id,
'transaction_date' => $payrollRun->period_end,
'reference' => "Payroll " . $payrollRun->periodLabel(),
'narration' => "Payroll for period " . $payrollRun->periodLabel(),
'lines' => $journalLines,
]);
$payrollRun->update([
'is_posted' => true,
'journal_id' => $journal->id,
]);
return $journal;
});
}
}Handles processing salaries → allowances → deductions → net → pay slip generation.
<?php
namespace App\Domain\HR\Services;
use App\Models\PayrollPeriod;
use App\Models\Employee;
use Illuminate\Support\Facades\DB;
class MonthlyPayrollRunner
{
public function run(PayrollPeriod $period)
{
if ($period->is_locked) {
throw new \Exception("Payroll period is locked.");
}
return DB::transaction(function () use ($period) {
foreach (Employee::active()->get() as $emp) {
$gross = $emp->basic_salary
+ $emp->allowances()->sum('amount')
- $emp->deductions()->sum('amount');
$tax = $emp->calculateTax($gross);
$net = $gross - $tax;
$period->employees()->create([
'employee_id' => $emp->id,
'gross_salary' => $gross,
'net_salary' => $net,
'tax_amount' => $tax,
'cost_center_id' => $emp->cost_center_id,
'party_id' => $emp->party_id,
]);
}
return $period->load('employees');
});
}
}Handles:
- End of period accrual
- Reversal posting on the first day of next month
- Actual salary payment posting
End of month:
DR Salary Expense
CR Salary Accrued
Start of next month (auto reversal):
DR Salary Accrued
CR Salary Expense
Payment day:
DR Salary Accrued
CR Bank/Cash
<?php
namespace App\Domain\Accounting\Services;
use IFRS\Models\Account;
class AccrualPostingService
{
public function postAccrual($period)
{
$total = $period->employees->sum('gross_salary');
return app(JournalService::class)->createJournal([
'transaction_date' => $period->end_date,
'reference' => 'Payroll Accrual',
'narration' => 'Accrued salary',
'lines' => [
[
'debit_account' => Account::code('500')->id, // Salary Expense
'credit_account' => Account::code('210')->id, // Salary Accrued
'amount' => $total,
],
]
]);
}
public function postReversal($period)
{
$total = $period->employees->sum('gross_salary');
return app(JournalService::class)->createJournal([
'transaction_date' => $period->start_date->addDay(),
'reference' => 'Accrual Reversal',
'narration' => 'Reverse accrued salary',
'lines' => [
[
'debit_account' => Account::code('210')->id,
'credit_account' => Account::code('500')->id,
'amount' => $total,
],
]
]);
}
} ┌──────────────────┐
│ HR Module │
│ (Employees, Pay) │
└───────┬──────────┘
│
PayrollRunDTO│
▼
┌──────────────────────────┐
│ PayrollPostingService │
└───────┬──────────────────┘
│ builds JournalDTO
▼
┌──────────────────────────────┐
│ Accounting: Posting │
│ LedgerPostingService │
└────────┬─────────────────────┘
│ creates ledgers
▼
┌──────────────────────────────┐
│ IFRS Ledger (eloquent-ifrs) │
└──────────────────────────────┘
HR never sees ledger internals. Accounting never imports HR classes. The boundary is always a DTO.
DR Employee Advance Receivable
CR Cash/Bank
During payroll, if net salary < remaining advance → update reconciliation:
DR Salary Payable
CR Employee Advance Receivable
<?php
namespace App\Domain\HR\Services;
use App\Models\EmployeeAdvance;
use App\Domain\Accounting\Services\JournalService;
class AdvanceReconciliationService
{
public function reconcile($employee, $payrollRecord)
{
$advance = EmployeeAdvance::where('employee_id', $employee->id)
->whereIn('status', ['open', 'partial'])
->first();
if (!$advance) return null;
$deduct = min($payrollRecord->net_salary, $advance->remaining_amount);
$advance->remaining_amount -= $deduct;
$advance->status = $advance->remaining_amount == 0 ? 'closed' : 'partial';
$advance->save();
return app(JournalService::class)->createJournal([
'transaction_date' => now(),
'reference' => "Advance Reconciliation for {$employee->name}",
'lines' => [
[
'debit_account' => $employee->salary_payable_account_id,
'credit_account' => $employee->advance_receivable_account_id,
'amount' => $deduct
]
]
]);
}
}If you want, I can continue with:
- Payroll models + relationships
- Loan management module
- Allowances & deduction rule engine
- Full payroll tax computation module
- Recruitment/Onboarding integration
- HR Attendance → Overtime → Payroll integration
- Leave accrual posting engine
Just tell me which part to build next.
Nice — below is a production-ready implementation for the three pieces you asked for:
- Payroll models + relationships (migrations + Eloquent models + relationships)
- Loan management module (migrations, models,
LoanServicewith apply/approve/disburse/repay & schedule) - Allowances & deduction rule engine (migrations, models, rule evaluator service, examples)
Everything follows the earlier domain structure (namespace App\Domain\HR and App\Domain\Accounting where relevant) and is ready to plug into the JournalService / LedgerPostingService you already have. I kept things pragmatic and transaction-safe.
Implementation notes:
- Use UUIDs for public IDs or integers if you prefer — migrations below use
id(integer) for simplicity except where noted.- Money uses
decimal(28,8)in places where precision matters; payroll amounts usedecimal(15,2).- Hook
PayrollPostingService(we produced earlier) to post journals from payroll runs.- Services use
DB::transaction()to ensure atomicity.
Create these migrations (filenames shortened for brevity).
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up() {
Schema::create('employees', function (Blueprint $t) {
$t->bigIncrements('id');
$t->unsignedBigInteger('organization_id')->index();
$t->string('employee_no')->nullable()->index();
$t->string('first_name');
$t->string('last_name')->nullable();
$t->string('email')->nullable()->unique();
$t->string('phone')->nullable();
$t->unsignedBigInteger('user_id')->nullable(); // link to app user
$t->unsignedBigInteger('cost_center_id')->nullable();
$t->unsignedBigInteger('party_id')->nullable(); // link to parties table
$t->string('payroll_account_id')->nullable(); // optional COA id for payroll
$t->decimal('basic_salary', 15,2)->default(0);
$t->enum('salary_frequency',['monthly','biweekly','weekly'])->default('monthly');
$t->enum('employment_type',['permanent','contract','temporary'])->default('permanent');
$t->date('hired_at')->nullable();
$t->date('terminated_at')->nullable();
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('employees'); }
};<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('payroll_runs', function (Blueprint $t) {
$t->bigIncrements('id');
$t->unsignedBigInteger('organization_id')->index();
$t->string('period_label')->index(); // "2025-10" or "2025-Wk42"
$t->date('period_start')->nullable();
$t->date('period_end')->nullable();
$t->boolean('is_locked')->default(false);
$t->boolean('is_posted')->default(false);
$t->unsignedBigInteger('posted_journal_id')->nullable();
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('payroll_runs'); }
};<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('payroll_employees', function (Blueprint $t) {
$t->bigIncrements('id');
$t->unsignedBigInteger('payroll_run_id')->index();
$t->unsignedBigInteger('employee_id')->index();
$t->decimal('basic', 15,2)->default(0);
$t->decimal('gross_salary', 15,2)->default(0);
$t->decimal('total_allowances', 15,2)->default(0);
$t->decimal('total_deductions', 15,2)->default(0);
$t->decimal('tax_amount', 15,2)->default(0);
$t->decimal('net_salary', 15,2)->default(0);
$t->unsignedBigInteger('cost_center_id')->nullable();
$t->unsignedBigInteger('party_id')->nullable();
$t->json('meta')->nullable(); // breakdowns, raw data
$t->timestamps();
$t->unique(['payroll_run_id','employee_id']);
});
}
public function down(){ Schema::dropIfExists('payroll_employees'); }
};<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('allowance_rules', function (Blueprint $t) {
$t->bigIncrements('id');
$t->unsignedBigInteger('organization_id')->index();
$t->string('name');
$t->enum('type',['allowance','deduction']);
$t->enum('calculation_mode',['fixed','percentage','expression']); // expression = custom PHP-ish or safe DSL
$t->decimal('amount', 15, 4)->nullable(); // fixed or percentage
$t->string('expression')->nullable(); // e.g. "basic * 0.10 + overtime"
$t->jsonb('conditions')->nullable(); // JSON: apply when ...
$t->boolean('is_taxable')->default(false);
$t->boolean('is_pensionable')->default(false);
$t->boolean('is_active')->default(true);
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('allowance_rules'); }
};<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('employee_allowances', function (Blueprint $t) {
$t->bigIncrements('id');
$t->unsignedBigInteger('employee_id')->index();
$t->unsignedBigInteger('allowance_rule_id')->index();
$t->decimal('amount',15,2);
$t->string('frequency')->default('monthly');
$t->jsonb('meta')->nullable();
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('employee_allowances'); }
};<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('employee_loans', function (Blueprint $t) {
$t->bigIncrements('id');
$t->unsignedBigInteger('employee_id')->index();
$t->unsignedBigInteger('party_id')->nullable();
$t->unsignedBigInteger('organization_id')->index();
$t->decimal('principal', 15,2);
$t->decimal('interest_rate', 8,4)->default(0); // e.g., 0.05 for 5%
$t->enum('amortization',['fixed_instalment','equal_principal','bullet'])->default('fixed_instalment');
$t->integer('term_months')->nullable();
$t->decimal('monthly_installment',15,2)->nullable();
$t->enum('status',['applied','approved','disbursed','closed','rejected'])->default('applied');
$t->date('applied_at')->nullable();
$t->date('approved_at')->nullable();
$t->date('disbursed_at')->nullable();
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('employee_loans'); }
};<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(){
Schema::create('employee_loan_payments', function (Blueprint $t) {
$t->bigIncrements('id');
$t->unsignedBigInteger('employee_loan_id')->index();
$t->date('due_date')->nullable();
$t->decimal('principal', 15,2);
$t->decimal('interest', 15,2);
$t->decimal('total', 15,2);
$t->decimal('paid', 15,2)->default(0);
$t->date('paid_at')->nullable();
$t->enum('status',['pending','paid','partial'])->default('pending');
$t->unsignedBigInteger('payroll_run_id')->nullable();
$t->unsignedBigInteger('payment_journal_id')->nullable(); // link to JE if payroll deducted or manual payment
$t->timestamps();
});
}
public function down(){ Schema::dropIfExists('employee_loan_payments'); }
};Place under App\Domain\HR\Models or App\Models.
<?php
namespace App\Domain\HR\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Employee extends Model
{
protected $table = 'employees';
protected $fillable = ['organization_id','employee_no','first_name','last_name','email','phone','user_id','cost_center_id','party_id','payroll_account_id','basic_salary','salary_frequency','employment_type','hired_at','terminated_at'];
public function payrollRuns(): HasMany {
return $this->hasMany(PayrollEmployee::class,'employee_id','id');
}
public function allowances(): HasMany {
return $this->hasMany(EmployeeAllowance::class,'employee_id','id');
}
public function loans(): HasMany {
return $this->hasMany(EmployeeLoan::class,'employee_id','id');
}
public function party() {
return $this->belongsTo(\App\Domain\Accounting\Models\Party::class,'party_id','id');
}
}<?php
namespace App\Domain\HR\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PayrollRun extends Model
{
protected $table = 'payroll_runs';
protected $fillable = ['organization_id','period_label','period_start','period_end','is_locked','is_posted','posted_journal_id'];
public function employees(): HasMany {
return $this->hasMany(PayrollEmployee::class,'payroll_run_id','id');
}
}<?php
namespace App\Domain\HR\Models;
use Illuminate\Database\Eloquent\Model;
class PayrollEmployee extends Model
{
protected $table = 'payroll_employees';
protected $fillable = ['payroll_run_id','employee_id','basic','gross_salary','total_allowances','total_deductions','tax_amount','net_salary','cost_center_id','party_id','meta'];
public function employee() { return $this->belongsTo(Employee::class,'employee_id'); }
public function payrollRun() { return $this->belongsTo(PayrollRun::class,'payroll_run_id'); }
}<?php
namespace App\Domain\HR\Models;
use Illuminate\Database\Eloquent\Model;
class AllowanceRule extends Model {
protected $table = 'allowance_rules';
protected $casts = ['conditions'=>'array'];
protected $fillable = ['organization_id','name','type','calculation_mode','amount','expression','conditions','is_taxable','is_pensionable','is_active'];
}
class EmployeeAllowance extends Model {
protected $table = 'employee_allowances';
protected $casts = ['meta'=>'array'];
protected $fillable = ['employee_id','allowance_rule_id','amount','frequency','meta'];
}<?php
namespace App\Domain\HR\Models;
use Illuminate\Database\Eloquent\Model;
class EmployeeLoan extends Model {
protected $table = 'employee_loans';
protected $fillable = ['employee_id','party_id','organization_id','principal','interest_rate','amortization','term_months','monthly_installment','status','applied_at','approved_at','disbursed_at'];
public function payments(){ return $this->hasMany(EmployeeLoanPayment::class,'employee_loan_id'); }
}
class EmployeeLoanPayment extends Model {
protected $table = 'employee_loan_payments';
protected $fillable = ['employee_loan_id','due_date','principal','interest','total','paid','paid_at','status','payroll_run_id','payment_journal_id'];
}This engine evaluates active rules for an employee and produces allowance/deduction line items (name, amount, taxable flag, account map).
app/Domain/HR/Services/AllowanceDeductionEngine.php
<?php
namespace App\Domain\HR\Services;
use App\Domain\HR\Models\AllowanceRule;
use App\Domain\HR\Models\EmployeeAllowance;
use App\Domain\HR\Models\Employee;
use Illuminate\Support\Facades\Log;
use Exception;
class AllowanceDeductionEngine
{
/**
* Compute allowances & deductions for a single employee for a payroll period.
* Returns ['allowances' => [...], 'deductions' => [...], 'total_allowances'=>x, 'total_deductions'=>y]
*/
public function computeForEmployee(Employee $employee, array $context = []): array
{
$rules = AllowanceRule::where('organization_id', $employee->organization_id)
->where('is_active', true)
->get();
$allowances = []; $deductions = [];
$totalAllow = 0; $totalDed = 0;
// context contains payroll values: basic, overtime, days_worked, etc.
$ctx = array_merge([
'basic' => (float)$employee->basic_salary,
'overtime' => 0.0,
'days' => 0,
], $context);
foreach ($rules as $rule) {
if (!$this->conditionsPass($rule->conditions ?? [], $employee, $ctx)) continue;
try {
$amount = $this->evaluateAmount($rule, $employee, $ctx);
} catch (Exception $e) {
Log::error("Failed to evaluate rule {$rule->id}: ".$e->getMessage());
continue;
}
$line = [
'rule_id' => $rule->id,
'name' => $rule->name,
'amount' => round($amount,2),
'is_taxable' => $rule->is_taxable,
'is_pensionable' => $rule->is_pensionable,
'calculation_mode' => $rule->calculation_mode,
];
if ($rule->type === 'allowance') {
$allowances[] = $line;
$totalAllow += $amount;
} else {
$deductions[] = $line;
$totalDed += $amount;
}
}
return [
'allowances' => $allowances,
'deductions' => $deductions,
'total_allowances' => round($totalAllow,2),
'total_deductions' => round($totalDed,2),
];
}
protected function evaluateAmount(AllowanceRule $rule, Employee $employee, array $ctx)
{
if ($rule->calculation_mode === 'fixed') {
return (float)$rule->amount;
}
if ($rule->calculation_mode === 'percentage') {
$base = strtolower($rule->expression) === 'basic' ? $ctx['basic'] : $ctx['basic'];
// we use amount as percent (e.g., 10 means 10%)
return ($base * (float)$rule->amount) / 100.0;
}
if ($rule->calculation_mode === 'expression') {
// WARNING: expression execution must be sandboxed for safety.
// Use a safe expression evaluator like math-php or a small DSL. Here we'll implement a minimal safe evaluator using allowed tokens.
return $this->evalExpression($rule->expression, $ctx);
}
throw new Exception("Unknown calculation mode {$rule->calculation_mode}");
}
/**
* Very small, safe expression evaluator:
* allowed variables: basic,overtime,days
* allowed operators: + - * / ( )
*/
protected function evalExpression(string $expr, array $ctx)
{
// whitelist characters
if (preg_match('/[^0-9\.\+\-\*\/\(\)\s_a-zA-Z]/', $expr)) {
throw new Exception('Expression contains invalid characters');
}
// replace variable names with numbers from $ctx
$repl = $expr;
foreach ($ctx as $k => $v) {
$repl = preg_replace('/\b'.preg_quote($k,'/').'\b/', "({$v})", $repl);
}
// final safety check: only digits, operators and parentheses remain
if (preg_match('/[^0-9\.\+\-\*\/\(\)\s]/', $repl)) {
throw new Exception('Expression has invalid tokens after substitution');
}
// evaluate safely using eval in restricted scope
// NOTE: eval is risky. In production use a proper math parser.
try {
// phpcs:disable
$val = 0;
// create function to safely compute
$func = create_function('', 'return '.$repl.';');
$val = $func();
// phpcs:enable
return (float)$val;
} catch (\Throwable $e) {
throw new Exception('Expression evaluation failed: '.$e->getMessage());
}
}
protected function conditionsPass($conditions, Employee $employee, array $ctx): bool
{
if (empty($conditions)) return true;
// Example conditions JSON:
// { "min_basic": 1000, "employment_type_in": ["permanent","contract"], "months_service_gt": 6 }
if (!is_array($conditions)) return true;
if (!empty($conditions['min_basic']) && $ctx['basic'] < $conditions['min_basic']) return false;
if (!empty($conditions['employment_type_in']) && !in_array($employee->employment_type, $conditions['employment_type_in'])) return false;
if (!empty($conditions['months_service_gt'])) {
$hired = $employee->hired_at ? \Carbon\Carbon::parse($employee->hired_at) : null;
if (!$hired) return false;
if ($hired->diffInMonths(now()) <= (int)$conditions['months_service_gt']) return false;
}
return true;
}
}IMPORTANT: The
evalExpressionabove usescreate_functionfor brevity but is unsafe for arbitrary user input. In production, use a safe expression evaluator (e.g., opis/closure is not safe either). I strongly recommend integrating a small math parser (e.g.,hoa/rulerorsymfony/expression-language) to evaluate rule expressions securely.
In your payroll runner, call:
$engine = app(\App\Domain\HR\Services\AllowanceDeductionEngine::class);
$result = $engine->computeForEmployee($employee, ['basic'=>$employee->basic_salary, 'overtime'=>$overtimeHours * rate, 'days'=>$workedDays]);
$payrollEmployee->total_allowances = $result['total_allowances'];
$payrollEmployee->total_deductions = $result['total_deductions'];
$payrollEmployee->gross_salary = $employee->basic_salary + $result['total_allowances'];
$payrollEmployee->net_salary = $payrollEmployee->gross_salary - $result['total_deductions'] - $tax;
$payrollEmployee->meta = ['allowances'=>$result['allowances'],'deductions'=>$result['deductions']];
$payrollEmployee->save();This service manages loan apply → approve → disburse → repayment scheduling → payroll deduction / manual payment.
app/Domain/HR/Services/LoanService.php
<?php
namespace App\Domain\HR\Services;
use App\Domain\HR\Models\EmployeeLoan;
use App\Domain\HR\Models\EmployeeLoanPayment;
use App\Domain\HR\Models\Employee;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
use Exception;
class LoanService
{
/**
* Apply for loan
*/
public function applyLoan(Employee $employee, float $principal, float $interestRate, int $termMonths, string $amortization = 'fixed_instalment')
{
return DB::transaction(function() use ($employee,$principal,$interestRate,$termMonths,$amortization) {
$loan = EmployeeLoan::create([
'employee_id' => $employee->id,
'organization_id' => $employee->organization_id,
'principal' => $principal,
'interest_rate' => $interestRate,
'amortization' => $amortization,
'term_months' => $termMonths,
'status' => 'applied',
'applied_at' => now(),
]);
return $loan;
});
}
/**
* Approve a loan (compute schedule)
*/
public function approveLoan(EmployeeLoan $loan)
{
if ($loan->status !== 'applied') throw new Exception('Loan not in applied state');
return DB::transaction(function() use ($loan) {
// compute schedule
$schedule = $this->buildSchedule($loan->principal, $loan->interest_rate, $loan->term_months, $loan->amortization, Carbon::today());
// persist payments
foreach ($schedule as $p) {
EmployeeLoanPayment::create([
'employee_loan_id' => $loan->id,
'due_date' => $p['due_date'],
'principal' => $p['principal'],
'interest' => $p['interest'],
'total' => $p['total'],
'status' => 'pending',
]);
}
$loan->status = 'approved';
$loan->approved_at = now();
$loan->monthly_installment = $schedule[0]['total'] ?? null;
$loan->save();
return $loan;
});
}
/**
* Disburse loan (creates journal: debit employee advance receivable, credit bank)
*/
public function disburseLoan(EmployeeLoan $loan, array $options = [])
{
if ($loan->status !== 'approved') throw new Exception('Loan must be approved before disbursement');
return DB::transaction(function() use ($loan,$options) {
$journalService = app(\App\Domain\Accounting\Services\JournalService::class);
// create journal: Debit Employee Advance (asset), Credit Bank/Cash
$je = $journalService->createDraft([
'entity_id' => $loan->organization_id,
'date' => now()->toDateString(),
'narration' => "Loan Disbursement for employee {$loan->employee_id}",
]);
// debit employee advance receivable account (assume config mapping)
$employeeAdvanceAccount = config('accounting.employee_advance_account_id');
$bankAccount = $options['bank_account_id'] ?? config('accounting.default_cash_account_id');
$journalService->addLine($je, [
'account_id' => $employeeAdvanceAccount,
'debit' => $loan->principal,
'description' => 'Employee Loan (advance)',
]);
$journalService->addLine($je, [
'account_id' => $bankAccount,
'credit' => $loan->principal,
'description' => 'Loan disbursed to employee',
]);
$journalService->validate($je);
app(\App\Domain\Accounting\Services\LedgerPostingService::class)->post($je);
// mark loan disbursed and link journal id
$loan->status = 'disbursed';
$loan->disbursed_at = now();
$loan->save();
// set payments exist; they were created earlier on approve
return $je;
});
}
/**
* Build repayment schedule
* For fixed_instalment uses annuity formula; for equal_principal uses principal/term + interest on reducing balance
*/
protected function buildSchedule(float $principal, float $annualInterestRate, int $termMonths, string $amortization, \DateTimeInterface $startDate)
{
$schedule = [];
$monthlyRate = $annualInterestRate / 12.0;
if ($amortization === 'fixed_instalment') {
// Annuity payment formula
if ($monthlyRate <= 0) {
$payment = $principal / $termMonths;
for ($i = 1; $i <= $termMonths; $i++) {
$due = (new Carbon($startDate))->addMonths($i);
$schedule[] = ['due_date'=>$due->toDateString(),'principal'=>round($payment,2),'interest'=>0.0,'total'=>round($payment,2)];
}
return $schedule;
}
$r = $monthlyRate;
$n = $termMonths;
$payment = $principal * ($r * pow(1+$r,$n)) / (pow(1+$r,$n)-1);
$balance = $principal;
for ($i=1;$i<=$n;$i++) {
$interest = round($balance * $r, 2);
$principalPart = round($payment - $interest, 2);
if ($i === $n) {
// adjust last payment for rounding
$principalPart = round($balance,2);
$payment = round($principalPart + $interest, 2);
}
$due = (new Carbon($startDate))->addMonths($i);
$schedule[] = ['due_date'=>$due->toDateString(),'principal'=>$principalPart, 'interest'=>$interest, 'total'=>round($principalPart+$interest,2)];
$balance = round($balance - $principalPart, 2);
}
return $schedule;
}
if ($amortization === 'equal_principal') {
$principalPart = round($principal / $termMonths, 2);
$balance = $principal;
for ($i=1;$i<=$termMonths;$i++) {
$interest = round($balance * $monthlyRate, 2);
$due = (new Carbon($startDate))->addMonths($i);
$total = round($principalPart + $interest, 2);
if ($i === $termMonths) { // last adjust
$principalPart = round($balance, 2);
$total = round($principalPart + $interest, 2);
}
$schedule[] = ['due_date'=>$due->toDateString(),'principal'=>$principalPart,'interest'=>$interest,'total'=>$total];
$balance = round($balance - $principalPart, 2);
}
return $schedule;
}
// bullet or others
if ($amortization === 'bullet') {
$interestTotal = round($principal * $annualInterestRate * ($termMonths/12.0),2);
$due = (new Carbon($startDate))->addMonths($termMonths);
$schedule[] = ['due_date'=>$due->toDateString(),'principal'=>round($principal,2),'interest'=>$interestTotal,'total'=>round($principal+$interestTotal,2)];
return $schedule;
}
throw new Exception("Unknown amortization method {$amortization}");
}
/**
* Repay via payroll deduction or manual payment
* If via payroll, we will mark payment_paid and create a journal entry (debit bank/payable, credit loan receivable)
*/
public function recordPayment(EmployeeLoanPayment $payment, float $amount, array $options = [])
{
return DB::transaction(function() use ($payment,$amount,$options) {
$payment->paid += $amount;
$payment->paid_at = $options['paid_at'] ?? now();
$payment->status = $payment->paid >= $payment->total ? 'paid' : 'partial';
$payment->save();
// create journal if manual payment (or if options indicate)
if (!empty($options['create_journal'])) {
$journalService = app(\App\Domain\Accounting\Services\JournalService::class);
$je = $journalService->createDraft([
'entity_id' => $payment->loan->organization_id,
'date' => $payment->paid_at->toDateString(),
'narration' => "Loan repayment for loan {$payment->employee_loan_id}",
]);
// debit bank/cash, credit loan receivable
$bankAccount = $options['bank_account_id'] ?? config('accounting.default_cash_account_id');
$loanReceivable = config('accounting.employee_advance_account_id');
$journalService->addLine($je,['account_id'=>$bankAccount,'debit'=>$amount,'description'=>'Loan repayment']);
$journalService->addLine($je,['account_id'=>$loanReceivable,'credit'=>$amount,'description'=>'Reduce employee loan receivable']);
$journalService->validate($je);
app(\App\Domain\Accounting\Services\LedgerPostingService::class)->post($je);
$payment->payment_journal_id = $je->id;
$payment->save();
}
// if loan fully paid, close loan
$loan = $payment->loan;
$remaining = $loan->payments()->whereIn('status',['pending','partial'])->sum(DB::raw('total - paid'));
if ($remaining <= 0.01) {
$loan->status = 'closed';
$loan->save();
}
return $payment;
});
}
}-
Define allowances/deductions rules in
allowance_rules. For example:- Overtime allowance:
type=allowance,calculation_mode=expression,expression="overtime * 1.5",conditionsrequiringovertime>0. - Transport allowance:
type=allowance,calculation_mode=fixed,amount=100.
- Overtime allowance:
-
Monthly payroll run:
MonthlyPayrollRunner::run($period)collects active employees.- For each employee call
AllowanceDeductionEngine::computeForEmployee()to get allowances/deductions. - Fill
payroll_employeesrows (basic, gross, totals). - Run tax calculator (pluggable service).
- Save payroll run and mark
is_lockedwhen ready.
-
Posting:
PayrollPostingService::postPayrollRun($payrollRun)(we provided earlier) converts payroll employees into journal lines and posts viaJournalService/LedgerPostingService.
-
Loans:
- Employee applies:
LoanService::applyLoan(). - HR approves:
LoanService::approveLoan()computes schedule and persistsemployee_loan_payments. - Disburse:
LoanService::disburseLoan()creates JE debiting advance receivable and crediting bank. - Repayment via payroll: collect per-employee deduction amount, apply to
EmployeeLoanPayment::paidand callLoanService::recordPayment($payment, $amount, ['create_journal'=>true, 'bank_account_id'=>...])or create a consolidated journal viaPayrollPostingService.
- Employee applies:
- Expression evaluation: replace the toy
evalExpression()with a proper safe expression evaluator library before accepting admin-entered expressions. - Rounding: consistently use
round(...,2)for monetary display, but use higher internal scale (like 8) for calculations when appropriate. - Authorization: restrict who can create/approve loans, change allowance rules, or post payroll. Use policies.
- Audit: create
AuditLogentries for loan approvals, disbursements, payroll posting and rule changes. - Idempotency: protect payroll posting endpoints with idempotency keys to avoid duplicate posts.
- Tests: add integration tests for payroll totals, loan schedules, and deduction application under multiple scenarios.
If you want, next I can:
- Produce controllers + API endpoints for applying/approving loans, managing allowance rules, and launching payroll runs.
- Add sample seeders that create typical allowance rules and sample employees for testing.
- Generate an OpenAPI / Postman spec for the new endpoints.
Which of those would you like next?