Double-entry accounting for the sites built on
omnisense/omnibase: books, a French chart of
accounts (PCG), journals, fiscal years, parties and lettering, bank feeds through
omnisense/omnibank, the reconciliation of
bank lines with ranked suggestions, and the reports a French accountant reads - trial
balance, general ledger, open items, compte de résultat, bilan, FEC. It is a Symfony bundle
(Base\Ledger\LedgerBundle) on the core 3 or 4, with its back office on omnibase/admin.
It was written for French real-estate companies (SCI): rent notices, tenants' payments, supplier invoices, a loan, card payments through Stripe. omnibase/estate posts its rent calls, payments and work invoices to it.
The rules it keeps:
- One way in. Entries are posted 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 - before writing anything. An entry carries asource(what it records); posting the same source twice returns the first entry. - Validated is final. A validated entry is numbered per fiscal year (
2026-000042) and frozen: a Doctrine listener refuses any change but its lines' lettering. A mistake is corrected by a reversal entry. - Amounts are integers. Every amount is in minor units (cents); a bank line is signed (negative when money leaves), an entry line has a debit or a credit, never negative.
- Reports read the books as they are. Draft entries count in every report but the FEC, which lists validated entries only.
Use it when an application must keep real books for one or several legal entities and match its bank statements against them. It is not an invoicing tool: invoices, rent calls or orders stay the application's, which posts what they mean.
How this manual is organised
- Installation: requirements, the bundle, Omnibank's gateways, the routes, the schema, the chart.
- Books, accounts and entries: every entity, its fields and invariants, the
enums,
EntryDraft. - Posting, parties and lettering:
Posting, numbering, reversals, the guard,Parties,Lettering,Chart. - Bank feeds: connections and consent, syncs, statement files, the importer, the webhook, the sealed state.
- Reconciliation: the matcher's ranks and scores, applying a suggestion, automatic reconciliation, undoing, rules and splits, the Stripe pattern.
- Reports and the FEC: every report's shape, the CSV downloads, the FEC.
- The back office: the screens, the pages, the dashboard widgets, who sees a book.
- Configuration reference:
ledger:, the parameters, the services. - Reference: the routes, the commands, the Twig filters, the event, the translations.
- Errors: the exceptions and when each is thrown.
- Testing: the suite, what it checks, testing an application that posts.
Proposer une modification docs/index.md
Installation
Requirements
What composer.json requires: PHP 8.2 or later, ext-sodium, doctrine/orm 2.15 or 3,
omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin,
omnisense/omnibank 1.x, and symfony/config, console, dependency-injection,
doctrine-bridge, http-kernel, security-bundle and validator 6, 7 or 8.
What it suggests:
| Package | For |
|---|---|
omnibank/files |
statement files (CAMT.053, OFX, CSV), configured as the files gateway |
omnibank/qonto |
Qonto accounts synced through its API |
omnibank/powens |
bank aggregation through Powens (consent pages, webhooks) |
omnibank/bridge |
bank aggregation through Bridge (consent pages, webhooks) |
symfony/lock |
entry numbering one process at a time per book, on top of the unique key |
The bundle
composer require omnibase/ledger:dev-main omnibank/files:1.x-dev
// config/bundles.php
return [
// ...
Base\BaseBundle::class => ['all' => true],
Base\Admin\AdminBundle::class => ['all' => true],
Omnibank\Bridge\Symfony\OmnibankBundle::class => ['all' => true],
Base\Ledger\LedgerBundle::class => ['all' => true],
];
LedgerBundle extends the core's AbstractBaseBundle: getPath() is the package's root
(its templates/ is @Ledger), and build() aliases every Base\Ledger\Entity\* to
App\Entity\Ledger\* and every Base\Ledger\Repository\* to App\Repository\Ledger\*
unless the application declares a class of that name - which is how an application extends a
ledger entity (a Book tied to its own company) without touching the bundle.
Omnibank's gateways
The bank feeds go through omnisense/omnibank's Omnibank\Registry, which its bundle
registers. Without the bundle the ledger, the lettering and the reports work; the bank
screens say Omnibank is missing and the bank services throw a BankException
(Bank\Connections is built with a null registry).
The ledger knows the gateways by their name in omnibank.gateways. files is the name the
statement upload asks for (StatementUpload::GATEWAY):
# config/packages/omnibank.yaml
omnibank:
gateways:
files: { factory: files }
qonto: { factory: qonto, options: { ... } }
powens: { factory: powens, options: { ... } }
bridge: { factory: bridge, options: { ... } }
Each provider's options are in its own manual (omnisense/omnibank).
The routes
# config/routes.yaml
ledger_controller:
resource: "@LedgerBundle/src/Controller"
type: attribute
prefix: /
This imports the back-office pages under /admin/ledger (reconciliation, bank, reports) and
the public webhook /ledger/bank/{gateway}/webhook. The CRUD screens' routes are generated
by omnibase/admin's base_admin loader. Every route is in the Reference.
Configuration
Every key is optional (Configuration reference):
# config/packages/ledger.yaml
ledger:
auto_accounts: false
chart: sci
admin_role: ROLE_ADMIN
secret: '%kernel.secret%'
reconciliation:
auto_threshold: 0.9
max_group_items: 12
bank:
sync_overlap_days: 5
The schema
The bundle ships no migration; its tables are ledger_* (ledger_book, ledger_account,
ledger_journal, ledger_fiscal_year, ledger_party, ledger_entry, ledger_entry_line,
ledger_bank_connection, ledger_bank_account, ledger_bank_line,
ledger_reconciliation_rule):
bin/console make:migration
bin/console doctrine:migrations:migrate
A first book
Create the book in the back office (Books > New), or in PHP (new Book('SCI Les Tilleuls', '123456789'), persisted), then give it the chart's accounts and journals:
bin/console ledger:chart:seed "SCI Les Tilleuls"
The book is named by its id, its SIREN or its exact name. config/charts/sci.php holds the
PCG accounts an SCI uses (capital and reserves, loans and deposits, buildings and their
depreciation, the collective accounts 401, 411, 455, 467, the VAT accounts, the bank 512 and the card clearing
account 5112, the expenses and the rents) and the journals BQ (bank), VT (sales: rent
notices), AC (purchases), OD (miscellaneous) and AN (opening balances).
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');
What each shows is in The back office.
A scheduled sync
0 */4 * * * bin/console ledger:bank:sync
imports the API-fed accounts' new lines and reconciles what is sure; statement-file accounts are skipped (Bank feeds).
Proposer une modification docs/installation.md
Books, accounts and entries
Every entity hangs off a Book; every relation to a book is onDelete: CASCADE at the
database. Amounts are integers in minor units. Dates are date_immutable columns set to
midnight.
Book
One legal entity's accounts (ledger_book).
| Property | Column | Meaning |
|---|---|---|
name |
string(255), not blank |
the entity's name |
siren |
string(9), nullable |
the SIREN; setSiren() keeps the digits only, an empty value is null. The FEC's file name starts with it |
currency |
string(3) |
EUR by default, upper-cased |
lockedUntil |
date, nullable |
nothing dated on or before it can be posted, changed or reversed |
createdAt |
datetime |
isLocked(\DateTimeInterface $date) compares days. getBook() returns the book itself, so
the voter treats a book like anything of a book.
Account
An account of a book's chart, by its PCG number (ledger_account, unique on book and number).
| Property | Column | Meaning |
|---|---|---|
number |
string(20) |
upper-cased, without spaces (Account::normalize(): "411 u3" is 411U3) |
label |
string(255) |
|
type |
AccountType |
given, or guessed from the number (AccountType::guess()) |
getGeneralNumber() is the leading digits (411 for 411U3): the general account the FEC
files an auxiliary account under. isUnder(string $prefix) tells whether the number starts
with the prefix.
Journal
ledger_journal, unique on book and code. code (string(8), upper-cased) and label.
The constants name the usual codes: Journal::BANK (BQ), SALES (VT), PURCHASES
(AC), MISCELLANEOUS (OD), OPENING (AN).
FiscalYear
An accounting period (ledger_fiscal_year, unique on book and start; columns starts_on,
ends_on, closedAt). The constructor refuses an end before the start; the back office's
form says so instead (validatePeriod()). FiscalYear::calendar($book, 2026) is the unsaved
calendar year a book without fiscal years works on. contains($date), isClosed().
__toString() is 2026, or 2025-2026 for a year across two calendar years.
A closed fiscal year takes no entry. Entry numbers run per fiscal year, prefixed with the year it ends in.
Party
Someone the book owes or is owed by (ledger_party, unique on book and reference, indexed on
IBAN).
| Property | Column | Meaning |
|---|---|---|
kind |
PartyKind |
tenant, supplier, associate, other |
name |
string(255) |
|
iban |
string(34), nullable |
normalised (upper-case, alphanumeric): how the party's transfers are recognised |
reference |
string(64), nullable |
the application's handle on them (a lease, a supplier code), unique in the book |
accountNumber |
string(20) |
its auxiliary account (411U3); an Account of that number exists in the book |
Entry and EntryLine
An Entry (ledger_entry) is balanced lines on one date in one journal.
| Property | Column | Meaning |
|---|---|---|
journal |
relation | |
date |
date |
|
label |
string(255) |
cut to 255 characters |
piece |
string(64), nullable |
the supporting document's reference (an invoice number, AVIS-2026-10-U3): the matcher looks for it in bank labels |
source |
string(191) |
what it records, unique per book (notice:2026-10-U3, bank:42) |
number |
string(32), nullable |
given at validation (2026-000042), unique per book |
validatedAt |
datetime, nullable |
set at validation |
reversalOf |
relation, nullable | the entry it reverses |
lines |
one-to-many, cascade persist, by id |
isValidated(), getTotalDebit(), getTotalCredit(), isBalanced(). validate(string $number) is called by Posting only; a second call is a LogicException.
An EntryLine (ledger_entry_line, indexed on letter):
| Property | Column | Meaning |
|---|---|---|
account |
relation | |
party |
relation, nullable, SET NULL on delete |
|
debit, credit |
integer |
never negative; one of them is zero |
label |
string(255), nullable |
the line's own label, else the entry's |
letter |
string(8), nullable |
the lettering code (AAA) |
letteredOn |
date, nullable |
getNet() is debit minus credit: positive when the line is owed to the book (a rent notice
on 411). An open item is an unlettered line.
Bank entities
BankConnection (ledger_bank_connection): a book's access to a bank through one gateway.
| Property | Column | Meaning |
|---|---|---|
gateway |
string(32) |
the gateway's name in omnibank.gateways |
name |
string(255), nullable |
|
state |
text, nullable |
the gateway's Connection, sealed (Bank feeds) |
consentStatus |
ConsentState |
NONE at first |
expiresAt |
datetime, nullable |
when the consent ends |
lastError |
text, nullable |
the last error a sync met, cut to 2000 characters |
accounts |
one-to-many BankAccount |
needsRenewal(?$now) is true when the consent is NEEDS_RENEWAL or REVOKED, or
expiresAt is past.
BankAccount (ledger_bank_account, unique on connection and external id): one account at
the bank - externalId (the gateway's id), iban (normalised), name, currency, its
ledger account (512x) and journal (BQ, BQ2...), lastSyncedAt, and the bank's own
bankBalance (bigint, minor units) with bankBalanceAt. BankAccount::mask($iban) shows
FR76 •••• 4321.
BankLine (ledger_bank_line, unique on bank account and external id, indexed on account
and status): a transaction as the bank booked it.
| Property | Column | Meaning |
|---|---|---|
externalId |
string(191) |
the transaction's id at the gateway |
bookedOn, valueOn |
date |
|
amount |
integer |
signed: negative when money leaves |
currency |
string(3) |
|
label |
string(512) |
white space collapsed |
counterpartyName, counterpartyIban, reference |
nullable | what the bank gives |
raw |
json |
the gateway's raw transaction |
status |
BankLineStatus |
UNMATCHED at first |
entry |
relation, nullable | its bank entry once reconciled |
reconciliations |
integer |
how many times it was reconciled |
getSource() is the source of its next bank entry: bank:<id>, then bank:<id>/2 after an
undo, and so on.
ReconciliationRule
What to post a recurring bank line against when nothing open matches it
(ledger_reconciliation_rule).
| Property | Column | Meaning |
|---|---|---|
name |
string(255) |
|
pattern |
string(255), nullable |
words that must all appear in the label (accents and punctuation ignored), or a /regular expression/ (flags iu added when it ends with /) |
iban |
string(34), nullable |
the counterparty's IBAN, when set it must match |
sign |
RuleSign |
ANY, IN (money in), OUT (money out) |
account |
relation | the account the line is posted against |
party |
relation, nullable | |
priority |
integer |
the highest first |
split |
json, nullable |
the amount spread over several accounts (Reconciliation) |
enabled |
boolean |
true |
matches(string $label, ?string $iban, int $amount): enabled, the sign accepts the amount,
the IBAN agrees when the rule has one; an empty pattern then matches only when the rule has
an IBAN.
Enums
| Enum | Cases | Methods |
|---|---|---|
AccountType |
ASSET asset, LIABILITY liability, EQUITY equity, INCOME income, EXPENSE expense |
isDebitNormal() (asset, expense), isResult() (income, expense), guess(string $number) |
BankLineStatus |
UNMATCHED, SUGGESTED, RECONCILED, IGNORED |
isPending() (unmatched or suggested) |
ConsentState |
ACTIVE, NEEDS_RENEWAL, REVOKED, NONE |
of(): from Omnibank's ConsentStatus (or a string), by value or name, NONE otherwise |
PartyKind |
TENANT, SUPPLIER, ASSOCIATE, OTHER |
collective(): 411, 401, 455, 467 |
RuleSign |
ANY, IN, OUT |
accepts(int $amount) (ANY refuses 0) |
AccountType::guess() follows the PCG's classes: 10-14 equity, the rest of class 1
liability; classes 2, 3, 5 asset; 6 expense; 7 income; class 4 by its longest prefix (40
liability but 409 asset, 41 asset but 419 liability, 42, 43 liability, 44
liability but 441, 4456, 4458 asset, 45 liability but 456 asset, 46, 47, 48,
49 asset, 487 liability).
EntryDraft
Base\Ledger\Model\EntryDraft is an entry as the application describes it, with account
numbers and a journal code rather than entities:
new EntryDraft(Book $book, string $journal, \DateTimeImmutable $date, string $label, string $source, ?string $piece = null)
line(string $account, int $debit, int $credit, ?Party $party = null, ?string $label = null): self
adds a line; getLines(), getTotalDebit(), getTotalCredit().
Text
How bank labels, names and references are compared (Base\Ledger\Model\Text): fold()
upper-cases, removes accents and turns every other character into a space;
squash() removes the spaces too, so references survive the bank's spacing.
Text::fold("Loyer d'Octobre - Dupont") // LOYER D OCTOBRE DUPONT
Text::squash('AVIS-2026-10/U3') // AVIS202610U3
Proposer une modification docs/concepts.md
Posting, parties and lettering
Posting an entry
Base\Ledger\Service\Posting is the one way entries get into a book.
use Base\Ledger\Model\EntryDraft;
use Base\Ledger\Service\Parties;
use Base\Ledger\Service\Posting;
// A rent notice: the tenant owes 850 €.
$tenant = $parties->partyFor($book, 'tenant', 'U3', 'Jean Dupont', 'FR76 3000 6000 0112 3456 7890 189');
$entry = $posting->post(
(new EntryDraft($book, 'VT', new \DateTimeImmutable('2026-10-01'), 'Loyer octobre U3', 'notice:2026-10-U3', 'AVIS-2026-10-U3'))
->line($tenant->getAccountNumber(), 85000, 0, $tenant)
->line('706', 0, 85000)
);
$entry->getNumber(); // "2026-000001": validated, numbered and frozen
post(EntryDraft $draft, bool $validate = true): Entry:
- An entry of the same book and
sourcealready there is returned as it is: posting is idempotent. - The draft is checked: two lines at least, a non-blank source, no negative amount, no line
both debit and credit, no line without an amount, debits equal to credits - else a
PostingExceptionnaming the line. - The date is checked (
assertOpen()): on or before the book'slockedUntil, or in a closed fiscal year, is aLockedPeriodException. - The journal must exist in the book (by code); a party must be of the same book.
- Every account is resolved by its normalised number. An unknown number is a
PostingExceptionunlessledger.auto_accountsis on: the account is then created under the longest existing account its number starts with (4011under401), with that parent's type and the line's party name, label, or the parent's label - and aPostingExceptionwhen there is no such parent. - The entry is persisted; with
$validate, it is numbered and flushed under a lock.
Nothing is written when a check fails.
Numbering
nextNumber(Book $book, \DateTimeInterface $date) gives <year>-<six digits>: the year the
fiscal year containing the date ends in (the calendar year without one), then the last number
with that prefix plus one. With symfony/lock installed, numbering and its flush run under the
lock ledger-number-<book id>, one process at a time per book; the unique key
(ledger_entry_number) stands behind it either way.
Drafts and validation
post($draft, false) leaves the entry a draft: no number, editable in the back office (its
date, label and piece), deletable. validate(Entry $entry) numbers and freezes it - refused
(PostingException) when it is unbalanced or has fewer than two lines, and in a locked
period. A validated entry is returned unchanged.
Reversing
reverse(Entry $entry, string $source, ?\DateTimeImmutable $date = null): Entry posts the
mirror image (debits and credits swapped, the same accounts, parties and labels) in the same
journal, labelled Extourne <number> <label>, dated $date or the entry's own date, with
reversalOf set, validated. The same $source twice returns the reversal already posted. A
draft is not reversed (change or delete it); an entry already reversed is refused.
The guard
Base\Ledger\EventListener\ValidatedEntryGuard (a Doctrine onFlush listener) refuses, with
a LockedEntryException, any flush that:
- changes a validated entry, or deletes it;
- changes a line of a validated entry other than its
letterandletteredOn, deletes one, or adds one.
An entry inserted or validated in the same flush is not yet frozen. The guard applies to every writer - the back office, a fixture, a service.
Parties
Base\Ledger\Service\Parties::partyFor(Book $book, PartyKind|string $kind, string $reference, string $name, ?string $iban = null): Party
finds a party by the application's reference, or creates it:
- Found, its name and IBAN are updated to what the application says now (and flushed when they changed).
- Created, its auxiliary account is the kind's collective account followed by the reference,
upper-cased and alphanumeric (
411U3;Xfor an empty reference), cut to 17 characters and made unique in the book with a suffix (411U32). TheAccountis created when the book has none of that number, with the collective account's type.
A party created in the request is found again before the flush (kept in memory by book and reference).
Lettering
Base\Ledger\Service\Lettering ties lines that cancel out:
$letter = $lettering->letter([$noticeLine, $paymentLine]); // "AAA"
letter(iterable $lines, ?\DateTimeImmutable $on = null): string refuses
(LetteringException) fewer than two lines, a line already lettered, lines of different
books, accounts or parties, and lines whose debits and credits differ. The letter is the
book's next: AAA, AAB... AAZ, ABA... ZZZ, AAAA (Lettering::increment()); the
lines' letteredOn is $on or today.
unletter(string $letter, Book $book): int takes a letter off every line carrying it and
returns how many. openItems(Book $book, ?Party $party = null, ?string $accountPrefix = '411')
returns the unlettered lines under the prefix (all accounts with null), oldest first.
A partial payment stays open beside its notice until the rest arrives: the lines are lettered together once their debits and credits balance.
The chart
Base\Ledger\Service\Chart::seed(Book $book, ?string $chart = null) adds to the book the
accounts and journals of config/charts/<chart>.php that it does not have, and leaves the
others alone - it can run again after the chart grows. The chart is ledger.chart (sci)
when none is named. definition(string $chart) returns the file's array; a name outside
[a-z0-9_-] or a missing file is an InvalidArgumentException.
A chart file returns:
use Base\Ledger\Enum\AccountType as T;
return [
'accounts' => [
'101' => ['Capital social', T::EQUITY],
'411' => ['Locataires', T::ASSET],
// number => [label, type]
],
'journals' => [
'BQ' => 'Banque',
// code => label
],
];
Chart reads the bundle's config/charts/ unless built with another $chartsDir
(a service definition argument).
In memory
The entities and the reports' static parts need no database. Run in omnisense/omnibase's
harness image, with this checkout's src/ on the autoloader:
use Base\Ledger\Entity\Account;
use Base\Ledger\Entity\Book;
use Base\Ledger\Entity\ReconciliationRule;
use Base\Ledger\Enum\AccountType;
use Base\Ledger\Enum\RuleSign;
use Base\Ledger\Export\FecWriter;
use Base\Ledger\Model\Text;
use Base\Ledger\Service\Lettering;
$book = new Book('SCI Les Tilleuls', '123 456 789');
echo $book->getSiren(), ' ', AccountType::guess('411U3')->value, ' ', AccountType::guess('401EDF')->value, "\n";
$rule = new ReconciliationRule($book, 'Frais bancaires', 'FRAIS TENUE COMPTE', new Account($book, '627', 'Services bancaires'), RuleSign::OUT);
var_dump($rule->matches('Frais de tenue de compte - octobre', null, -850));
var_dump($rule->matches('FRAIS TENUE COMPTE', null, 850));
echo FecWriter::amount(123456), ' ', FecWriter::amount(-5), "\n";
echo Lettering::increment('AAZ'), ' ', Lettering::increment('ZZZ'), "\n";
echo Text::fold("Loyer d'Octobre - Dupont"), ' | ', Text::squash('AVIS-2026-10/U3'), "\n";
123456789 asset liability
bool(true)
bool(false)
1234,56 -0,05
ABA AAAA
LOYER D OCTOBRE DUPONT | AVIS202610U3
Proposer une modification docs/usage-posting.md
Bank feeds
A book reaches its banks through omnisense/omnibank: each BankConnection names one gateway
of omnibank.gateways, keeps that gateway's Connection sealed, and holds the
BankAccounts it gives access to. Every bank service goes through
Base\Ledger\Bank\Connections, which holds the Omnibank\Registry (or null without
Omnibank's bundle: isAvailable() false, gateways() empty, gateway() throws a
BankException).
Connecting a bank
use Base\Ledger\Bank\Connections;
['connection' => $connection, 'url' => $url] = $connections->start(
$book,
'powens',
fn ($connection) => $router->generate('ledger_admin_bank_return', ['connection' => $connection->getId()], UrlGeneratorInterface::ABSOLUTE_URL),
'Powens',
);
// $url: the bank's (or aggregator's) consent page; null when there is none.
start(Book $book, string $gateway, string|callable $returnUrl, ?string $name = null) saves a
new connection, then asks the gateway to connect(). The return URL may be a callable given
the saved connection. When the gateway answers with a page, the user is sent there; back on
the return URL, complete(BankConnection $connection, string $returnUrl, array $query = [])
hands the query the provider added to the gateway (state return) and asks connect() again
- either the next page, or
nullwhen the connection is done. Done, its accounts are discovered.
What a gateway throws (other than a BankException) is recorded in the connection's
lastError and rethrown as a BankException. After each step the gateway's Connection is
sealed back into the entity with its consent status and expiry (store()).
renew(BankConnection $connection, string $returnUrl): ?string goes through the bank's page
again (an expired consent, a new password): the page to go to, or null when nothing is
needed.
Accounts
discover(BankConnection $connection): int asks the gateway for the connection's accounts
and attaches those the book does not have yet. attach() gives each bank account its ledger
account and journal by rank in the book: the first posts on 5121 in BQ, the second on
5122 in BQ2, and so on, each created (labelled Banque - <name>) when missing. An account
without a name is Compte <masked IBAN>, or Compte bancaire.
Statement files
The files gateway has no consent: the back office's connect page asks the account's name
and IBAN, starts a files connection and attaches one account with a random external id
(files-<12 hex>) in the book's currency.
Importing transactions
Base\Ledger\Bank\Importer::import(BankAccount $account, iterable $transactions): int turns
Omnibank BankTransactions into BankLines and returns how many are new. A transaction is
imported once (its id is unique per bank account; duplicates in one batch are skipped). A line
already there is updated while it is pending (unmatched or suggested) - banks correct pending
lines: date, amount, label, value date, counterparty, reference, raw data - and left alone once
reconciled or ignored.
Syncing an account
Base\Ledger\Bank\Sync::sync(BankAccount $account, ?\DateTimeImmutable $until = null): int:
- A connection that needs renewal (its status, or its expiry), or whose Omnibank consent is
not active, is marked
NEEDS_RENEWAL(orREVOKED, when the bank revoked it) with the message "The bank wants its consent given again." - and the sync stops with aBankException. - The transactions are asked since the last sync minus
ledger.bank.sync_overlap_daysdays (5), from midnight; with no start date on the first sync. Lines already imported are matched on their id. - When the gateway supports
FetchBalances, the last booked balance (any balance when none is booked) becomes the account'sbankBalance. lastSyncedAtis set andlastErrorcleared.
A failure is recorded in lastError; one whose message speaks of consent, expiry,
re-authentication, SCA, 401 or 403 marks the connection NEEDS_RENEWAL. The bank no longer
listing the account is a BankException.
syncConnection(BankConnection $connection): int syncs every account of a connection.
Uploading a statement
Base\Ledger\Bank\StatementUpload::upload(BankAccount $account, string $name, string $content): int
reads a CAMT.053, OFX or CSV file through the files gateway and imports its lines:
- An empty file, or no
filesgateway, is aBankException. - A CSV (by its
.csvname, or content that starts neither with<norOFXHEADER) says nothing of its account: it is given this account's id, IBAN, name and currency. - The statement's account must be this one - same IBAN or same id; a single account without an IBAN on either side is taken - else "<file> is not a statement of <account>".
- The closing balance, when the statement has one, becomes the account's bank balance unless a later one is already kept.
- Overlapping statements import each line once. The file itself is not stored.
The webhook
POST /ledger/bank/{gateway}/webhook (ledger_bank_webhook, public): the aggregators
(Powens, Bridge) say a connection changed.
| Case | Answer |
|---|---|
| the gateway is not configured | 404 |
the gateway's notify() refuses the payload (InvalidNotificationException: a bad signature) |
400, with its message |
| no connection is found for it | 202 |
| handled | 204 |
The connection is the one of that gateway whose sealed state holds the provider's connection
id, or the only one of that gateway (Connections::findByProviderId()). A notification
carrying a consent updates the connection's status and expiry and the sealed state; then,
unless the connection needs renewal, each account is synced and reconciled automatically
(errors stay in lastError; the provider gets its 2xx).
The sealed state
Base\Ledger\Security\StateCipher seals a connection's state (the provider's tokens, its
user) with sodium's secretbox (XSalsa20-Poly1305), under the SHA-256 of
omnibase/ledger bank connection|<ledger.secret>; the result is v1: and the base64 of the
nonce and the ciphertext. Each seal uses a new nonce. A state sealed under another secret, or
altered, does not open (BankException: "Connect the bank again."). An empty secret is an
InvalidArgumentException when the service is built.
ledger.secretdefaults to%kernel.secret%. Changing either makes every stored connection unreadable: each bank must be connected again.
Proposer une modification docs/usage-bank.md
Reconciliation
A bank line is reconciled when it is posted in its bank account's journal and lettered with
what it settles. Base\Ledger\Reconciliation\Matcher says what a line may settle, ranked;
Base\Ledger\Reconciliation\Reconciler posts it.
The open items
Matcher::openItems(Book $book) is what a bank line can settle: the unlettered lines of the
third-party accounts (class 4) and of the clearing accounts (511).
Suggestions
Matcher::suggest(BankLine $line, ?array $openItems = null): list<Suggestion> returns the
suggestions best first (a line of amount 0 has none). The line's label, reference and
counterparty name are folded (Text::fold()) and compared with the open items, by rank of
trust:
| Rank | What is found | Score |
|---|---|---|
| 1 | an open item's piece (4 characters at least, squashed) in the label or reference, the amounts agreeing | 0.99 |
| 1 | the piece found, a different amount | 0.6, with no items |
| 2 | the counterparty's IBAN is a party's, one open item of exactly the amount | 0.95 |
| 2 | the same, several items of that amount: the oldest | 0.93 |
| 2 | the same, all the party's items adding up to it | 0.94 |
| 2 | the same, some of them adding up to it | 0.92 |
| 2 | the IBAN is a party's, nothing of that amount (a payment on account) | 0.5 |
| 3 | a party's name in the label, one item of exactly the amount | 0.9 |
| 3 | the same, several of that amount: the oldest | 0.88 |
| 3 | the same, all its items | 0.89 |
| 3 | the same, some of them | 0.85 |
| 4 | nobody recognised, one open item of exactly the amount | 0.75 |
| 4 | nobody recognised, one party's items adding up to it | 0.7 |
| 5 | the first matching rule by priority, with its account's open items adding up to it | 0.92 |
| 5 | the first matching rule | 0.9 |
| 5 | another matching rule | 0.5 |
Ambiguity lowers the scores: the same amount owed by several parties with nothing else to
tell them apart gives 0.5 each, several groups 0.5 each, two parties recognised the same
way (IBAN or name) both with items 0.6 at most. A party's name is recognised when every
word of it of 3 letters or more is in the label, one of them 4 letters or more. Only items on
the amount's side are considered: owed to the book for money coming in, owed by it for money
going out.
"Adding up" is a subset search among a holder's first ledger.reconciliation.max_group_items
open items (12), oldest first, at least two of them, with a search budget of 20,000 steps.
Two ways to the same items are one suggestion, the best score kept.
Suggestion
Base\Ledger\Reconciliation\Suggestion (readonly): score (0 to 1), reason (French, shown
in the back office), openItems, rule, party. total() is what the open items add up to
(debit minus credit); settles(int $amount) whether they settle the amount exactly;
withScore() a copy with another score.
Applying a suggestion
Reconciler::apply(BankLine $line, Suggestion $suggestion): Entry posts the line in its bank
account's journal, dated as the bank booked it, with the line's label, the source
$line->getSource() (bank:<id>), and the first open item's piece (or the line's reference)
as piece. The bank account's line (512x) is debited for money in, credited for money out;
against it:
- The items settle the amount: one line per account and party, for what their items add up to; each is then lettered with its items. A holder whose items cancel out among themselves is lettered without a line.
- Items that do not settle it (a partial payment): one line on the first item's account and party, not lettered - it will be when the rest arrives.
- A rule: its split (below), the rule's party on the lines of the rule's own account.
- A party: its auxiliary account (a payment on account).
- Nothing of these: a
ReconciliationException.
The line becomes RECONCILED with its entry, its reconciliation count goes up, and
Base\Ledger\Event\BankLineReconciled is dispatched (with the settled items, or none). A line
that is not pending, or of amount 0, is refused.
Automatic reconciliation
autoReconcile(BankAccount $account): int goes through the account's pending lines, oldest
first: a line with exactly one suggestion at or above ledger.reconciliation.auto_threshold
(0.9) is applied; the others become SUGGESTED (suggestions exist) or UNMATCHED (none). A
line that fails to post (a locked period, an account missing) is left for someone. It returns
how many were applied. It runs after each sync, each statement upload, each webhook, and from
the reconciliation page's button.
Ignoring and undoing
ignore(BankLine $line)leaves a line out of the books (an internal transfer posted otherwise, a test):IGNORED. A reconciled line is refused: undo it first.undo(BankLine $line): an ignored or suggested line goes back toUNMATCHED. A reconciled one is unlettered, its entry reversed (dated as it was, source<source>:undo), the entry and its reversal lettered together on the class 4 and 511 accounts so that neither is an open item, and the line isUNMATCHEDagain. Refused when the entry's date is in the locked period or a closed fiscal year. The next reconciliation posts with the sourcebank:<id>/2.
Rules and splits
A ReconciliationRule places recurring lines nothing open matches: bank fees to 627, a loan
instalment split between 164 (capital) and 6611 (interest), a Stripe payout to 5112.
Reconciler::split(ReconciliationRule $rule, int $amount) returns the parts
[account, minor units, label]:
- no split: the whole amount on the rule's account;
- otherwise, in the split's order:
amounta fixed part in minor units,percenta share (rounded), each capped at what is left; a part withrest: truetakes what remains; with no such part, the rule's own account takes it. A part withoutaccountis the rule's account;labelis the line's label. Parts of 0 are dropped.
[{"account": "164", "amount": 85000}, {"account": "6611", "rest": true}]
In the back office, the split is typed as JSON. A text that is not a JSON array saves the rule with no split (
ReconciliationRuleCrudController's setter), without an error.
The Stripe pattern
Card payments go 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
5112open items, by its reference (rank 1) or by a rule on5112("STRIPE", money in: rank 5 with its items,0.92). - The payout is posted
512/5112and the clearing account's lines are lettered.
Following a reconciliation
use Base\Ledger\Event\BankLineReconciled;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener]
final class RentPaid
{
public function __invoke(BankLineReconciled $event): void
{
foreach ($event->openItems as $item) {
// $item->getEntry()->getSource(): "notice:2026-10-U3" - the rent call is paid.
}
}
}
$event->line, $event->entry (the bank entry) and $event->openItems (the items it settled;
empty for a partial payment, a rule or a payment on account).
Proposer une modification docs/usage-reconciliation.md
Reports and the FEC
Every report is a service with a compute() that reads the book, and a static part
(fromTotals(), fromLines()) that works on plain arrays - what the tests and an
application's own exports use. Amounts are minor units. Draft entries count in every report
(they say what the books say today); only the FEC keeps validated entries alone.
Trial balance
Base\Ledger\Report\TrialBalance::compute(Book $book, ?\DateTimeImmutable $from = null, ?\DateTimeImmutable $to = null)
- the balance générale:
| Key | Content |
|---|---|
rows |
per account, by number: number, label, type (value), debit, credit, balance (debit minus credit), debitBalance, creditBalance |
classes |
per PCG class (the number's first character): debit, credit, balance |
totals |
debit, credit, debitBalance, creditBalance |
result |
income minus expenses (accounts of type income and expense): a profit is positive |
balanced |
total debits equal total credits |
TrialBalance::typeOf(array $row) reads a row's type: the enum, its value, or
AccountType::guess() from the number.
General ledger
GeneralLedger::compute(Book $book, ?$from = null, ?$to = null, ?string $accountPrefix = null)
- the grand livre: per account (by number) its
lines-date,journal,number,piece,label(the line's, else the entry's),party,debit,credit, the runningbalance,letter- and itsdebit,creditandbalancetotals.
Open items
OpenItems::compute(Book $book, ?Party $party = null, ?string $accountPrefix = '411', ?\DateTimeImmutable $at = null)
- what is still owed, by account and party:
parties(each withparty,name,account,items,balance,oldestdate), largest balance first, and thetotal.$atleaves out the lines dated after it.401gives what is still to pay to suppliers.
Compte de résultat
IncomeStatement::compute(Book $book, \DateTimeImmutable $from, \DateTimeImmutable $to, bool $withPrevious = false).
The headings, and the account prefixes each takes:
| Side | Key | Heading | Prefixes |
|---|---|---|---|
| income | rents |
Loyers | 706, 7083 |
| income | recovered_charges |
Charges récupérées | 708 |
| income | other_income |
Autres produits | 7088, and every income account no prefix matches |
| expenses | rental_charges |
Charges locatives et de copropriété | 614, 6061 |
| expenses | repairs |
Entretien et réparations | 615, 6063 |
| expenses | insurance |
Assurances | 616 |
| expenses | fees |
Honoraires | 622, 611 |
| expenses | taxes |
Impôts et taxes | 63 |
| expenses | interest |
Intérêts des emprunts et dettes | 661 |
| expenses | depreciation |
Dotations aux amortissements | 681 |
| expenses | bank_fees |
Frais bancaires | 627 |
| expenses | other_expenses |
Autres charges | every expense account no prefix matches |
An account goes to the group of the longest prefix it starts with (7083 to the rents, 708
to the recovered charges); its side is its type. Each side is a map of groups (key,
label, lines - number, label, amount, previous -, total, previous); both
sides are positive when normal (a refund larger than the charge makes it negative).
totals is income, expenses, result; previous the same for N-1, or null.
With $withPrevious, the N-1 column is the same period a year earlier:
IncomeStatement::yearBefore() keeps a month's last day its last day (2028-02-29 gives
2027-02-28), so the N-1 of a twelve-month fiscal year is the fiscal year before.
Bilan
BalanceSheet::compute(Book $book, \DateTimeImmutable $at, bool $withPrevious = false) -
every account's balance from the first entry to $at.
| Side | Key | Heading |
|---|---|---|
| assets | fixed_assets |
Immobilisations nettes |
| assets | receivables |
Créances locataires et clients |
| assets | other_assets |
Autres créances |
| assets | cash |
Disponibilités |
| liabilities | equity |
Capitaux propres |
| liabilities | loans |
Emprunts |
| liabilities | deposits |
Dépôts de garantie reçus |
| liabilities | suppliers |
Dettes fournisseurs |
| liabilities | associates |
Comptes courants d'associés |
| liabilities | other_liabilities |
Autres dettes |
Where an account goes (BalanceSheet::place(string $number, int $balance)):
165deposits,16loans,10-14equity, the rest of class 1 other liabilities - on the passif whatever their side (negative when on the wrong one);- class 2 fixed assets, net (depreciation
28shows negative), class 3 other assets; - the others by their balance: in debit,
41receivables,51and53cash, the rest other assets; in credit,40suppliers,455associates, the rest other liabilities - a tenant who overpaid is a debt, an overdrawn bank a debt.
The income and expense accounts are not closed in the books: their balance is the result,
in the capitaux propres - "Résultat de l'exercice" for the fiscal year containing $at, and
"Résultats antérieurs non affectés" for what earlier years left (no closing entry to
120/129). totals is assets, liabilities, result; balanced says whether actif and
passif agree (in both columns with N-1).
use Base\Ledger\Report\BalanceSheet;
use Base\Ledger\Report\IncomeStatement;
use Base\Ledger\Report\TrialBalance;
// The totals of a period, as EntryLineRepository::totalsByAccount() returns them.
$totals = [
['number' => '411U3', 'label' => 'Jean Dupont', 'type' => 'asset', 'debit' => 170000, 'credit' => 85000],
['number' => '5121', 'label' => 'Banque', 'type' => 'asset', 'debit' => 85000, 'credit' => 850],
['number' => '627', 'label' => 'Services bancaires', 'type' => 'expense', 'debit' => 850, 'credit' => 0],
['number' => '706', 'label' => 'Loyers', 'type' => 'income', 'debit' => 0, 'credit' => 170000],
];
$trial = TrialBalance::fromTotals($totals);
echo 'balanced: ', var_export($trial['balanced'], true), ', result: ', $trial['result'], "\n";
$statement = IncomeStatement::fromTotals($totals);
echo 'rents: ', $statement['income']['rents']['total'], ', bank fees: ', $statement['expenses']['bank_fees']['total'], ', result: ', $statement['totals']['result'], "\n";
$sheet = BalanceSheet::fromTotals($totals);
echo 'assets: ', $sheet['totals']['assets'], ', liabilities: ', $sheet['totals']['liabilities'], ', balanced: ', var_export($sheet['balanced'], true), "\n";
echo implode('/', BalanceSheet::place('411U3', -5000)), ' ', implode('/', BalanceSheet::place('5121', 1000)), "\n";
echo IncomeStatement::yearBefore(new DateTimeImmutable('2028-02-29'))->format('Y-m-d'), "\n";
Run in omnisense/omnibase's harness image:
balanced: true, result: 169150
rents: 170000, bank fees: 850, result: 169150
assets: 169150, liabilities: 169150, balanced: true
liabilities/other_liabilities assets/cash
2027-02-28
CSV downloads
The back office's compte de résultat and bilan download with ?format=csv: a UTF-8 BOM,
semicolons, the columns Section, Rubrique, Compte, Libellé, N, N-1 (N-1 left out with
?n1=0), amounts with a decimal comma and no thousands separator; one row per account, one
total row per group, then the result (compte de résultat) or each side's total (bilan). Files:
compte-de-resultat-<from>-<to>.csv, bilan-<at>.csv.
The FEC
Base\Ledger\Export\FecWriter::write(Book $book, FiscalYear $year): string - the Fichier des
Écritures Comptables (article A47 A-1 of the LPF) of a fiscal year:
- one row per line of every validated entry of the period, by date then number;
- the 18 columns in their order:
JournalCode,JournalLib,EcritureNum,EcritureDate,CompteNum,CompteLib,CompAuxNum,CompAuxLib,PieceRef,PieceDate,EcritureLib,Debit,Credit,EcritureLet,DateLet,ValidDate,Montantdevise,Idevise; - tab-separated, CRLF line ends, dates
YYYYMMDD, amounts with a decimal comma and no thousands separator (FecWriter::amount(123456)is1234,56); - a line on a party, or on an auxiliary account (
411U3), is filed under its general account (411, with that account's label) and carries the auxiliary account and the party's name inCompAuxNumandCompAuxLib; PieceRefis the entry's piece, or its number;PieceDatethe entry's date;MontantdeviseandIdeviseare empty for a book in euros, the signed amount and the currency otherwise;- tabs, pipes and line breaks inside a field become a space (
clean()).
FecWriter::filename() is <SIREN>FEC<closing date>.txt (000000000 without a SIREN). The
fiscal year is FiscalYearRepository::forYear($book, 2026): the one ending in 2026, else the
calendar year.
bin/console ledger:fec:export "SCI Les Tilleuls" 2026 --dir=var/fec
bin/console ledger:fec:export 1 2026 --stdout > fec.txt
Proposer une modification docs/usage-reports.md
The back office
The screens are loaded when omnibase/admin is installed (config/services.php checks for
Base\Admin\Controller\AbstractCrudController). Their labels are in French, as the books
are.
Who sees a book
Base\Ledger\Security\Voter\LedgerVoter decides LEDGER_VIEW and LEDGER_MANAGE, on a
Book or on anything with a getBook() (an entry, an account, a bank line...):
ledger.admin_role(ROLE_ADMIN) views and manages every book;- otherwise the application's
BookAccessInterfaceservices are asked: anycanManage()saying yes grants both, anycanView()grantsLEDGER_VIEW; - without a subject (the ledger's screens as a whole), only the admin role is granted.
use Base\Ledger\Entity\Book;
use Base\Ledger\Security\BookAccessInterface;
use Symfony\Component\Security\Core\User\UserInterface;
final class AssociateBookAccess implements BookAccessInterface
{
public function canView(UserInterface $user, Book $book): bool
{
return /* the user is an associate of the book's company */ false;
}
public function canManage(UserInterface $user, Book $book): bool
{
return false;
}
}
BookAccessInterface is autoconfigured (tag ledger.book_access): implementing it is
enough. omnibase/estate's Ledger\AssociateBookAccess is one.
Every ledger CRUD uses LedgerAdminTrait: reading a record takes LEDGER_VIEW
(setEntityPermission), the actions that write (NEW, EDIT, DELETE, BATCH_DELETE, the
three saves) LEDGER_MANAGE - not omnibase/admin's default super-administrator. The pages
below are #[IsGranted('LEDGER_VIEW')] as a whole, and check LEDGER_MANAGE on the record
for what changes something.
The screens
| CRUD | Slug | What it allows |
|---|---|---|
BookCrudController |
books |
the books; actions Balance (trial balance), Impayés (open items), Plan comptable (seed the chart, asked to confirm); a book with entries cannot be deleted |
AccountCrudController |
accounts |
the chart, by number, 50 a page, searched by number and label, filtered by type; book and number chosen when created, shown on edit; an account with lines cannot be deleted |
JournalCrudController |
journals |
book and code chosen when created; a journal with entries cannot be deleted |
FiscalYearCrudController |
fiscal-years |
a new one is this calendar year of the first book; closing it is setting closedAt |
PartyCrudController |
parties |
a party typed here gets its auxiliary account when saved: left empty, the kind's collective account and the reference (else the name), made unique; the Account created when missing; a party with lines cannot be deleted |
EntryCrudController |
entries |
no "new" (entries are posted, not typed); a draft's date, piece and label can be corrected, then Valider; a validated entry is read-only, Extourner posts its reversal; the detail page shows the lines; only drafts can be deleted |
BankConnectionCrudController |
bank-connections |
no "new" (opened from the connect page); Renouveler, except for files; the record's note says when the bank asks for renewal, else the last error; the state is never shown |
BankAccountCrudController |
bank-accounts |
no "new"; Rapprocher, Importer un relevé, Synchroniser (not for files); the ledger account and journal chosen in a list grouped by book |
BankLineCrudController |
bank-lines |
read-only, newest first, 50 a page, filtered by status; Rapprocher opens the account's reconciliation |
ReconciliationRuleCrudController |
reconciliation-rules |
name, priority, enabled, pattern, counterparty IBAN, direction, account, party, split (JSON); a new rule points to 627 (or the first account) of the first book |
A new record belongs to the first book until its form says otherwise; with no book, "Create a
book first". The CRUDs' routes are admin_crud_<slug>_* (admin_crud_bank_lines_index...).
The record links that act (Plan comptable, Valider, Extourner, Renouveler, Synchroniser) carry
a CSRF token in their query, <action>_<id>: a link from elsewhere, or a prefetch, does
nothing.
The pages
They render in the admin's layout with its menus (AbstractLedgerPageController::page()).
After an action they go back to the back-office page the button was on, and only to one under
/admin on the same host - never to the site page under a nested back office.
Reconciliation (/admin/ledger/reconcile/{bankAccount}): the pending lines, each with
its five best suggestions and the open items on its side to pick from by hand; the lines
placed lately (40), which can be undone; the account's rules and the book's parties; the
threshold. Accept a suggestion, or pick items, a rule or a party (a hand-made suggestion of
score 1.0); ignore a line; undo; reconcile automatically. Every change is a POST with the
token ledger_line_<id> (ledger_reconcile_auto_<id> for the button).
Upload (/admin/ledger/bank/{bankAccount}/upload): the file field statement (CAMT.053,
OFX or CSV), token ledger_upload_<id>; after the import, automatic reconciliation and the
reconciliation page.
Connect (/admin/ledger/bank/connect/{gateway}?book=): for files, a form (name, IBAN;
token ledger_bank_connect), then the upload page; for another gateway (token
ledger_bank_connect in the form or the query), off to its consent page and back on
/admin/ledger/bank/return/{connection}. A gateway that is not configured is a flash
message. The book is ?book=, else the first.
Reports (/admin/ledger/book/{book}/...): trial balance, general ledger (?account= a
prefix), open items (?account=, 411 by default), compte de résultat and bilan, printed
without the admin's chrome, ?n1=0 without N-1, ?format=csv downloaded; the period is
?from=&to= (Y-m-d), or ?year= (the fiscal year ending in it), else the current fiscal
year; the bilan is ?at=, else the period's end or today, whichever is earlier. The FEC of a
year downloads from /fec/{year}.
Dashboard widgets
ledger_bank (BankWidgetType, @Ledger/admin/widget/bank.html.twig), for the books the
user may view:
- each bank account with its balance in the books (its
512x) and at the bank, its last sync and consent - "Renouveler" when the bank asks (not forfiles) - and its lines to reconcile, linking to the reconciliation page; - the six latest lines across accounts;
- with no account yet: "Connecter une banque" for each configured gateway but
files(Powens, Bridge, Qonto by name), and "Importer un relevé" whenfilesis configured.
ledger_books (BooksWidgetType, @Ledger/admin/widget/books.html.twig), for up to four
books the user may view: the current fiscal year to date - income, expenses and result (the
compte de résultat's totals), open receivables (open debits on 411), open payables (open
credits on 401) and cash (512 to today). Each tile opens its report; the book links to its
compte de résultat, bilan and balance.
Proposer une modification docs/back-office.md
Configuration reference
ledger:
Base\Ledger\DependencyInjection\LedgerConfiguration, dumped with Symfony's
YamlReferenceDumper in omnisense/omnibase's harness image:
ledger:
# Posting to an account number the book does not have creates it, as a sub-account of the longest existing account its number starts with (4011 under 401). Off: such an entry is refused.
auto_accounts: false
# The chart Chart::seed() uses when none is named: a file of config/charts/ (sci: a French real-estate SCI).
chart: sci
# Who views and manages every book (LEDGER_VIEW, LEDGER_MANAGE). Services implementing BookAccessInterface may grant more.
admin_role: ROLE_ADMIN
# What the key sealing the bank connections' state (tokens) is derived from. Changing it makes the stored connections unreadable: they must be connected again.
secret: '%kernel.secret%'
reconciliation:
# A bank line is reconciled on its own only when exactly one suggestion scores at least this much.
auto_threshold: 0.9
# How many open items of one party a grouped payment is looked for among (subset sum, oldest first).
max_group_items: 12
bank:
# A sync asks again for the days before the last one: banks book late. Lines already imported are matched on their id.
sync_overlap_days: 5
| Option | Type | Default | Read by | Effect |
|---|---|---|---|---|
auto_accounts |
bool | false |
Posting |
an unknown account number is created under its longest existing parent, instead of refused |
chart |
string | sci |
Chart |
the file of config/charts/ seeded when none is named |
admin_role |
string | ROLE_ADMIN |
LedgerVoter |
the role that views and manages every book |
secret |
string | %kernel.secret% |
StateCipher |
what the bank state's key is derived from; must not be empty |
reconciliation.auto_threshold |
float, 0 to 1 | 0.9 |
Reconciler |
the score a lone suggestion needs to be applied on its own |
reconciliation.max_group_items |
int, 2 to 20 | 12 |
Matcher |
the open items a grouped payment is searched among |
bank.sync_overlap_days |
int, 0 or more | 5 |
Sync |
the days asked again before the last sync |
Parameters
LedgerExtension sets one parameter per key and per level (the core's
AbstractBaseExtension::setConfiguration()): ledger.auto_accounts, ledger.chart,
ledger.admin_role, ledger.secret, ledger.reconciliation (the array),
ledger.reconciliation.auto_threshold, ledger.reconciliation.max_group_items, ledger.bank,
ledger.bank.sync_overlap_days. The services read them with #[Autowire('%ledger.…%')].
Services
config/services.php autowires and autoconfigures every class of src/ except
DependencyInjection/, Entity/, Enum/, Model/, Event/, Exception/,
Reconciliation/Suggestion.php, Controller/Admin/, Admin/ and the bundle class. Every
service is private.
Controller/butAdmin/(the webhook) is taggedcontroller.service_arguments.- When
Omnibank\Registryexists,Bank\Connectionsgets$registryasservice('Omnibank\Registry')->nullOnInvalid():nullwhen Omnibank's bundle is not registered. - When omnibase/admin is installed:
Controller/Admin/(taggedcontroller.service_arguments) andAdmin/(the two widget types, autoconfigured as omnibase/admin widget types).
Autoconfiguration the bundle relies on:
| Class | How |
|---|---|
BookAccessInterface |
#[AutoconfigureTag('ledger.book_access')], injected into LedgerVoter with #[AutowireIterator] |
ValidatedEntryGuard |
#[AsDoctrineListener(event: Events::onFlush)] |
LedgerExtension (Twig) |
#[AsTwigFilter] on its methods |
| the three commands | #[AsCommand] |
LedgerVoter |
a Voter |
Optional collaborators: Symfony\Component\Lock\LockFactory (Posting, numbering under a
lock), EventDispatcherInterface (Reconciler, the BankLineReconciled event).
Proposer une modification docs/configuration.md
Reference
Routes
| Route | Path | Methods | Access |
|---|---|---|---|
ledger_admin_reconcile |
/admin/ledger/reconcile/{bankAccount} |
GET | view |
ledger_admin_reconcile_apply |
/admin/ledger/reconcile/line/{line}/apply |
POST | manage, token |
ledger_admin_reconcile_ignore |
/admin/ledger/reconcile/line/{line}/ignore |
POST | manage, token |
ledger_admin_reconcile_undo |
/admin/ledger/reconcile/line/{line}/undo |
POST | manage, token |
ledger_admin_reconcile_auto |
/admin/ledger/reconcile/{bankAccount}/auto |
POST | manage, token |
ledger_admin_bank_upload |
/admin/ledger/bank/{bankAccount}/upload |
GET, POST | manage |
ledger_admin_bank_connect |
/admin/ledger/bank/connect/{gateway} (?book=) |
GET, POST | manage, token |
ledger_admin_bank_return |
/admin/ledger/bank/return/{connection} |
GET | manage |
ledger_admin_bank_renew |
/admin/ledger/bank/connection/{connection}/renew |
GET | manage, link token |
ledger_admin_bank_sync |
/admin/ledger/bank/{bankAccount}/sync |
GET | manage, link token |
ledger_admin_trial_balance |
/admin/ledger/book/{book}/trial-balance |
GET | view |
ledger_admin_general_ledger |
/admin/ledger/book/{book}/general-ledger |
GET | view |
ledger_admin_open_items |
/admin/ledger/book/{book}/open-items |
GET | view |
ledger_admin_income_statement |
/admin/ledger/book/{book}/income-statement |
GET | view |
ledger_admin_balance_sheet |
/admin/ledger/book/{book}/balance-sheet |
GET | view |
ledger_admin_fec |
/admin/ledger/book/{book}/fec/{year} |
GET | view |
ledger_admin_book_seed |
/admin/ledger/book/{book}/seed |
GET | manage, link token |
ledger_admin_entry_validate |
/admin/ledger/entry/{entry}/validate |
GET | manage, link token |
ledger_admin_entry_reverse |
/admin/ledger/entry/{entry}/reverse |
GET | manage, link token |
ledger_bank_webhook |
/ledger/bank/{gateway}/webhook |
POST | public (the gateway checks the signature) |
Ids are digits; {gateway} is [a-z0-9_-]+; {year} four digits. "View" and "manage" are
LEDGER_VIEW and LEDGER_MANAGE on the record (see The back office).
Commands
| Command | Arguments and options | What it does |
|---|---|---|
ledger:chart:seed |
book (id, SIREN or name); --chart= (a file of config/charts/, default ledger.chart) |
adds the chart's missing accounts and journals |
ledger:bank:sync |
[book]; --no-reconcile |
syncs every API-fed bank account (all books, or one) and reconciles what is sure; files accounts are skipped; fails (exit 1) when an account failed, each reported as a warning |
ledger:fec:export |
book, year (the year the fiscal year closes in); --dir= / -d (.); --stdout |
writes <SIREN>FEC<YYYYMMDD>.txt, or prints it; a year that is not digits is exit 2 |
An unknown book is an error and exit 1. ledger:bank:sync prints one line per account:
<book> / <account>: <n> new line(s), <m> reconciled.
Twig filters
Base\Ledger\Twig\LedgerExtension:
| Filter | Example | Result |
|---|---|---|
ledger_money(currency = 'EUR', locale = null) |
{{ 123456|ledger_money }} |
1 234,56 € in a French locale (NumberFormatter, the default locale); 1 234,56 EUR without intl |
ledger_amount |
{{ 123456|ledger_amount }} |
1 234,56; empty for 0 and null (a report's column) |
ledger_iban |
{{ iban|ledger_iban }} |
FR76 •••• 4321 |
Event
Base\Ledger\Event\BankLineReconciled (dispatched by its class name, after
Reconciler::apply()): line, entry, openItems
(Reconciliation).
Templates
@Ledger/admin/: reconcile, upload, connect_files, trial_balance, general_ledger,
open_items, income_statement, balance_sheet (.html.twig), the partials _period,
_report_nav, _statement, _style, and widget/bank, widget/books. An application
overrides one in templates/bundles/LedgerBundle/admin/....
Translations
The domain ledger (ICU), in English and French (translations/ledger+intl-icu.{en,fr}.yaml):
status.* (the bank lines' statuses), reconcile.*, bank.*, report.*. The CRUDs' labels
and the flash messages are written in French in the code.
Proposer une modification docs/reference.md
Errors
Every refusal of the ledger is a Base\Ledger\Exception\LedgerException
(a \RuntimeException): nothing was written. Catch it where an application posts on behalf
of a user; the back office turns each into a flash message.
| Exception | Parent | Thrown when |
|---|---|---|
LedgerException |
\RuntimeException |
the base class |
PostingException |
LedgerException |
Posting: fewer than two lines, no source, a negative or two-sided line, a line without amount, an unbalanced entry, no such journal, a party of another book, an unknown account (auto_accounts off) or one with no parent (on); validating an unbalanced entry; reversing a draft or an entry already reversed |
LockedPeriodException |
PostingException |
a date on or before the book's lockedUntil, or in a closed fiscal year (Posting::assertOpen(), called by post, validate and undo) |
LockedEntryException |
LedgerException |
ValidatedEntryGuard at flush: a validated entry changed or deleted, a line of one changed (beyond lettering), added or deleted |
LetteringException |
LedgerException |
Lettering::letter(): not entry lines, fewer than two, one already lettered, different accounts or parties, debits and credits that differ |
ReconciliationException |
LedgerException |
Reconciler: a line not pending, of amount 0, with nothing to post against; ignoring a reconciled line; undoing a line that is not reconciled, or in the locked period |
BankException |
LedgerException |
Omnibank missing, a gateway not configured, a gateway's failure (wrapped), a consent to renew, an account the bank no longer lists, an empty statement or one of another account, a sealed state that does not open |
Other exceptions:
\InvalidArgumentException-Chart::definition()for a chart name outside[a-z0-9_-]or a missing file;new FiscalYear()ending before it starts;StateCipherbuilt with an empty secret.\LogicException-Entry::validate()on an entry already validated (Postingchecks first).Omnibank\Exception\InvalidNotificationException- caught by the webhook, answered 400.
A gateway's own exception is kept as the BankException's previous exception, and its message
in the connection's lastError.
Proposer une modification docs/errors.md
Testing
The suite
vendor/bin/phpunit # checked out on its own
vendor/bin/phpunit -c vendor/omnibase/ledger # from an application that requires it
The tests need no database: entities are built in memory and repositories are stubs
(tests/InMemoryLedger.php); the mapping is checked against a MySQL schema
(Entity/MappingTest). The statement uploads go through omnisense/omnibank's real files
gateway. tests/bootstrap.php uses whichever autoloader exists (LEDGER_AUTOLOAD, this
checkout's, the host's, /srv/app/vendor) and registers omnisense/omnibank's core and
files from OMNIBANK_PATH, a checkout beside this one, or /srv/omnibank, when the host
does not have them - without them the bank tests skip.
In omnisense/omnibase's harness, with this checkout and the omnibank workspace mounted:
docker run --rm --network none \
-v "$PWD":/srv/demo/vendor/omnibase/ledger:ro \
-v ~/Sites/omnibank:/srv/omnibank:ro \
omnisense/omnibase-harness test ledger
............................................................... 63 / 111 ( 56%)
................................................ 111 / 111 (100%)
OK (111 tests, 666 assertions)
Without the omnibank mount, 12 bank tests skip (111 tests, 12 skipped).
What it checks
| File | What |
|---|---|
Service/PostingTest |
the checks of a draft, idempotent sources, numbering per fiscal year, auto accounts, reversals, locked periods |
Service/PartiesTest |
a party created once with its auxiliary account, each kind under its collective account, numbers kept unique |
Service/LetteringTest |
letters in sequence, the refusals, unlettering, open items |
Service/ChartTest |
the SCI chart has what a real-estate company posts to; seeding again keeps what the book has |
EventListener/ValidatedEntryGuardTest |
what the guard refuses and the lettering it lets through |
Reconciliation/MatcherTest |
a reference in the label, a reference with another amount, the counterparty's IBAN, the oldest of equal items, a party's name, one transfer for several items, all of a party's items, a supplier, ambiguous amounts and IBANs, a rule, a Stripe payout through its rule |
Reconciliation/ReconcilerTest |
a payment posted and lettered with its notice, a supplier payment, a loan instalment split, a Stripe payout, a partial payment, nothing to post against, automatic reconciliation, undo (and in the locked period), ignored lines, splits adding up |
Bank/ConnectionsTest |
the sealed state, other secrets, the webhook's connection, no Omnibank |
Bank/ImporterTest, Bank/SyncTest, Bank/StatementUploadTest |
each transaction once, the overlap, consent to renew, statements of another account, CSV files |
Report/ReportsTest, Report/StatementsTest |
trial balance, general ledger, open items, compte de résultat and bilan with N-1 |
Export/FecWriterTest |
the 18 columns, auxiliary accounts, amounts, the file name |
Security/LedgerVoterTest |
the admin role views and manages everything; an application grants a book through BookAccessInterface |
DependencyInjection/LedgerExtensionTest |
the defaults and the services; configured values |
Entity/MappingTest |
the mapping is valid; the tables and their unique keys |
Templates/TemplatesTest |
the reconciliation page, the reports, the upload and connect pages, the widgets render |
Controller/CrudRequiredFieldsTest, Controller/Admin/CrudFormsTest, Controller/Admin/NewFormsSubmitTest |
the CRUDs' required fields; enums chosen through EnumType and related records picked in a list, not embedded; each "new" form writes every field it shows, a fiscal year ending before it starts is a form error, book and code fixed once created |
In an application's tests
Post through Posting as production does, and assert on the entries: a rent call posts once
whatever the retries (same source), and a payment reconciled settles it
(BankLineReconciled, the lines lettered). Lettering::openItems($book, $party) says what a
tenant still owes. Feed bank lines with Importer::import() and Omnibank's BankTransaction
models, or with a CAMT.053 file through StatementUpload::upload() and the files gateway -
no bank account needed.