omnibase / ledger
Double-entry accounting for omnibase: books, a French chart of accounts, journals, parties and lettering, bank feeds through glitchr/omnibank, reconciliation with ranked suggestions, trial balance, general ledger, income statement, balance sheet and the FEC export
Package info
github.com/glitchr-studio/omnibase-ledger
Type:symfony-bundle
pkg:composer/omnibase/ledger
Requires
- php: >=8.2
- ext-sodium: *
- doctrine/orm: ^2.15|^3.0
- glitchr/omnibank: 1.x-dev
- glitchr/omnibase: 3.0.*-dev
- omnibase/admin: dev-main
- symfony/config: ^6.0|^7.0|^8.0
- symfony/console: ^6.0|^7.0|^8.0
- symfony/dependency-injection: ^6.0|^7.0|^8.0
- symfony/doctrine-bridge: ^6.0|^7.0|^8.0
- symfony/http-kernel: ^6.0|^7.0|^8.0
- symfony/security-bundle: ^6.0|^7.0|^8.0
- symfony/validator: ^6.0|^7.0|^8.0
Requires (Dev)
- phpunit/phpunit: ^10.5|^11.0|^12.0
- symfony/framework-bundle: ^6.0|^7.0|^8.0
- symfony/twig-bundle: ^6.0|^7.0|^8.0
Suggests
- omnibank/bridge: Bank aggregation through Bridge (consent pages, webhooks)
- omnibank/files: Statement files (CAMT.053, OFX, CSV): configure it as the "files" gateway to upload statements
- omnibank/powens: Bank aggregation through Powens (consent pages, webhooks)
- omnibank/qonto: Qonto accounts synced through its API
- symfony/lock: Entry numbering one process at a time per book (LockFactory), on top of the unique key
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-09 16:04:01 UTC
README
Double-entry accounting for omnibase applications, with bank feeds through glitchr/omnibank and bank reconciliation. It was written for French real-estate companies (SCI): rent notices, tenants' payments, supplier invoices, a loan, card payments through Stripe.
-
Books. One per legal entity. Each has its chart of accounts (PCG), its journals (BQ, VT, AC, OD, AN), its fiscal years, and a lock date after which nothing changes.
-
Posting.
- Entries come in through
Posting::post(), which refuses an unbalanced entry, a negative or two-sided line, an unknown account or journal, and a date in the locked period. - Posting the same
sourcetwice returns the first entry. - A validated entry is numbered per fiscal year (
2026-000042) and frozen. A Doctrine listener refuses any change to it except lettering. A wrong entry is corrected withPosting::reverse().
- Entries come in through
-
Parties and lettering.
- Tenants, suppliers and associates each get an auxiliary account (
411U3,401EDF,455M1). - Lettering ties lines that cancel out, such as a rent notice and its payment. Open items are the lines that are not lettered yet.
- Tenants, suppliers and associates each get an auxiliary account (
-
Bank feeds.
- Omnibank's gateways import transactions: statement files (
files: CAMT.053, OFX, CSV), Qonto, Powens and Bridge. - Each transaction is imported once.
- A connection's state (tokens) is sealed with sodium's secretbox, under a key derived from the kernel secret.
- Omnibank's gateways import transactions: statement files (
-
Reconciliation.
Matcherranks what a bank line may settle:- a reference found in the label
- the counterparty's IBAN
- a party's name
- several open items of one party that add up to the amount
- a reconciliation rule
Reconcilerthen posts the line in the bank journal, letters it and marks it reconciled. A line is applied automatically only when exactly one suggestion is sure (score ≥ 0.9). -
Reports. Trial balance, general ledger, open items, the FEC (fichier des écritures comptables), and the two statements an SCI's associates and accountant read:
- the compte de résultat over a period: rents, recovered charges, other income; rental charges, repairs, insurance, fees, taxes (taxe foncière), loan interest, depreciation, bank fees, other expenses - each account under the longest PCG prefix it matches, the rest in "Autres". With N-1 beside it when asked.
- the bilan at a date: net fixed assets, receivables, other receivables, cash; equity (with the year's result, and earlier results not yet closed), loans, tenants' deposits, suppliers, associates' current accounts, other debts. Class 4 and 5 accounts go by their balance (a tenant in credit is a debt). Actif equals passif.
Every report counts draft entries as well as validated ones: they say what the books say today. The statements print without the admin's chrome and download as CSV (
?format=csv). -
Back office (omnibase/admin):
- CRUD screens for every entity. A record is created from its "new" form (books, journals, accounts, fiscal years, parties, reconciliation rules); its book - and a journal's code, an account's number - are chosen then and only shown on "edit". What the database would refuse is said by the form: a second journal of the same code in a book, a second account of the same number, a fiscal year that ends before it starts, a party's reference already taken. A party's auxiliary account left empty is numbered from its kind and reference (411…, 401…)
- the reconciliation page, statement upload, bank connection
- the reports
- two dashboard widgets
Requirements
- PHP 8.2+,
ext-sodium glitchr/omnibase3.x,omnibase/adminglitchr/omnibank1.x with the gateways you use (omnibank/files,omnibank/qonto,omnibank/powens,omnibank/bridge). Without it, the ledger, lettering and reports still work, and the bank screens say Omnibank is missing.
Installation
composer require omnibase/ledger:dev-main glitchr/omnibank:1.x-dev omnibank/files:1.x-dev
// config/bundles.php Omnibank\Bridge\Symfony\OmnibankBundle::class => ['all' => true], Base\Ledger\LedgerBundle::class => ['all' => true],
# config/routes.yaml ledger_controller: resource: "@LedgerBundle/src/Controller" type: attribute prefix: /
# config/packages/ledger.yaml: every key is optional ledger: auto_accounts: false # posting to an unknown 7061 creates it under 706 chart: sci # config/charts/sci.php, what Chart::seed() uses admin_role: ROLE_ADMIN # views and manages every book (LEDGER_VIEW, LEDGER_MANAGE) secret: '%kernel.secret%' # seals the bank connections; changing it means connecting again reconciliation: auto_threshold: 0.9 # one suggestion at least this sure: applied without asking max_group_items: 12 # a grouped payment is searched among this many open items bank: sync_overlap_days: 5 # each sync asks again for the days before the last one
# config/packages/omnibank.yaml: the gateway names the ledger knows omnibank: gateways: files: { factory: files } # statement upload (required for it) qonto: { factory: qonto, options: { ... } } # see each package's README powens: { factory: powens, options: { ... } } bridge: { factory: bridge, options: { ... } }
Then generate the migration. The bundle ships none: its tables are ledger_*.
bin/console make:migration && bin/console doctrine:migrations:migrate bin/console ledger:chart:seed "<book id, SIREN or name>"
Dashboard widgets
// App\Controller\Admin\DashboardController::configureWidgetItems() yield MenuItem::block('ledger_bank', 'Banque', 'fa-solid fa-building-columns'); yield MenuItem::block('ledger_books', 'Comptabilité', 'fa-solid fa-book');
ledger_bankshows:- each bank account with its balance in the books (512x) and at the bank
- its last sync and its consent, with a "Renouveler" link when the bank asks
- its lines to reconcile, linking to the reconciliation page
- the six latest lines
- with no account yet: "Connecter une banque" for each configured gateway, and "Importer un relevé".
ledger_booksshows each book's current fiscal year to date: income (7), expenses (6), result (the compte de résultat's totals), open receivables (411), open payables (401) and cash (512). Each tile opens its report, and the book links to its compte de résultat, bilan and balance.
Who sees a book
ledger.admin_role sees and manages every book. To grant more, implement Base\Ledger\Security\BookAccessInterface. Such services are autoconfigured: any of them saying yes is enough.
final class AssociateBookAccess implements BookAccessInterface { public function canView(UserInterface $user, Book $book): bool { /* an associé of its company */ } public function canManage(UserInterface $user, Book $book): bool { return false; } }
Use
// A rent notice: the tenant owes 850 €. $tenant = $parties->partyFor($book, 'tenant', 'U3', 'Jean Dupont', 'FR76…'); $posting->post((new EntryDraft($book, 'VT', $date, 'Loyer octobre U3', 'notice:2026-10-U3', 'AVIS-2026-10-U3')) ->line($tenant->getAccountNumber(), 85000, 0, $tenant) ->line('706', 0, 85000)); // The bank feed: import, then reconcile what is sure. $sync->sync($bankAccount); // or $upload->upload($bankAccount, $name, $content) $reconciler->autoReconcile($bankAccount);
The transfer carrying "AVIS-2026-10-U3", or coming from Jean Dupont's IBAN, is then posted as 512 / 411U3 in BQ and lettered with the notice.
Stripe goes through the 5112 clearing account:
- The application posts each payment (5112 / 411, its piece the payment id) and its fee (6278 / 5112).
- The payout on the statement settles the 5112 open items, either by its reference or by a rule on 5112 ("STRIPE", money in).
- The payout is then posted 512 / 5112 and the clearing account is lettered.
A rule may split the amount: [{"account": "164", "amount": 85000}, {"account": "6611", "rest": true}] splits a loan instalment between capital and interest.
Public API
| Class | Methods |
|---|---|
Model\EntryDraft |
__construct(Book, string $journal, \DateTimeImmutable, string $label, string $source, ?string $piece), line(string $account, int $debit, int $credit, ?Party, ?string $label): self |
Service\Posting |
post(EntryDraft, bool $validate = true): Entry, reverse(Entry, string $source, ?\DateTimeImmutable): Entry, validate(Entry): Entry, assertOpen(Book, \DateTimeInterface) |
Service\Chart |
seed(Book, ?string $chart = null) |
Service\Parties |
partyFor(Book, PartyKind|string $kind, string $reference, string $name, ?string $iban): Party |
Service\Lettering |
letter(iterable $lines): string, unletter(string, Book): int, openItems(Book, ?Party, ?string $prefix = '411'): array |
Bank\Importer |
import(BankAccount, iterable<BankTransaction>): int |
Bank\Sync |
sync(BankAccount): int, syncConnection(BankConnection): int |
Bank\StatementUpload |
upload(BankAccount, string $name, string $content): int |
Bank\Connections |
start(), complete(), renew(), discover(), attach(), open(), store(), gateways() |
Reconciliation\Matcher |
suggest(BankLine, ?array $openItems): list<Suggestion>, openItems(Book) |
Reconciliation\Reconciler |
apply(BankLine, Suggestion): Entry, autoReconcile(BankAccount): int, ignore(BankLine), undo(BankLine) |
Report\TrialBalance, GeneralLedger, OpenItems |
compute(Book, …): array |
Report\IncomeStatement |
compute(Book, \DateTimeImmutable $from, \DateTimeImmutable $to, bool $withPrevious = false): array, fromTotals(array $totals, ?array $previous), yearBefore(\DateTimeImmutable) |
Report\BalanceSheet |
compute(Book, \DateTimeImmutable $at, bool $withPrevious = false): array, fromTotals(array $totals, ?array $yearTotals, ?array $previous, ?array $previousYear), place(string $number, int $balance) |
Export\FecWriter |
write(Book, FiscalYear): string, filename(Book, FiscalYear) |
Every refusal is a Base\Ledger\Exception\LedgerException. The subclasses are PostingException, LockedPeriodException, LockedEntryException, LetteringException, ReconciliationException and BankException.
Commands
| Command | What it does | Suggested cron |
|---|---|---|
ledger:chart:seed <book> [--chart=sci] |
add a chart's missing accounts and journals | once |
ledger:bank:sync [book] [--no-reconcile] |
import the API-fed accounts' new lines, reconcile what is sure | every few hours |
ledger:fec:export <book> <year> [--dir=.] [--stdout] |
write <SIREN>FEC<YYYYMMDD>.txt |
when the accountant asks |
Routes
| Path | Name |
|---|---|
/admin/ledger/reconcile/{bankAccount} |
ledger_admin_reconcile, plus _apply, _ignore, _undo, _auto (POST, CSRF) |
/admin/ledger/bank/{bankAccount}/upload |
ledger_admin_bank_upload |
/admin/ledger/bank/connect/{gateway}?book= |
ledger_admin_bank_connect; back on ledger_admin_bank_return |
/admin/ledger/bank/connection/{id}/renew, /admin/ledger/bank/{id}/sync |
ledger_admin_bank_renew, ledger_admin_bank_sync |
/admin/ledger/book/{book}/trial-balance, /general-ledger, /open-items |
ledger_admin_trial_balance, ledger_admin_general_ledger, ledger_admin_open_items |
/admin/ledger/book/{book}/income-statement?from=&to=&n1=0|1&format=csv |
ledger_admin_income_statement |
/admin/ledger/book/{book}/balance-sheet?at=&n1=0|1&format=csv |
ledger_admin_balance_sheet |
/admin/ledger/book/{book}/fec/{year} |
ledger_admin_fec |
/ledger/bank/{gateway}/webhook |
ledger_bank_webhook (public; the gateway checks the signature) |
Tests
The tests need no database. Entities are built in memory and repositories are stubs. The mapping is checked against a MySQL schema. The statement uploads go through Omnibank's real files gateway.
vendor/bin/phpunit # standalone, or from a host: vendor/bin/phpunit -c vendor/omnibase/ledger
Licence
MIT since 2026-10-09; earlier versions remain published under LGPL-3.0-or-later.