← Back to projects

Separis

Internal · BNC accounting

Internal accounting tool for a French liberal profession (BNC physician). Ingests PDF bank statements from two banks (CCF and SG), auto-categorises transactions through a rule-learning engine, reconciles deferred card statements, computes straight-line depreciation, and generates the 2035 tax return plus a Sage Expert export. Flask web interface with a lightweight mobile view accessible over Tailscale. Stack: Python 3, pdfplumber, SQLite, Flask.

FR EN
87 Tests
2 Banks (CCF, SG)
3 PDF document types
10 SQLite tables

Why I built it

A French BNC physician used a single account for everything: professional fees and personal spending mixed on the same statements. Handing that bookkeeping to an accounting firm meant paying a premium — untangling professional from personal, line by line, falls to the accountant, and that sorting is billed. He wanted to take back control and do his own accounting. But Sage, the destination accounting software, is anything but ergonomic for that sorting work. Hence Separis: do everything in a fast interface — PDF import → categorisation that learns the pro/personal split → deferred-card reconciliation → depreciation → 2035 return — then output a file ready to import into Sage. The tool is strictly internal — it handles real tax data and has no intention of ever being public. It runs locally with Tailscale access from a phone, to validate transactions the same evening after a consultation.

Structural technical decisions

01
All monetary amounts stored as integer cents throughout

Every monetary value is stored as `INTEGER` cents — never as a floating-point euro amount. The constraint is enforced at the SQLite schema level (`test_amounts_are_integer_centimes`) and upheld all the way to the Sage Expert output format (the `cents_to_fr` function formats as French decimal). No floating-point rounding can create or destroy a cent in the double-entry lines.

02
Two-tier rules: built-in + learned, first-match wins

Categorisation is an ordered pipeline: learned rules (priority 1/2/3) run before built-in ones. The first match on the normalised label wins. Normalisation strips accents, SEPA reference tokens, and whitespace before comparison — making rules resilient to format variations between banks. A keyword learned from one manual decision automatically surfaces for subsequent imports.

03
Deferred reconciliation: exact cent-level verification

CCF separates the account statement (with a CUMUL DÉBITS DIFFÉRÉS per card) from the card statement (the detailed lines). The matcher links these two sources and verifies that the net sum (debit − credit) of categorised card lines lands exactly on the account cumul. A 1-cent discrepancy produces a 'mismatch' status persisted in the database — it is never silently ignored. This rigour let me catch several date-shift discrepancies between the two statements.

04
Loan splitter: principal, interest, insurance as three distinct entries

Each loan instalment is decomposed into three accounting entries (pret_amortissement → 164000, pret_interets → 661100, pret_assurance → 638000) from the amortisation schedule imported as a PDF. These entries feed AGAPS boxes 6700 (financial costs) and 6380 (insurance) directly, never mixing capital repayment with deductible charges. The three amounts must sum back to the gross debit of the instalment.

05
Sage export in cp1252 with checksum and deduplication

Sage Expert imports a cp1252 text format with #Compte and #LigneComptable sections. Every generated file is encoded in cp1252 (unsupported characters replaced), its SHA-256 is stored in the database alongside the entry count, and the export history is browsable in the interface. Amazon transactions with a professional part and a personal part produce two distinct pairs of entries (expense account + 108000) so the owner's drawing account is never polluted with deductible charges.

The non-trivial challenge

Mapping internal PCG codes to AGAPS 2035 form fields without silent data loss

The Plan Comptable Général uses 6-digit codes (700000, 636400, 662000…); the AGAPS 2035 form has its own field codes (7000, 6500, 6620…). The mapping between the two is not 1-to-1: some internal codes land in pseudo-keys (personal aggregates, raw bank flows, loan entries); some codes can feed multiple form fields. The real challenge: losing nothing silently. I added an automatic coverage diagnostic (diagnose_2035) that lists codes used in decisions but not mapped to any form field, professional transactions without an accounting code, and personal transactions with a suspicious 6/7XXX code. This diagnostic runs live in the interface and blocks any incomplete declaration.

Lesson learned

A tax-data pipeline has no right to ambiguity: every cent must be traceable from the raw PDF line through the categorisation decision and the Sage entry all the way to the AGAPS form field. The moment I tolerated an approximation — 'this code is probably right' — it ended up in the final return. The solution is not to be more manually careful; it's to make errors impossible to ignore: amounts in integer cents throughout, exact cent-level reconciliation, mandatory coverage diagnostic before export. The pipeline is not a data-entry aid — it's a formal constraint on the data.

Features

📄
Multi-bank PDF parsers

Structured extraction from CCF current account, CCF deferred card, and SG account statements via pdfplumber: real transaction dates, amounts in integer cents, label normalisation, SHA-256 deduplication on import

🧠
Rule-learning categoriser

Two-tier rule engine: built-in rules (CARMF, URSSAF, AGAPS, Branchet, Orange…) plus rules learned from manual decisions, ordered by priority. First match wins; result stored in review_decisions

🔗
Deferred-card reconciliation

Matching engine that links a CCF account statement's CUMUL DÉBITS DIFFÉRÉS line to the corresponding card import: verifies that the sum of pro+perso+uncategorised lands exactly on the cumul; matched/mismatch status persisted in the database

📉
Straight-line depreciation 30/360

Annual depreciation (dotation) per fixed asset using the French 30/360 convention (accountant standard): active-day proration, net book value at year end, cap guard against exceeding original value — feeds the 2035 form line CH

📊
2035 tax return generation

Full mapping from internal PCG codes to AGAPS 2035-A/B form fields: income (7000), social charges (6500, 6364), retrocessions (6511), insurance (6380), banking fees (6270), travel (6250), professional dues (6660), loan interest (6700)… with a coverage diagnostic and attention points

📤
Sage Expert export

Generates a cp1252 text file in Sage Expert format: #Compte section (6-digit codes) + #LigneComptable section (double-entry lines). Handles Amazon splits (pro part + personal part to 108000), loan entries (interest, principal, insurance), SHA-256 checksum and export history in the database

📱
Mobile interface + audit view

Lightweight no-cache /m interface optimised for mobile to review transactions one by one on the go; desktop audit table with filters by month/bank/category, bulk validation, and a 🚩 flag system for transactions to revisit

🏗️
Strict SQLite schema

10 tables with CHECK constraints on all enums (bank, document type, category, decision source), foreign keys enabled, amounts always in integer cents, manual cascade deletion to preserve integrity when re-importing a PDF

🧪
Full test suite

87 pytest tests covering CCF/SG parsers (real JSON fixtures), categoriser, reconciliation engine, depreciation, Sage exporter, loan splitter, label normalisation, and an end-to-end import→categorise→export pipeline

Tech Stack

Runtime
Python 3Flask 3.0
PDF
pdfplumber 0.11
Data
SQLite (10 tables)native sqlite3
Tests / CI
pytest 8.2 (87 tests)
Interface
Flask Jinja2 templatesmobile-first /mTailscale LAN
Exports
Sage Expert (cp1252)AGAPS 2035 (MD + TXT)PDF balance

🔒 Internal tool — no public version