Development¶
Repository layout¶
| Path | Contents |
|---|---|
src/lib/ |
Carbure\Lib namespace: technical foundation (configuration, database, logs, JWT, routing, woob, push notification service client, setup wizard, migrations, settings) |
src/lib/import/, src/lib/import/banks/ |
Carbure\Lib\Import namespace: reading imported statements (OFX, QIF, CAMT, CSV) and bank CSV profiles |
src/resources/ |
Carbure\Resources namespace: API resources |
portal/ |
Vue 3 web portal |
swagger/ |
OpenAPI generator and Swagger UI |
sql/ |
Schema and migrations |
tests/unit/, tests/integration/ |
PHPUnit tests |
tests/portal/ |
Portal unit tests (node:test) |
tests/e2e/ |
Portal end-to-end tests (Playwright) |
tools/migrate.php |
Schema migrations (--status, --dry-run, --baseline) |
tools/coverage-check.php |
Coverage threshold check |
composer.json, phpstan.neon, phpstan.tests.neon, phpunit.xml, tools/phpstan/ |
Development tools (PHPStan and PHPUnit 11, installed by Composer in vendor/) and Carbure's rules; the API itself has no dependencies |
docker/ |
Dockerfile, docker-compose.yml (with MariaDB), nginx and PHP-FPM configuration, entrypoint.sh |
data/ (not versioned) |
Instance data: conf/ (prod.ini), logs/, cache/, woob/ (banks and credentials) |
VERSION |
Carbure version (dev in the repository, written by the release build) |
docs/, mkdocs.yml |
This documentation |
Detailed rules for contributors and coding agents: AGENTS.md.
Open tasks: TODO.md.
Running locally¶
./start.sh # http://localhost:8000/portal/ (admin / admin-password, marie / marie-password)
./start.sh --reset # start over from the sample data (also after changing sql/carbure.sql)
./start.sh --fresh # start over from an empty database and configuration: setup wizard
./start.sh --build # rebuild the image (changes to docker/ or to the PHP extensions)
./start.sh --fake-woob # stubbed woob: sample accounts sync without a bank
./start.sh --stop # stop (the database and /data are kept)
./start.sh --clean # stop and delete the database and /data
./start.sh --help # help
start.sh reproduces the production architecture: it runs docker/docker-compose.yml
(the production compose file) with the docker/docker-compose.dev.yml override. Same
containers as the target: the Carbure Docker image (docker/Dockerfile: nginx,
PHP-FPM, woob, an entrypoint that applies the migrations) and MariaDB 13, with a /data
volume and a /data/conf/prod.ini, just like an installed instance. The override only
changes:
- the code (
src/,portal/,swagger/,sql/,tools/), mounted from the repository instead of being copied into the image: a change takes effect on the next request, without restarting or rebuilding; - the ports, which only listen on
127.0.0.1; scheduled sync, which is disabled; - the database,
carbure_dev, and/data, a Docker volume (the localdata/folder is not mounted).
There is only one application image, the production one: you develop on what will be
shipped. Rebuilding the image (--build) is only needed after changing
docker/ (Dockerfile, nginx, PHP).
- On first launch, the image is built (a few minutes for woob) and the database
receives the end-to-end test dataset (
tests/e2e/server/seed.php): a household with three months of transactions, budgets, rules and two accounts. prod.iniis rewritten on every start (log_level=debug, databasecarbure_dev). The real woob is used by default;--fake-woobreplaces it with the tests' fake woob (tests/fixtures/fake-woob.php), which answers in place of the banks: the sample accounts sync (fail@bankfails on purpose), and adding a bank and discovering accounts work without credentials. Options can be combined (./start.sh --reset --fake-woob).--freshreproduces a first installation: the database and/dataare emptied, noprod.iniis written and no sample data is loaded; the portal shows the setup wizard, prefilled with the dev database (starter pack included). Subsequent starts keep the configuration and data created by the wizard (/data/conf/.wizardmarker), until--resetor--clean.PORT(8000) andDB_PORT(3308, access to MariaDB from the host:carbure/dev) change the ports, which only listen on127.0.0.1. Logs: the portal's Logs tab, ordocker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml exec carbure ls /data/logs.
Adding a route¶
namespace Carbure\Resources;
use Carbure\Lib\ApiRoute;
final class Transaction {
/**
* Search transactions by label.
*
* @param string $query The text to find in the label
* @param int $limit The maximum number of transactions
* @return array The list of transactions
*/
#[ApiRoute('/transaction/search', method: 'GET')]
public static function search($query, $limit = 100)
{
// ...
}
}
- The method must be public and static, in a class of
src/resources/(Carbure\Resourcesnamespace, file name = class name, case included): nothing else to declare,ApiResolverscans the folder. - Classes are loaded on demand (PSR-4) by
Carbure\Lib\ClassLoader, registered bysrc/autoload.php, without Composer: norequire_oncebetween classes, but auseat the top of the file for each class from another namespace, including PHP's global classes (use Error;,use mysqli;). A sub-namespace (Carbure\Lib\Import) maps to a lowercase subfolder (src/lib/import/). - Request keys are passed by name: parameter names are part of the API contract.
public: truedisables the JWT check;stream: trueproduces an SSE response;raw: truereturns the text returned by the method as is (MCP server). A unit test checks the list of public routes.- Administration route: declare it
admin: truein its attribute;Webservicerejects it (403) for other users before calling the method.User::requireAdmin()is only for conditional checks inside the method (e.g.PUT /useron another user). Add the portal tab or action to the list reserved for administrators. - A unit test (
tests/unit/ApiResolverTest.php) pins the access level of each route (public,useroradmin): add every new route to it. - Client error:
throw new Error("message", 400); server error:Exception. - Current user:
Jwt::getUserIdFromToken().
Compatibility
The API is used by the iOS app, which is updated independently of the server. Only add routes or optional parameters; never rename or remove anything.
Adding a bank to the import¶
A CSV statement is read with its bank's profile when one of its first lines is
exactly the header of a known export, and no other profile claims it; otherwise
its columns are guessed from their names (generic reader CsvGeneric, which keeps the
heuristics from before profiles). OFX, QIF and CAMT.053 are read the same way for
all banks (OfxReader, QifReader, CamtReader). BankFile::parse() remains the
entry point and reports in bank the profile that read the file.
To add a bank's CSV export:
-
Profile:
src/lib/import/banks/<id>.php(lowercase identifier,-between words) returns an array whose schema is described and checked byBankProfiles(unknown or missing key, column missing from the header: exception, raised by the tests). Its comment gives the source of the format and its particularities.<?php /** * Ma Banque: export CSV of the operations. * * Particularities: lines about the account before the header, ";", Windows-1252. * Sources: open source projects that read this format. * Fixture: tests/fixtures/import/ma-banque.csv. Schema: BankProfiles. */ return [ 'name' => 'Ma Banque', // shown by the preview, row of the documentation table 'formats' => [[ // one format per variant (old and new, language) 'header' => ['Date', 'Libellé', 'Débit', 'Crédit'], // the header written by the bank 'delimiter' => ';', // ; , tab or | 'preamble' => 5, // lines about the account before the header (0 by default) 'encoding' => 'Windows-1252', // encoding of a file that is not UTF-8 'day_first' => true, // dd/mm; guessed from the dates if absent 'columns' => [ // column => its name in header 'date' => 'Date', 'label' => 'Libellé', 'debit' => 'Débit', 'credit' => 'Crédit', ], // 'skip_statuses' => ['PENDING'], // values of the status column for ignored lines // 'filename' => '/^Historique/', // file name required in addition to the header ]], ];Columns:
date(booking date) andlabelare required;operation(transaction date),detail(appended to the label, unless one contains the other),amount(signed) ordebitandcredit(a debit is negative whatever its sign),fee(deducted from the amount),status. The header is compared ignoring case, accents and punctuation, without the empty cells at the end of the line. -
Fixture: an anonymized export with the same structure (lines before the header, encoding, delimiter, line endings) in
tests/fixtures/import/<id>.csv, added toBankProfilesTest::bankFixtures(). The tests check that each fixture is only recognized by its profile and that each format of a profile has its fixture. - Snapshot:
tests/fixtures/import/expected/<id>.csv.json, the expected output ofBankFile::parse()(withoutbank), checked transaction by transaction; existing snapshots do not change. - Documentation: a row named after the profile in the banks table of Features (a test compares the table with the profiles).
Labels and fingerprints
Changing the label read for a bank changes the fingerprint (UUID) of its transactions: an already imported file would show its lines as likely duplicates.
Types¶
Every parameter, return value and property of the PHP code has a native type (checked by
PHP at runtime); arrays are detailed in the PHPDoc (array<string, mixed>).
PHPStan checks this (level 8, the tools/phpstan/NativeTypesRule.php rule and the
deprecation rules): a value that may
be false (result of a query, of strtotime(), of a file read) is not used as is, hence
Db::select(), Db::result(), Month::timestamp() and File::read(), which throw an
exception instead; a value that may be null (missing row, failed preg_replace()) gets its
default. The tests are analysed separately (phpstan.tests.neon, level 5,
without Carbure's rules):
No declare(strict_types=1): request parameters arrive as strings
(query string) and are converted. A route flag is never typed as a plain bool
("false" would become true).
Complexity¶
The cyclomatic complexity of each function or method (1 + one per decision: if,
elseif, loop, case, catch, ternary, &&/||, match arm) is capped at
10 (rule tools/phpstan/CyclomaticComplexityRule.php, threshold in phpstan.neon),
checked along with the types by composer phpstan, in pre-commit and in CI. A method that
grows gets split into small private methods.
Tests¶
composer install # once: PHPUnit in vendor/
# Unit: no external dependency (vendor/bin/phpunit --testsuite unit)
composer test
# Unit + integration: MariaDB/MySQL required (vendor/bin/phpunit)
DB_HOST=127.0.0.1 DB_USER=root DB_PASSWORD=root composer test:all
APP_ENV=testing(set inphpunit.xml) loadsconf/testing.ini.- Integration tests recreate the
carbure_testdatabase fromsql/carbure.sql(tests/bootstrap.php). - A run's temporary files (fake woob state, fake SMTP server
certificate…) go into its own directory,
carbure-test-<pid>in the temporary directory, deleted at the end and passed to the fake woob through theCARBURE_TEST_TMPvariable: two runs on the same machine (two worktrees, the e2e suite) do not interfere with each other. - Goal: ≥ 90% coverage of the code in
src/, measured in CI across all suites.
Portal unit tests¶
The portal modules that do not depend on the DOM (utils/, services/apiService.js
with a fake fetch, stores/budgetStore.js, the methods of the modules/ mixins)
are tested with Node's test runner, with no dependencies. tests/portal/setup.mjs
provides the few browser objects they need (window, CSS, Vue).
Portal end-to-end tests¶
The Playwright tests in tests/e2e/specs/ drive the portal in Chromium, against the
real API and a MariaDB carbure_e2e database recreated before each test: they also check
that the portal and the API agree (routes, parameters, responses).
cd tests/e2e
npm ci && npx playwright install chromium # only once
E2E_DB_HOST=127.0.0.1 E2E_DB_PASSWORD=root npx playwright test
npx playwright test specs/budget.spec.js # a single file
npx playwright test --ui # interactive mode
npx playwright show-report # HTML report of the last run
- Playwright starts PHP's built-in server (
php -S) with the test routertests/e2e/server/router.php(APP_ENV=e2e,conf/e2e.ini): it serves the portal, the API, and/api/__e2e/reset, which recreates the database with the dataset fromtests/e2e/server/seed.php(household, categories, budgets, rules, two accounts). This router is never served by nginx and refuses to answer outsideAPP_ENV=e2e. - You need a PHP with
mysqli(PHP_BINto pick one) and a MariaDB/MySQL database:E2E_DB_HOST,E2E_DB_PORT(3306),E2E_DB_USER(root),E2E_DB_PASSWORD. For exampledocker run -d -e MARIADB_ROOT_PASSWORD=root -p 3306:3306 mariadb:10.11. - woob is replaced by
tests/fixtures/fake-woob.php, logs go totests/e2e/.data/logs/. The fake woob state is incarbure-e2e-<port>in the temporary directory (CARBURE_TEST_TMP, set byplaywright.config.js): two parallel runs each take their own port (E2E_PORT) and database (E2E_DB_NAME, ending in_e2e). - Tests run one after another (a single database). Expected texts are read
from
portal/i18n/<language>.json(tr()intests/e2e/fixtures.js, which reuses the portal's translation, plurals included): changing a label does not break the tests. - The setup wizard and the administration banners mock the API responses
(
page.route), since the test server is already installed and up to date.
Lint¶
find src tests swagger -name '*.php' -print0 | xargs -0 -n1 php -l
for f in $(find portal -name '*.js'); do cp "$f" /tmp/check.mjs && node --check /tmp/check.mjs; done
The portal files are ES modules: node --check must read them as .mjs.
Git hooks¶
| Hook | Check |
|---|---|
pre-commit |
php -l and node --check on staged files, PHPStan if a PHP file is staged (composer install required) |
pre-push |
PHP unit tests (composer install required) and portal unit tests |
Continuous integration and delivery¶
| Workflow | Trigger | Steps |
|---|---|---|
| CI | Pull request, push to main |
PHP/JS/CSS lint, portal unit tests, unit and integration tests (MariaDB), coverage ≥ 90%, portal end-to-end tests (Playwright, report as an artifact on failure); Docker image: hadolint, build, Trivy scan, start with docker compose, installation through the wizard and smoke test |
| Release | Push of a tag (v1.2.0 or 1.2.0), or manual run with the tag |
CI, amd64/arm64 Docker image published to Docker Hub (ltoinel/carbure) (version passed through CARBURE_VERSION), with SBOM and provenance, and a provenance attestation signed by GitHub; then creation of the release with the application archive (src, portal, swagger, sql, tools/migrate.php, VERSION written from the tag) and the generated notes |
| Security | Pull request, push to main, every Monday, manual run |
Secret scanning of the history (gitleaks), vulnerabilities in the repository's dependencies (Trivy); every Monday and on demand, vulnerabilities in the image published on Docker Hub (Trivy). Reports in the Security tab |
| Docs | Push to main, pull request (documentation changed) |
MkDocs build; on main, GitHub Pages deployment |
| PR title | Pull request (opened, edited, pushed) | Title compliant with Conventional Commits (it becomes the main commit) |
Actions are pinned by their commit hash (uses: owner/action@<sha> # vX.Y.Z),
updated by Dependabot: to add one, resolve the tag's hash
(gh api repos/<owner>/<action>/commits/<tag> --jq .sha). Each job has a
timeout-minutes; a new push to a pull request cancels its previous run,
never those of main.
Publishing a version: push a tag on main (do not create the release in
the GitHub interface):
The workflow runs the CI, publishes the ltoinel/carbure Docker Hub image (1.2.0, 1.2 and
latest), then creates the release with its archives and the generated notes (grouped by
label, .github/release.yml). A tag with a suffix (v1.2.0-rc.1) produces a
pre-release, without the latest tag. The repository's releases are immutable: no file
can be added once the release is published, hence this order (the workflow creates a
draft, attaches the archives to it, then publishes it). The image also receives a provenance
attestation (see Security). A new release
(not a pre-release) is then announced on the #changelog channel of the
Discord server: title, link, notes and docker pull
command, through the webhook of the secret DISCORD_CHANGELOG_WEBHOOK (without this
secret, the announcement is skipped).
Actions → Release → Run workflow (tag field) rebuilds the Docker image of an existing
tag; its release, being immutable, is not changed. The tag must be an existing semantic
version (v1.2.0, 1.2.0, v1.2.0-rc.1). latest is only set on the highest
published version and 1.2 only on the highest 1.2.x: rebuilding an old
version does not move them back.
Publishing to Docker Hub (ltoinel/carbure) uses the repository secrets
DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (read/write Docker Hub access token, in
Settings → Secrets and variables → Actions).
Database migrations¶
The schema is versioned: the schema_migrations table lists the applied migrations, and the
schema version is the latest one. tools/migrate.php applies the missing ones, in
the order of their names; the Docker container runs it on every start, the setup
wizard migrates an existing database, and an administrator can apply them from the
portal banner (POST /api/system/migrate).
- Create
sql/migrations/YYYY-MM-DD_description.sql. - In it, you must declare a
-- applied-if: <query>line that returns a non-zero number when the migration is already in place (for example a column present ininformation_schema.COLUMNS): a database created fromsql/carbure.sql, or migrated by hand, is then recognized instead of failing. A test checks this. - Carry the change over into
sql/carbure.sql(new installations and tests) and add the migration's version to theschema_migrationslist at the end of the file. - As long as the migration may not be applied (an instance outside Docker not yet
updated), the code that depends on it must remain tolerant (for example by
catching the query error), or only be deployed after
php tools/migrate.php; note this inTODO.md.
Once all known databases have gone through a set of migrations, those can be
removed: sql/carbure.sql already contains them (this is what was done for version 1.0,
see sql/migrations/README.md).
Portal translations¶
The portal texts are in one JSON file per language: portal/i18n/fr.json
(reference language, complete), en.json, es.json, it.json, de.json and pt.json
(European Portuguese: Intl uses pt-PT there for plurals, numbers, dates
and <html lang>). portal/i18n.js loads them before displaying the portal: the active
language (chosen in the profile, otherwise the browser's if the portal has it, French
otherwise) and French, which serves as the fallback for a missing key (then the key
itself). Files are requested as i18n/<language>.json?v=<version> (version
given by /api/health) so that those of a previous version are not kept in cache.
With ?debug=true, a missing key is reported once in the console.
- A text is used by its key,
t('importTitle'); nested keys with a dot (months.1); parameters in braces:t('bankSetupTitle', { bank }). - Plurals: never "transaction(s)". Text that depends on a number has one
form per plural category of the language (
Intl.PluralRules, i18next convention), and the number is passed as thecountparameter:
t('rulesCount', { count: rules.length }) picks rulesCount_one or
rulesCount_other (_other if the language's category is missing: many, few…).
In French 0 and 1 are singular; in the other languages only 1 is.
- Every key exists in all languages, with the same {parameters}, with no
empty text; every key used by the portal exists and no key is unused.
tests/portal/i18n.test.mjs checks this (keys built by the code,
t('syncStep_' + étape), are recognized by their prefix).
- Files keep 4-space indentation and a final newline (checked
by the same test).
Adding a language¶
- Copy
portal/i18n/en.jsontoportal/i18n/<code>.json(BCP 47 code:de,es…) and translate each text; write the language's plural forms (_one,_few,_many,_other… according toIntl.PluralRules),_otherbeing required. - Declare the language in
LOCALESinportal/i18n.js(and inINTL_LOCALESif itsIntllocale differs, likept→pt-PT), add its<option>(name written in the language itself, withlang) to the language lists inportal/index.html(profile and setup wizard). - On the server side, add it to
Messages::LANGUAGES(accepted byPUT /userand the installation) with its texts, its amount separators and its date format insrc/lib/Messages.php, and its category and insight names insql/starter.json. - Run the tests:
node --test 'tests/portal/*.test.mjs'(the files inportal/i18n/are all checked) andcomposer test(MessagesTest,StarterTest).
Documentation¶
Conventions¶
- PHP: PHPDoc on every method, prepared SQL statements only.
- Portal: texts through
t('clé'), keys added inportal/i18n/fr.jsonandportal/i18n/en.json; plurals as_one/_otherwith{ count }. - LF line endings (
.gitattributes), commit messages in English.