Skip to content

omnibase/ledger

Double-entry accounting for omnibase: books, a French chart of accounts, journals, parties and lettering, bank feeds through omnisense/omnibank, reconciliation with ranked suggestions, trial balance, general ledger, income statement, balance sheet and the FEC export

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/ledger:dev-main

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 a source (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.

Propose a change 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).

Propose a change 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

Propose a change 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:

  1. An entry of the same book and source already there is returned as it is: posting is idempotent.
  2. 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 PostingException naming the line.
  3. The date is checked (assertOpen()): on or before the book's lockedUntil, or in a closed fiscal year, is a LockedPeriodException.
  4. The journal must exist in the book (by code); a party must be of the same book.
  5. Every account is resolved by its normalised number. An unknown number is a PostingException unless ledger.auto_accounts is on: the account is then created under the longest existing account its number starts with (4011 under 401), with that parent's type and the line's party name, label, or the parent's label - and a PostingException when there is no such parent.
  6. 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 letter and letteredOn, 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; X for an empty reference), cut to 17 characters and made unique in the book with a suffix (411U32). The Account is 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

Propose a change 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 null when 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:

  1. A connection that needs renewal (its status, or its expiry), or whose Omnibank consent is not active, is marked NEEDS_RENEWAL (or REVOKED, when the bank revoked it) with the message "The bank wants its consent given again." - and the sync stops with a BankException.
  2. The transactions are asked since the last sync minus ledger.bank.sync_overlap_days days (5), from midnight; with no start date on the first sync. Lines already imported are matched on their id.
  3. When the gateway supports FetchBalances, the last booked balance (any balance when none is booked) becomes the account's bankBalance.
  4. lastSyncedAt is set and lastError cleared.

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 files gateway, is a BankException.
  • A CSV (by its .csv name, or content that starts neither with < nor OFXHEADER) 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.secret defaults to %kernel.secret%. Changing either makes every stored connection unreadable: each bank must be connected again.

Propose a change 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 to UNMATCHED. 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 is UNMATCHED again. Refused when the entry's date is in the locked period or a closed fiscal year. The next reconciliation posts with the source bank:<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: amount a fixed part in minor units, percent a share (rounded), each capped at what is left; a part with rest: true takes what remains; with no such part, the rule's own account takes it. A part without account is the rule's account; label is 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:

  1. The application posts each payment (5112 / 411, its piece the payment id) and its fee (6278 / 5112).
  2. The payout on the statement settles the 5112 open items, by its reference (rank 1) or by a rule on 5112 ("STRIPE", money in: rank 5 with its items, 0.92).
  3. The payout is posted 512 / 5112 and 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).

Propose a change 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 running balance, letter - and its debit, credit and balance totals.

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 with party, name, account, items, balance, oldest date), largest balance first, and the total. $at leaves out the lines dated after it. 401 gives 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)):

  • 165 deposits, 16 loans, 10-14 equity, 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 28 shows negative), class 3 other assets;
  • the others by their balance: in debit, 41 receivables, 51 and 53 cash, the rest other assets; in credit, 40 suppliers, 455 associates, 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) is 1234,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 in CompAuxNum and CompAuxLib;
  • PieceRef is the entry's piece, or its number; PieceDate the entry's date;
  • Montantdevise and Idevise are 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

Propose a change 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 BookAccessInterface services are asked: any canManage() saying yes grants both, any canView() grants LEDGER_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 for files) - 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é" when files is 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.

Propose a change 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/ but Admin/ (the webhook) is tagged controller.service_arguments.
  • When Omnibank\Registry exists, Bank\Connections gets $registry as service('Omnibank\Registry')->nullOnInvalid(): null when Omnibank's bundle is not registered.
  • When omnibase/admin is installed: Controller/Admin/ (tagged controller.service_arguments) and Admin/ (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).

Propose a change 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.

Propose a change 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; StateCipher built with an empty secret.
  • \LogicException - Entry::validate() on an entry already validated (Posting checks 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.

Propose a change 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.

Propose a change docs/testing.md