nml / fincore
NML FinCore - Robust Double-Entry Accounting Engine for Laravel ERPs
Fund package maintenance!
Requires
- php: ^8.1
- illuminate/database: ^10.0|^11.0
- illuminate/support: ^10.0|^11.0
Requires (Dev)
- orchestra/testbench: ^9.0
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
NML FinCore is a robust, decoupled double-entry accounting engine designed for Laravel applications. It enables real-time ledger generation, polymorphic linking to transactional models, multi-SBU reporting, and strict period locks.
💖 NML FinCore PRO (Commercial UI Kit & ERP Auto-Posting Engine)
Looking for a Ready-Made QuickBooks-style Accounting Dashboard, Live Drill-Down Reports, and Zero-Accounting-Knowledge ERP Event Bridge?
Check out NML FinCore PRO!
🌟 What's in FinCore PRO?
- Full QuickBooks Online (QBO) UI Suite: Chart of Accounts, Journal Voucher Manager, Trial Balance, Profit & Loss, Balance Sheet, and General Ledger.
- Zero-Accounting-Knowledge Event Bridge (
FinCorePro::recordErpEvent): Record Sales, Payments, Bills, and Expenses directly from ERP controllers without calculating Debits, Credits, VAT, or COGS! - Dynamic ERP Posting Rules UI (
/accounting/settings): Accountants can dynamically re-map accounts from the browser without code changes. - Dual-Mode Integration: Embeds seamlessly into your existing Bootstrap 4/5 or Blade ERP layout via Scoped CSS (
.fincore-scope).
🚀 Get FinCore PRO ($29 Sponsor Tier)
Sponsor $29 on GitHub Sponsors (Premod1) to receive instant private repository access, automated collaborator invitation, and all future updates:
👉 Sponsor & Get FinCore PRO ($29)
Once sponsored, install via Composer:
"repositories": [ { "type": "vcs", "url": "https://github.com/Premod1/nml-fincore-pro.git" } ], "require": { "nml/fincore-pro": "^1.0" }
Installation
Install the package via Composer:
composer require nml/fincore
Migration Setup
The package migrations will be loaded automatically. Run the database migrations to create the necessary tables:
php artisan migrate
Configuration (Optional)
Publish the configuration file to customize the table prefix, rounding tolerance, currency, and period locks:
php artisan vendor:publish --tag=fincore-config
Core Feature List
- Double-entry accounting engine: Enforces exact balance matching for debit and credit lines within a configurable rounding tolerance.
- Dynamic chart of accounts: Classifies and rolls up accounts hierarchically under Assets, Liabilities, Equity, Revenue, and Expenses with parent-child relationship support.
- Real-time general ledger generation: Generates real-time ledger entries and balances.
- Trial Balance reporting: Dynamic Trial Balance generation based on date range and SBU codes.
- Balance Sheet reporting: Dynamic Balance Sheet generation showing Assets, Liabilities, and Equity.
- Income Statement (P&L): Dynamic Profit & Loss reporting based on Revenue and Expense accounts.
- Cash Flow statements: Automatically generated Cash Flow statements based on account movements.
- Multi-SBU / branch reporting: Tracks entries using Strategic Business Unit (SBU) codes for departmental or branch-level accounting.
- Fiscal period locking: Prevents retrospective postings or modifications to closed accounting periods and years.
- Polymorphic transaction linking: Links journal entries directly to external source models (Invoices, Sales, Purchases, Payments).
- Multi-currency support: Supports transactions in foreign currencies with exchange rates.
- Tax/VAT handling: Enforces tax rules, inclusive/exclusive calculations, and automated tax line generation.
- Fixed asset management: Fixed asset register with purchase cost and accumulated depreciation tracking.
- Automated depreciation posting: Automatically calculates and posts monthly straight-line depreciation entries.
- Budgeting & variance reporting: Sets monthly budget targets per account and generates Budget vs. Actual variance reports.
- Bank reconciliation matching: Automatically matches bank statement transactions with ledger lines and supports manual clearing.
- AR/AP ageing reports: Tracks partner receivables and payables with FIFO payment allocation and ageing buckets.
- Fiscal year closing engine: Automatically zero-out temporary Revenue/Expense accounts and transfers Net Profit/Loss to Retained Earnings.
- Full audit trail & activity logging: Automatically logs all creations, updates, status transitions, and deletions, capturing user ID, IP address, user agent, and JSON diffs.
Initialization
Seed the standard Chart of Accounts using the provided seeder or calling the initializer class directly:
Option A: Using Laravel Database Seeder
Add the package seeder class to your main database/seeders/DatabaseSeeder.php file:
public function run(): void { $this->call(\Nml\FinCore\Database\Seeders\FinCoreSeeder::class); }
Or run it directly from the terminal:
php artisan db:seed --class="Nml\FinCore\Database\Seeders\FinCoreSeeder"
Option B: Programmatic Initialization
You can seed the standard Chart of Accounts programmatically (e.g., in a tenant setup flow or custom script):
use Nml\FinCore\Services\ChartOfAccountsInitializer; // Seeds the default chart of accounts (idempotent, safe to rerun) ChartOfAccountsInitializer::initialize('LKR');
Chart of Accounts Management
To create and manage custom accounts (including parent-child hierarchies), use the Account model:
Creating a Root Account
use Nml\FinCore\Models\Account; use Nml\FinCore\Enums\AccountType; $assetsParent = Account::create([ 'code' => '1000', 'name' => 'Assets', 'type' => AccountType::ASSET->value, 'subtype' => 'asset', 'parent_id' => null, // Root account ]);
Creating a Sub-Account (Child Account)
Link it to its parent by passing the parent's id:
$bankAccount = Account::create([ 'code' => '1100', 'name' => 'Seylan Bank A/C', 'type' => AccountType::ASSET->value, 'subtype' => 'current_asset', 'parent_id' => $assetsParent->id, // Linked to the parent account ]);
Usage Scenarios
1. Creating and Posting a Journal Entry
Create a journal entry as a draft, validate it, and post it to the General Ledger.
use Nml\FinCore\Facades\FinCore; use Nml\FinCore\Enums\JvType; // Create the journal entry draft $entry = FinCore::createJournalEntry([ 'date' => '2026-06-18', 'reference' => 'INV-2026-0001', 'type' => JvType::GENERAL->value, 'description' => 'Credit sale invoice registration', 'sbu_code' => 'COLOMBO_SBU', 'journalable_type' => 'App\Models\Invoice', // Optional polymorphic model link 'journalable_id' => 45, 'lines' => [ [ 'account_id' => 3, // Accounts Receivable (Asset) 'type' => 'debit', 'amount' => 50000.00, 'description' => 'Debit A/R for customer invoice' ], [ 'account_id' => 20, // Sales Revenue (Revenue) 'type' => 'credit', 'amount' => 50000.00, 'description' => 'Credit Sales Revenue' ] ] ]); // Post to General Ledger (Performs balance matching & period lock checks) try { $entry->post(); } catch (\Nml\FinCore\Exceptions\AccountingException $e) { // Handle validation errors (e.g., Unbalanced Debits/Credits or Closed Period) Log::error($e->getMessage()); }
1.1. Creating a Manual Journal Voucher (JV)
For manual journal adjustments entered by accountants via a voucher UI, collect the form inputs (rows with Account, Type, Debit/Credit, and optional Notes) and post them directly:
use Nml\FinCore\Facades\FinCore; use Nml\FinCore\Enums\JvType; // Map the dynamic rows of a manual JV interface to the lines array: $entry = FinCore::createJournalEntry([ 'date' => '2026-06-18', 'reference' => 'JV-2026-0045', 'type' => JvType::GENERAL->value, 'description' => 'Manual adjustment for prepaid rent', 'lines' => [ [ 'account_id' => 12, // Prepaid Expense Account 'type' => 'debit', 'amount' => 15000.00, // Debit amount from Row 1 'description' => 'Amortization of rent' ], [ 'account_id' => 25, // Rent Expense Account 'type' => 'credit', 'amount' => 15000.00, // Credit amount from Row 2 'description' => 'Corresponding rent credit' ] ] ]); // Post to ledger $entry->post();
Parameter Details
type: Indicates the category of the journal entry. Developers should use theNml\FinCore\Enums\JvTypeenum:JvType::GENERAL->value('general'): Normal business transactions (e.g., sales invoices, supplier purchases, payments).JvType::ADJUSTMENT->value('adjustment'): End-of-period adjustments (e.g., depreciation, accruals, prepayments).JvType::CLOSING->value('closing'): Year-end closing entries to reset revenue/expense balances to the retained earnings account.
sbu_code: Strategic Business Unit (Branch, Department, or Division) code. Tagging entries with this code enables department-wise or branch-wise filtering on all financial statements.journalable_type&journalable_id: Polymorphic relationship columns that map this journal entry to the source document in the ERP (e.g.,App\Models\InvoiceorApp\Models\Payment). This provides an audit trail back to the originating business transaction.
2. Generating a Trial Balance
Generate a Trial Balance report within a specific date range or filter by SBU.
use Nml\FinCore\Facades\FinCore; $trialBalance = FinCore::getTrialBalance( startDate: '2026-01-01', endDate: '2026-06-30', sbuCode: 'COLOMBO_SBU' // Optional SBU filter ); // Format of the returned array: // [ // 'accounts' => [ // ['account' => AccountModel, 'debit' => 50000.00, 'credit' => 0.00, 'balance' => 50000.00], // ... // ], // 'total_debits' => 50000.00, // 'total_credits' => 50000.00, // 'is_balanced' => true // ]
3. Generating an Income Statement (Profit & Loss)
Get the revenue, expense, and net income metrics for a specified period.
use Nml\FinCore\Facades\FinCore; $incomeStatement = FinCore::getIncomeStatement( startDate: '2026-04-01', endDate: '2026-06-30' ); $netIncome = $incomeStatement['net_income']; $totalRevenue = $incomeStatement['revenue']['total']; $totalExpenses = $incomeStatement['expenses']['total'];
4. Generating a Balance Sheet
Generate the statement of financial position as of a specific date.
use Nml\FinCore\Facades\FinCore; $balanceSheet = FinCore::getBalanceSheet( date: '2026-06-18' ); $assets = $balanceSheet['assets']['total']; $liabilities = $balanceSheet['liabilities']['total']; $equity = $balanceSheet['equity']['total']; $currentYearEarnings = $balanceSheet['current_year_earnings']; // Net profit/loss dynamically rolled into equity if fiscal year is unclosed $isBalanced = $balanceSheet['is_balanced']; // Assets = Liabilities + Equity
5. Generating a General Ledger (Account Ledger)
Generate a detailed General Ledger report for a specific account, supporting memory-efficient pagination for large datasets:
use Nml\FinCore\Facades\FinCore; $ledger = FinCore::getGeneralLedger( accountId: 3, // Accounts Receivable startDate: '2026-01-01', endDate: '2026-06-30', sbuCode: 'COLOMBO_SBU', perPage: 50, // Optional pagination limit page: 1 // Optional page number ); foreach ($ledger['accounts'] as $accountReport) { $account = $accountReport['account']; // Account model instance $openingBalance = $accountReport['opening_balance']; $closingBalance = $accountReport['closing_balance']; // Pagination metadata (only returned if perPage is specified) $pagination = $accountReport['pagination'] ?? null; foreach ($accountReport['entries'] as $entry) { $entryNumber = $entry['entry_number']; $date = $entry['date']; $type = $entry['type']; // 'debit' or 'credit' $amount = $entry['amount']; $runningBalance = $entry['running_balance']; } }
6. Generating a Cash Flow Statement
Generate a Cash Flow statement (indirect method) for a date range:
use Nml\FinCore\Facades\FinCore; $cashFlow = FinCore::getCashFlowStatement( startDate: '2026-01-01', endDate: '2026-06-30' ); $operatingTotal = $cashFlow['operating_activities']['total']; $investingTotal = $cashFlow['investing_activities']['total']; $financingTotal = $cashFlow['financing_activities']['total']; $netIncreaseDecrease = $cashFlow['net_increase_decrease']; $beginningCash = $cashFlow['beginning_cash']; $endingCash = $cashFlow['ending_cash']; $isReconciled = $cashFlow['is_reconciled']; // true if beginningCash + netIncreaseDecrease matches endingCash
7. Managing Entry Statuses
Transitions draft entries to submitted, and void or reject them:
// Submit for review $entry->submit($userId); // Reject and send back to draft $entry->returnToDraft('Reason for rejection'); // Void posted journal entry $entry->void();
8. Advanced: Tax/VAT & Multi-Currency
Registering a Tax Rate
Create a tax rate mapping it to a general ledger account for tax postings:
use Nml\FinCore\Models\Tax; $vatTax = Tax::create([ 'name' => 'VAT 15%', 'code' => 'VAT-15', 'rate' => 15.00, 'account_id' => 15, // Output VAT Payable account ID 'is_active' => true ]);
Creating a Multi-Currency Entry with Automatic Tax Calculation
When creating a foreign currency transaction with tax:
- Provide the
currency(e.g.'USD') andexchange_rate(e.g.300.000000). - Provide
fc_amount(Foreign Currency amount) on lines. The system automatically computes the functional currencyamount = fc_amount * exchange_rate. - Provide
tax_idandtax_behavior('inclusive'or'exclusive'). The system calculates the tax and automatically creates the corresponding tax ledger entry.
use Nml\FinCore\Facades\FinCore; use Nml\FinCore\Enums\JvType; $entry = FinCore::createJournalEntry([ 'date' => '2026-06-18', 'reference' => 'TX-USD-001', 'type' => JvType::GENERAL->value, 'description' => 'Foreign sale invoice with tax', 'currency' => 'USD', 'exchange_rate' => 300.000000, 'lines' => [ [ 'account_id' => 3, // Accounts Receivable (Asset) 'type' => 'debit', 'fc_amount' => 115.00, // Total USD including tax 'description' => 'A/R for foreign sale' ], [ 'account_id' => 20, // Sales Revenue (Revenue) 'type' => 'credit', 'fc_amount' => 115.00, 'tax_id' => $vatTax->id, 'tax_behavior' => 'inclusive', // Tax amount (15 USD / 4500 LKR) is extracted and posted to Output VAT 'description' => 'Sales revenue inclusive of VAT' ] ] ]); // Post to ledger. Validates balancing in both USD (115.00) and LKR (34500.00). $entry->post();
9. Fixed Assets & Depreciation
Registering a Fixed Asset
Register a fixed asset with purchase costs, depreciation parameters, and its target ledger accounts:
use Nml\FinCore\Models\FixedAsset; $asset = FixedAsset::create([ 'name' => 'Delivery Van', 'code' => 'FA-VAN-001', 'purchase_date' => '2026-01-01', 'purchase_cost' => 2400000.00, 'salvage_value' => 400000.00, 'useful_life_years' => 5, 'depreciation_method' => 'straight_line', // 'straight_line' or 'reducing_balance' 'depreciation_rate' => 20.00, // 20% annual rate 'asset_account_id' => 5, // Motor Vehicles cost account 'accumulated_depreciation_account_id' => 6, // Accumulated Depreciation account 'depreciation_expense_account_id' => 25, // Depreciation Expense account 'status' => 'active' ]);
Calculating and Posting Depreciation
Calculate monthly depreciation for a specific asset or all active assets, which automatically creates and posts the corresponding double-entry journal:
use Nml\FinCore\Facades\FinCore; // 1. Calculate the monthly depreciation amount for a specific asset $amount = FinCore::calculateMonthlyDepreciation($asset, '2026-06-30'); // 2. Post depreciation for a specific asset (Creates Debit Depreciation Expense, Credit Accumulated Depreciation) $entry = FinCore::postDepreciationForAsset($asset->id, '2026-06-30'); // 3. Post monthly depreciation for all active fixed assets in bulk $results = FinCore::postDepreciationForAllActiveAssets('2026-06-30');
10. Budgeting & Variance Reporting
Setting Monthly Budget Targets
Set a target monthly budget amount for a specific account and SBU:
use Nml\FinCore\Facades\FinCore; $budget = FinCore::setMonthlyBudget( accountId: 25, // Depreciation Expense account year: 2026, month: 6, amount: 40000.00, sbuCode: 'COLOMBO_SBU' // Optional );
Generating a Budget vs. Actual Variance Report
Fetch target budgets, actual ledger movements, and variance details for a specific month:
use Nml\FinCore\Facades\FinCore; $report = FinCore::getBudgetVarianceReport( year: 2026, month: 6, sbuCode: 'COLOMBO_SBU' // Optional ); foreach ($report as $row) { $accountName = $row['account_name']; $budgetAmount = $row['budget_amount']; $actualAmount = $row['actual_amount']; $variance = $row['variance']; // Positive is Favorable, Negative is Unfavorable $variancePercentage = $row['variance_percentage']; $status = $row['status']; // 'Favorable' or 'Unfavorable' }
11. Bank Reconciliation Matcher
Starting a Bank Reconciliation Statement
Create a bank reconciliation session for a specific bank ledger account, date, opening and closing balance:
use Nml\FinCore\Facades\FinCore; $reconciliation = FinCore::createReconciliation( accountId: 1, // Bank Account statementDate: '2026-06-30', openingBalance: 150000.00, closingBalance: 185000.00 );
Fetching Unreconciled Ledger Lines
Get all posted journal entry lines for this account that remain uncleared:
use Nml\FinCore\Facades\FinCore; $unreconciled = FinCore::getUnreconciledLines( accountId: 1, endDate: '2026-06-30' );
Running the Auto-Matcher
Match uploaded bank statement transactions against unreconciled ledger entries automatically. The engine matches based on exact amount, type (debit/credit), and transaction date differences:
use Nml\FinCore\Facades\FinCore; $statementTransactions = [ ['date' => '2026-06-10', 'amount' => 5000.00, 'type' => 'debit', 'reference' => 'TXN-1'], ['date' => '2026-06-15', 'amount' => 30000.00, 'type' => 'credit', 'reference' => 'TXN-2'], ]; $results = FinCore::autoMatchStatementTransactions( reconciliationId: $reconciliation->id, statementTransactions: $statementTransactions ); $matched = $results['matched']; // List of successfully matched items $unmatched = $results['unmatched']; // List of bank statement items not found in ledger
Manual Clear and Finalization
Reconcile lines manually or finalize the statement. Finalization requires the cleared balance to match the statement closing balance perfectly:
use Nml\FinCore\Facades\FinCore; // Manually clear a ledger line FinCore::manuallyClearLine($reconciliation->id, $lineId = 42, '2026-06-25'); // Finalize and close the reconciliation statement FinCore::finalizeReconciliation($reconciliation->id);
12. Accounts Receivable (AR) & Accounts Payable (AP) Ageing Reports
Recording Entries with Partner Metadata
To track receivables and payables per customer or vendor, pass the polymorphic partnerable object and optional due_date when creating double-entry records:
use Nml\FinCore\Facades\FinCore; // Create sales invoice entry with partner tracking $entry = FinCore::createJournalEntry([ 'date' => '2026-06-01', 'reference' => 'INV-2026-001', 'lines' => [ [ 'account_id' => 3, // Accounts Receivable (Receivable type) 'type' => 'debit', 'amount' => 120000.00, 'due_date' => '2026-06-30', 'partnerable_type' => 'App\Models\Customer', 'partnerable_id' => 105, 'description' => 'Sales invoice for Customer #105' ], [ 'account_id' => 20, // Sales Revenue 'type' => 'credit', 'amount' => 120000.00 ] ] ]); $entry->post();
Generating Ageing Reports
Fetch the ageing analysis report for all partners. The engine allocates credits against debits using a FIFO (First In, First Out) algorithm and classifies outstanding amounts into age buckets based on the due_date:
By default, the engine tracks Accounts Receivable using code 1200 (or accounts with subtype receivable) and Accounts Payable using code 2000 (or accounts with subtype payable). You can customize these codes in config/accounting.php (ar_account_codes and ap_account_codes) or pass specific account IDs directly.
use Nml\FinCore\Facades\FinCore; // 1. Get Accounts Receivable Ageing Report (Default configured AR accounts) $arReport = FinCore::getReceivablesAgeingReport('2026-06-30'); // 2. Get Accounts Payable Ageing Report (Default configured AP accounts) $apReport = FinCore::getPayablesAgeingReport('2026-06-30'); // 3. (Optional) Filter by specific Account IDs $customArReport = FinCore::getReceivablesAgeingReport('2026-06-30', [$arAccountId]); $customApReport = FinCore::getPayablesAgeingReport('2026-06-30', [$apAccountId]); foreach ($arReport as $row) { $partnerName = $row['partner_name']; $totalOutstanding = $row['total_outstanding']; $current = $row['current']; // Not due yet $bucket1 = $row['1_30']; // 1 - 30 days overdue $bucket2 = $row['31_60']; // 31 - 60 days overdue $bucket3 = $row['61_90']; // 61 - 90 days overdue $bucket4 = $row['91_plus']; // 91+ days overdue }
13. Fiscal Year-End Closing Engine
Closing a Fiscal Year
At the end of a fiscal year, close the year by calling closeFiscalYear. The engine automatically:
- Calculates the net balance of all Revenue and Expense accounts for that year.
- Generates and posts a closing journal entry on the last day of the fiscal year, transferring the net profit or loss to the specified Retained Earnings account.
- Sets the balance of all Revenue and Expense accounts to zero relative to the next period.
- Marks the
FiscalYearand all its monthlyFiscalPeriods as closed, locking them against future postings or modifications.
use Nml\FinCore\Facades\FinCore; // Close Fiscal Year ID 1, transferring net profit/loss to Retained Earnings Account ID 8 $closingEntry = FinCore::closeFiscalYear( fiscalYearId: 1, retainedEarningsAccountId: 8, userId: 1 // Optional user ID of the approver );
14. Audit Trail / Activity Log
Retrieving Audit History
The engine automatically logs all creations, updates, state changes (submitted, posted, voided), and deletions of journal entries. This includes capturing the actor's user ID, IP address, user agent, action type, and a JSON diff of modified attributes (old_values vs new_values).
Retrieve the history for any journal entry:
use Nml\FinCore\Facades\FinCore; $logs = FinCore::getAuditHistory($journalEntryId); foreach ($logs as $log) { Log::info("Action: " . $log->action); Log::info("Performed by User: " . $log->user_id); Log::info("IP: " . $log->ip_address); Log::info("User Agent: " . $log->user_agent); Log::info("Old State: ", (array) $log->old_values); Log::info("New State: ", (array) $log->new_values); }
15. Running Tests
Run the package test suite using PHPUnit:
./vendor/bin/phpunit
