Self-hosting fiscalm
fiscalm keeps invoices, statement reconciliation and the accountant's pack on your server. It prepares payment files and document downloads; a human imports payments and sends mail. There is no payment or mail-sending endpoint.
Start in Sandbox with invented data, verify the integrations you need, then enable live mode. ANAF, email, bank import and storage integrations require their own setup; installing the app does not configure those services.
Requirements
Docker path: Docker with Linux containers, Compose v2, Git and internet access to
build dependencies. A Linux desktop or server can host this path.
The verified host is Linux/amd64 with rootless Docker; the build and runtime checks
below have not established support for Docker Desktop on macOS/Windows, WSL2,
Linux/arm64 or other architectures. Multi-platform base-image manifests alone are
not an end-to-end test of fiscalm on those platforms. Script path: Linux
with systemd user services, Python 3.11+, uv, Node 22 (verified; Next.js requires at least 20.9), npm and git.
PDF statement parsing and receipt previews also need Poppler tools
(pdftotext and pdftoppm, Debian/Ubuntu package poppler-utils); the Docker
image includes them. The tick also uses bash, coreutils (timeout) and util-linux (flock). Allow disk
space for documents and backups. The Python service invokes a selected AI CLI as a child process. fiscalm is
licensed under AGPL-3.0-only (see LICENSE).
Docker quick start
Install the release source from the public main branch:
git clone https://github.com/GeorgeFL7/fiscalm.git
cd fiscalm
# main contains the latest release.
git switch main
# Replace this origin with the HTTPS address of your reverse proxy.
cat > .env <<'EOF'
FINREC_PUBLIC_URL=https://fiscalm.example
FINREC_TRUST_PROXY=1
EOF
bin/docker-build.sh
docker compose run --rm --no-deps web init
docker compose up -d --wait
curl -fsS http://127.0.0.1:8790/healthz
For reproducibility, select a reviewed release tag or source commit before building. Keep the source directory for Compose commands; keep the same Compose project name when moving it.
Configure the reverse proxy for that origin before opening the setup link. The
HTTPS origin must be set before init and before the web listener starts; the
loopback HTTP endpoint below is only a liveness check.
The build compiles the static app in a Node stage and installs locked Python
packages in a slim runtime. All base images are pinned by multi-platform digest;
update those digests deliberately for security fixes. There is no published fiscalm
container image. FINREC_IMAGE selects the local image name. Use bin/docker-build.sh
for builds: it supplies FISCALM_VERSION from pyproject.toml. Plain
docker compose up -d --build does not supply that required argument on a clean shell.
/healthz reports process liveness, not working credentials or integrations.
Without company flags, init creates a private install with no companies. Save the one-time setup link and API token to a password
manager when shown: repeated init does not reveal them again. Open the setup link
under your HTTPS origin, choose a password of 12–128 characters, enrol an authenticator
with its QR code and confirm a TOTP code. Save the ten recovery codes shown once. Add the first company by CUI in the app as an admin,
then set its first RON account. Until a company exists, accounting commands refuse
with Add a company in fiscalm first. The setup state disappears immediately
after addition, without restarting the server. No payment file can be built until
the RON account is configured and live mode is enabled. Explicit company flags remain supported for CLI
provisioning. For a fresh installation, pass company flags to the first init
instead of running the empty-ledger init above (synthetic data):
finrec init --company-key EXAMPLE --cui 12345674 --legal-name "Example SRL" --iban RO35TEST0000000000000000
# With Compose: docker compose run --rm --no-deps web init --company-key EXAMPLE --cui 12345674 --legal-name "Example SRL" --iban RO35TEST0000000000000000
After an empty-ledger init, add firms in fiscalm. Existing firm identities are never replaced by init.
Init prints credentials to stdout; Docker captures that
output in container logs. Use --rm for init, restrict Docker/log access, and do
not copy initialization output into shared logs or tickets. Before configuring HTTPS, SSH forwarding can provide a
private local check: ssh -L 8790:127.0.0.1:8790 user@server.
Compose publishes only to host loopback. The explicit 0.0.0.0 listener is inside
the container network. Do not publish it directly to the internet. The image runs
as uid 10001; named volumes retain ownership. Data is in the data volume and
credentials in secrets; scheduled snapshots use /data/backups in the data volume.
The scheduler shares the data and credentials, running every six hours by
default and checking for manual-check requests every five seconds. Its tick lock
prevents overlapping runs. The next due time survives restarts; a fresh scheduler
waits one interval before its first automatic tick. Failed ticks log and wait
until due again. Container shutdown forwards SIGTERM to a running tick and
allows 60 seconds for it to stop. To change the interval or enable a trusted proxy,
put these non-secret settings in a local .env file (ignored by git):
FINREC_PUBLIC_URL=https://fiscalm.example
FINREC_TICK_INTERVAL=21600
FINREC_TRUST_PROXY=1
FINREC_PUBLIC_URL is the install's public HTTPS address (the hostname of the reverse proxy below), set once. Upload
links are built on it, and ANAF sends the browser back to <it>/anaf/callback after the certificate login.
Then recreate with docker compose up -d --wait. FINREC_PORT changes the host-published
port. To override other runtime settings, use a Compose override file with
environment and any required read-only mounts on all three services.
Linux install script
Run as the account that will own fiscalm, from a clone in that account's home:
bin/selfhost-install.sh --data "$HOME/.local/share/contahub" \
--secrets "$HOME/.config/contahub/secrets" \
--public-url https://fiscalm.example
The script checks tools, syncs the Python environment, builds the app, generates
user units, initializes the first Administrator and starts the web/timer units.
At the end it prints the one-time setup link. No separate finrec init or service-start
command is needed for a normal script install. Default host is
127.0.0.1, port 8790. Flags: --data, --secrets, --units, --host, --port, --public-url,
--trust-proxy, --replace-existing, --no-enable. --public-url https://… sets the install's public address
(FINREC_PUBLIC_URL in service.env); without it, on a terminal, the script asks for it once. It is added to an
existing service.env when missing and never replaced. The last flag writes units without activating them.
For an unattended account enable lingering (loginctl enable-linger, which may
require administrator help). Root execution is refused. Generated units carry
# generated by selfhost-install; unmarked units
are refused unless --replace-existing explicitly authorizes replacement. Back
up existing units and service.env before using that flag. The installer warns
when requested listener flags differ from preserved service.env settings, and
when binding 0.0.0.0.
Units use PrivateTmp, ProtectSystem=strict, ProtectHome=read-only and writable exceptions for the configured data/secrets directories. These user-service sandbox features require a kernel/systemd setup permitting unprivileged user namespaces. Verify support before starting; do not silently remove protections. If service.env overrides writable paths, update ReadWritePaths in the units to match those paths before restarting.
The generated <data>/service.env supplies all runtime paths. It is preserved on
upgrades, so edit it to configure integrations or change host/port after install.
The installer accepts the generated KEY="value" syntax (double quotes, with only
backslash/quote escapes), simple unquoted values without spaces, and whole-line
comments. Other EnvironmentFile syntax is refused before builds or initialization;
put paths containing spaces in double quotes. An existing public URL must be a valid,
non-empty HTTPS origin; --public-url never replaces an existing invalid value.
Restart the web service after edits. Optional maintenance CLI commands must use
the same FINREC_* settings as that file.
The installer never copies credentials from another account. Open the setup link,
configure password+TOTP or a passkey, save the recovery codes and continue directly
to onboarding, without another login. Passkeys need a DNS HTTPS FINREC_PUBLIC_URL,
for example Xavier's Tailscale HTTPS name; password+TOTP also works on an HTTPS IP
address. Configure the HTTPS proxy before opening the link.
Only an Administrator sees and saves the installation wizard. Other signed-in roles see that the administrator is still configuring the install. Progress is stored in the ledger: closing the browser resumes at the first unfinished step.
The steps are Mod → Firma → ANAF e-Factura → Banca → E-mail → Facturi anterioare → Magazine → AI → Finalizare. Mod and Firma are required; all six integration/import steps offer Mai târziu. Sandbox selects ANAF Test, Live selects ANAF Producție. Banca saves whether statements arrive by mail and/or open banking; manual upload is always available. Mail is read-only, and statements from mail need that connection. Without AI, unclear cases go to De lămurit. Finalizare lists connections and deferred steps with links to Setări. All configurations remain available there; Setări › Mod changes Sandbox/Live using an administrator's existing sensitive-action identity confirmation.
Check liveness with curl -fsS http://127.0.0.1:8790/healthz (use the configured
port if changed).
For diagnostics use systemctl --user status finrec-web.service finrec-tick.timer finrec-backup.timer and journalctl --user -u finrec-web.service -u finrec-tick.service -u finrec-backup.service.
To listen on the Tailscale address use --host tailscale; it refuses if no Tailscale IPv4 is found.
Configuration
Use the same environment for CLI, web and scheduler. Firm facts, accounts and
aliases live in state/ledger.db. Add firms by CUI in fiscalm and edit their settings
there; no company configuration file is read or imported. finrec init prepares an
empty ledger for the setup flow. Optional company flags create the first firm only.
finrec --home PATH selects the ledger and supplies default secret paths for an isolated install.
Environment variables
| Variable | Engine default | Purpose |
|---|---|---|
FINREC_HOME |
/data/finance |
Ledger (state/ledger.db), documents, logs, live marker, integration settings |
FINREC_SECRETS |
~/.config/finrec/secrets |
TOTP seeds, statement passwords and ANAF credentials |
FINREC_PUBLIC_URL |
unset | Public HTTPS origin for browser sign-in and integration callbacks |
FINREC_HOST |
127.0.0.1 |
Listener address; tailscale resolves local Tailscale IPv4 |
FINREC_PORT |
8790 |
Listener port; CLI serve --host/--port overrides host/port env |
FINREC_TRUST_PROXY |
unset/off | 1, true or yes trusts X-Forwarded-Host/Proto/For; the proxy must replace all three, never pass client values through |
FINREC_APP_OUT |
clone's app/out |
Built static export |
FINREC_SCHEDULER |
unset/systemd | 1 queues manual checks for the shared-volume scheduler |
FINREC_TICK_INTERVAL |
21600 |
Container scheduler seconds between ticks, integer at least 5 |
FINREC_BACKUPS |
<FINREC_HOME>/backups |
Scheduled local archives; Compose sets /data/backups |
FINREC_MCP_TOKEN |
unset | Bearer credential injected into a local stdio MCP process |
FINREC_ANAF_ENV |
prod |
ANAF host selection: prod or test |
FINREC_RCLONE |
~/.local/bin/rclone |
Optional archive executable |
FINREC_CLAUDE |
claude |
Claude Code executable override (explicit AI integration required) |
Firms and accounts
Use fiscalm, Setări, Firme to add firms by CUI, choose their display names and colours, and manage their bank accounts and aliases. The ledger stores those facts together with their transactions. One account per currency can prepare payments; a replaced account stays attached to the firm for historical matching. An IBAN cannot belong to two firms. Company identities and CUIs cannot be rewritten by initialization. The accountant is configured per firm in Setări, Contabil.
Statement mail senders come from the bank catalogue for current accounts. Retired accounts and banks without a published sender add no mail source.
Secrets and access
The secrets directory is private (0700), files 0600, never committed:
<FINREC_SECRETS>/
int-<id>-<field>-<version> # an integration's secret field (the ANAF client secret, a bank's statement password)
int-<id>-oauth-<version> # an OAuth connection's tokens (ANAF), written and refreshed by finrec
Mail access is read-only IMAP over TLS, configured in Setări › Integrări: host, port, username, password, own address and aliases. Citire facturi enables reading the selected folder (default INBOX). fiscalm never requests APPEND/write capabilities, changes message flags, writes drafts, or sends mail.
Client invoices expose PDF and XML downloads. Accountant reports offer
Pregătește dosarul, then Descarcă ZIP and Marchează ca trimis.
Preparing freezes that company's exact document set and ZIP version. Confirmation
marks only those documents as sent; later arrivals wait for a later pack. No mail
account is needed. CLI: finrec pack build --company EXAMPLE --month 2026-10,
then finrec pack mark-sent --company EXAMPLE --month 2026-10 --version SHA256.
The build result names the local ZIP; the version is its SHA-256.
Payment reminders show evidence-checked text with Copiază textul, in live and
shadow. Copying never marks a payment collected or sends a message.
The account schema stores stable user IDs and installation-wide roles. It has no legacy link-name mapping or per-company access table/flag. Product schema upgrades preserve accounts and their session, setup-link, recovery-code and token ownership relationships; an integrity failure rolls the account-table change back.
Successful sign-in or setup closes the session previously presented by that browser, including when switching accounts. Replacement is atomic: a failed attempt keeps the previous session; other devices stay signed in.
The app confirms the current identity when returning to a tab and after a session change in another tab. Private controls and open sheets are suspended while that read is unconfirmed; a failed read offers Reîncearcă. Same-account edits remain, and changing accounts clears the previous private view. Cross-tab notification stores only a random nonce, never credentials. Returning to setup/recovery keeps its code acknowledgement; another tab changing the session discards the old flow.
Accounts, roles, password hashes, session hashes and recovery-code hashes live in
the ledger. init creates the first Administrator and prints a setup link that
expires after 72 hours and works once. The secret is in its URL fragment. TOTP
seeds live only in private auth-totp-<user id>-<version> files in FINREC_SECRETS.
Each installation and restore check needs its own secrets directory.
Passkeys are bound to the DNS host of FINREC_PUBLIC_URL; an HTTPS IP address
does not support them. The credential id, public key, counter and backup metadata
stay in the ledger, while the private key stays with the authenticator. Changing
the public host requires key re-enrolment or an administrator/server CLI reset.
Code-only passkey recovery is restricted to verified key replacement; it never
grants financial access or step-up, and finalization closes every session.
On the sign-in screen, choose Folosește o cheie de acces, then confirm it on the
device. Existing owned keys can also confirm the same account for sensitive actions.
This choice requires both server availability and a compatible browser. Returning
from its native prompt preserves the pending confirmation; changing accounts cancels it.
The browser setup page removes the setup secret from the address bar immediately. On a DNS HTTPS install with a compatible browser, Folosește o cheie de acces creates and verifies the account's first passkey instead of configuring a password and TOTP. Name the key, confirm it on the device and save the ten recovery codes before continuing. If the completion answer is lost, Verifică accesul la cont opens an explicit check; sign in with the new key if necessary and replace unsaved codes in Cont. Setup is never resubmitted automatically. Cont shows configured key counts and each session's actual sign-in method. Password/TOTP change controls apply to an existing configured pair. Administrators see key-only users as configured and can reset them through a new setup link; the reset confirmation includes removal of their passkeys. In Cont › Chei de acces, every role can add a key on a compatible device, rename it or remove it after confirming identity. An additional key enables sign-in when the first device is unavailable. The last key cannot be removed without another complete sign-in method. After an unreadable write response, refresh the list and check the result before trying again. For password-and-TOTP setup, the QR code is generated locally, without an external QR service; manual entry of the same key is also available. If the five-minute enrolment expires or five code attempts are used, choose Reîncepe configurarea and start a fresh enrolment. This is explicit; an unconfirmed completion is never retried on its own. After TOTP confirmation, the ten recovery codes appear once and can be copied or saved as a text file. Save them separately from the password and confirm they are saved before continuing.
If the authenticator is lost, sign in with the password, then choose Folosește un cod de recuperare. This opens only account recovery, with no financial views. Enrol the replacement authenticator and save the new recovery codes. Completion closes all sessions; sign in again using a new authenticator code. The old authenticator and recovery-code set no longer work. For an account using only passkeys, choose Recuperează accesul cu un cod on the sign-in screen and enter the username and a saved recovery code. This opens only key replacement. Name and verify a new key, save the new codes, then sign in again with that key. The old keys, old codes and sessions are retired; completing recovery never opens financial views. If the first recovery response is lost, Verifică recuperarea contului checks the existing session without spending another code. Password/TOTP recovery keeps its separate authenticator flow.
Setări › Cont is available to every normal role. It shows account metadata, remaining recovery-code count and the account's sessions. Close an individual other session or all other sessions after confirming the action and, when requested, confirming the current identity again. Ieși din cont closes the current browser session. Before the first company, Cont is also available below setup. An unconfirmed close refreshes metadata and keeps a warning; it never repeats the mutation automatically. The same page changes the password, configures a replacement authenticator and generates a new recovery-code set. Each requires recent identity confirmation. Password changes close other sessions; authenticator replacement closes other sessions and replaces recovery codes after a successful new TOTP. Save the replacement codes before continuing. Failed or expired enrolment can be restarted explicitly; an unconfirmed mutation is never repeated automatically.
In Setări › Utilizatori, an Administrator can inspect another account’s active sessions and close them individually after confirming identity when requested. The administrator’s own sessions link to Cont, where current-cookie sign-out remains separate from closing other sessions. An uncertain close refreshes the selected account’s session list and keeps the warning without repeating the request.
The server CLI works in Sandbox, without a company configured:
finrec user list
finrec token create --user admin --name "Reporting" --scope read
finrec user create operator --role member --display-name "Operator"
finrec user create reader --role readonly
finrec user setup-link admin
finrec user reset operator
finrec user unlock operator
finrec user role operator member
finrec user disable operator
finrec user enable operator
create prints the new account's setup link. setup-link replaces an unused or
expired link for an account awaiting setup. reset removes the account's factors,
recovery codes and browser access immediately and prints a new setup link; server
access can recover the last administrator this way. A configured account requires
an explicit reset. The last working administrator cannot be disabled or demoted.
Passwords are entered only in the browser, never in CLI arguments. Administrator,
Membru and Doar citire apply to every company in the installation. API and MCP tokens
belong to an account and never exceed its current role; a disabled owner cannot
authenticate. finrec token list includes each token’s owner. No ownerless token
works. Token creation and revocation are audited without storing secrets.
Setări › Utilizatori lists global account roles, factor status, last sign-in, and lock state. Administrators can add accounts, change names and roles, disable/reactivate or unlock accounts, and create one-time setup/reset links. A reset of the current account opens its new setup flow directly. The server keeps the last-working-administrator guard; no company-by-company grants exist. The browser administration API lists account roles, factor status, last sign-in, lock deadline and active sessions. Creating/updating/disabling users, issuing a new setup link, resetting/unlocking an account or closing its session requires a fresh administrator sign-in (10 minutes). When it is older, the browser keeps the current form and opens Confirmă identitatea for the same account's password plus TOTP or an owned passkey, when available. It retries the refused operation once after confirmation. Renunță leaves the operation unperformed; a lost network response never triggers an automatic retry. A reset preserves the account and role, clears its factors and sessions, and reveals a new setup link once. API audit metadata records the administrator's stable ID without secrets.
Normal accounts list/create/revoke their own API tokens in Setări › Acces or
Setări › Cont; Cont also works before the first company.
Read-only accounts can create only read tokens; other roles may choose read/write.
Creation requires recent authentication. Administrators can inspect and revoke all
keys, with their live role checked at the revocation write. The server CLI remains
available for administrators choosing an explicit owner with --user. The UI
shows each key’s owner and displays its secret only once. An unconfirmed mutation
refreshes metadata and keeps a warning without automatically repeating the POST.
Bondrop controls require Membru or Administrator; Doar citire never reads the firm’s
upload-link data or offers a write token.
Browser actions, statement/report uploads and history imports recheck the current session and role in every outer ledger write transaction. API v1 and MCP do the same for the token, its owner and effective scope. Parsing and other I/O do not hold the database write lock; nested writes share one atomic authority decision. Disabling, demoting or revoking a credential before the next transaction prevents its writes.
The HTTP listener requires a configured HTTPS FINREC_PUBLIC_URL before it starts. Browser sign-in requires HTTPS, including loopback. The cookie is Secure, HttpOnly, SameSite=Strict and scoped to its host. A password alone never signs in. For password/TOTP accounts, recovery uses the password and one unused recovery code, replaces TOTP, displays new recovery codes and closes every session. Sign in again with the replacement. No login or recovery mail is sent.
Every normal account can inspect and close its own sessions or regenerate recovery codes through the account API after recent authentication. Closing other sessions keeps the current one; closing the current session clears its cookie. Regeneration reveals ten new recovery codes once and invalidates all previous codes. These local operations also work in Sandbox and for read-only accounts.
Normal accounts of every role with a configured password/TOTP pair can replace the password or TOTP after fresh confirmation with that pair or an owned passkey. Password replacement closes other browser sessions and pending authentication tickets. TOTP replacement keeps the old factor active until a new code is verified, then closes other sessions and displays new recovery codes. A failed replacement preserves the working sign-in pair. Removing the only password/TOTP pair requires an administrator reset and a new setup link.
The scheduled tick removes expired or revoked sessions, tickets and setup links,
including before any company is configured. Failed sign-in audit events are kept
for 90 days; older security events remain, with their IP addresses cleared. Active
sessions last at most 30 days, so their IPs are removed earlier with the session.
It also removes abandoned and superseded TOTP files and interrupted private writes,
only for user IDs in this installation. Files belonging to other user IDs and other
integrations are untouched. finrec auth sweep runs the same local cleanup manually.
FINREC_PUBLIC_URL must be an HTTPS origin without a path, credentials, query or
fragment; one trailing slash is stripped. Receipt links use it (HTTP is accepted
only for loopback); without it they use the app origin. Invalid origins refuse
link creation before a credential is stored. ANAF requires HTTPS and the registered <origin>/anaf/callback.
The initial API write token is shown once; only its hash is stored. Create and revoke further tokens as described in the API/MCP guide.
Add companies after installation
fiscalm's company configuration API can preview an ANAF CUI, add an active firm and update
its display name or palette, including in Sandbox. CLI parity:
finrec company add --cui 12345674 [--name Exemplu] [--palette blue].
The CLI and API share validation, ANAF lookup, locking, backups and atomic persistence.
See the generated contract's x-app-actions metadata in openapi.json.
Company changes commit atomically to the ledger and are visible to web, CLI and scheduler reads immediately. New firms start without a paying RON account; add one in fiscalm before preparing payment files. Backup and restore carry all firm facts in the ledger snapshot.
Document archive
In Setări › Integrări, add an Arhivă Google Drive for each firm: company key,
rclone remote name (for example drive:), Google Drive root folder ID and layout.
The remote must be a Google Drive remote configured on the server; credentials stay
in rclone's private configuration. Local filesystem paths and inline backend
configuration are refused. Give each firm its own root.
Layouts are fixed: month creates YYYY-MM/Cheltuieli and YYYY-MM/Extrase as
needed; quarter creates YYYY/Qn/YYYY-MM/<kind>. Filenames are preserved, including
account-specific statement filenames. Exactly one enabled storage integration must
match the firm; defaults do not override this rule. Missing, disabled or ambiguous
configuration refuses the archive.
Only live mode uploads or calls rclone. Dry runs make no remote requests. Sources must be regular files within the firm's own data directory, checked on the opened descriptor, at most 50 MiB. A private staged copy prevents path swaps from changing the upload. Files already present are compared by MD5 and never overwritten; conflicting or ambiguous names refuse and retry on the next run. Successful copies are recorded in the ledger; Drive folder IDs are rediscovered on retries, without a separate folder map. Changing a destination or layout affects unarchived documents only; previously archived documents are not copied again.
Going live archives the complete eligible ledger backlog, including history imports; there is no start-month cutoff. The old empty income folder is not created. Archive sources are company-owned files only. Phone receipts use Bondrop; unreviewed uploads remain excluded until reviewed and copied into the firm's storage. If duplicate Drive folders exist, an administrator must consolidate them under the configured root before retrying. Folder lookups are cached within a run; no cache survives a run or a configuration change. The remote type is checked before use.
HTTPS reverse proxy
Use a hostname you control and replace contahub.example below. Keep the backend
reachable only by the proxy. Set FINREC_TRUST_PROXY=1 in Compose's .env, or
--trust-proxy on the initial script install. On an existing script install edit
service.env and restart. With trust enabled, the proxy must replace
X-Forwarded-Host, X-Forwarded-Proto and X-Forwarded-For. Never pass through or append
client-supplied values: X-Forwarded-For must contain only the client's address as
seen by the proxy, or the client could choose its sign-in rate-limit budget.
X-Forwarded-Host must include the public port when it is not 443. These headers
are ignored by default.
App/API/MCP Origin checks use the configured public origin. Keep authentication
request bodies, Cookie and Authorization headers out of proxy logs. Receipt and
integration capability URLs also stay private. Do not add third-party analytics.
Caddy (automatic certificate management when DNS and inbound ports permit):
contahub.example {
header Strict-Transport-Security "max-age=31536000"
reverse_proxy 127.0.0.1:8790 {
header_up Host contahub.example
header_up X-Forwarded-Host contahub.example
header_up X-Forwarded-Proto https
}
}
Do not enable Caddy access logging for this site. Also configure the proxy's error logging to discard or redact credential-bearing URLs. nginx (provision certificates separately and replace the certificate paths):
server {
listen 80;
server_name contahub.example;
access_log off;
error_log /dev/null;
return 301 https://contahub.example$request_uri;
}
server {
listen 443 ssl;
add_header Strict-Transport-Security "max-age=31536000" always;
server_name contahub.example;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
access_log off;
error_log /dev/null;
location / {
proxy_pass http://127.0.0.1:8790;
proxy_set_header Host contahub.example;
proxy_set_header X-Forwarded-Host contahub.example;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
These examples assume the proxy is on the host. A containerized proxy needs a
private shared network and must replace forwarded headers the same way. HTML has
a CSP that allows only local resources and hashes the export's inline bootstrap
scripts, forbids framing, and sends no referrers. /healthz is intentionally
unauthenticated; it reports process liveness only, with no financial/config data.
Going live and integrations
An administrator selects Sandbox or Live in Setări › Mod.
The browser asks for confirmation of identity with the existing password+TOTP or passkey.
The change is audited with the acting account. No CLI command is needed.
The ANAF upload/status environment follows the mode: Sandbox → TEST, Live → production.
Inbox reads stay in production in either mode. FINREC_ANAF_ENV is only a development/test override
for both environments; omit it on self-hosted installs. The commands below remain available to server operators.
docker compose exec web finrec integration
docker compose exec web finrec live status
docker compose exec web finrec live on
# To disable outward effects:
docker compose exec web finrec live off
For a script install, use .venv/bin/finrec with the same environment as the
service. live on refuses an empty firm registry. It creates
<FINREC_HOME>/state/LIVE, the only marker; off removes it.
Sandbox allows every operation confined to the ledger and this installation's
storage: explanations and remembered rules, settlement and receipt confirmations, card owners,
marking paid (awaiting proof), statement/report uploads, history imports, firms and integrations.
It allows manual SPV/ANAF uploads only to ANAF TEST and refuses production uploads, nudges, webhooks, marketplace write-backs
and payment-file generation/downloads. Local work retains every authorization and money guard. Read-only integrations can also contact external services in Sandbox; Sandbox is not
an offline mode. ANAF reads, IMAP intake and bank reads use their respective credentials and
configuration. Adding a firm by CUI contacts ANAF’s public service without credentials. The configured AI integration also works in Sandbox:
its configuration by the administrator consents to document text being sent to that provider. AI only assists
where rules cannot decide; checked answers never choose the firm or settle a payment.
Existing payment files encountered in Sandbox are journalled and moved to
.shadow/*.blocked under the payment folder, preserving their bytes
but taking them out of circulation. Unpaid rows then appear as a missing file; changing the paying
account stays blocked until that state is resolved. Activation alone does not restore them;
a previously committed live import/merge journal can still finish its authorized recovery.
A changed file, invalid journal or symlink stops recovery for manual investigation.
New init stays in Sandbox. Repeated init preserves an already-live install.
check may queue a tick in either mode. Live still never sends money
or mail. Configure external services under configuration and follow ANAF setup. Integrations are set up in the app, Setări › Integrări; ANAF is connected
there with "Conectează ANAF", which needs FINREC_PUBLIC_URL (configuration). integration reports prerequisites and the
ANAF connection's state without network requests or secret values. Optional rclone is not bundled in the core Docker image; extend it and mount its config if needed.
AI providers and Docker
The first-start web wizard and Setări › Integrări › Serviciu AI offer no AI, an Anthropic API key, an OpenAI-compatible or local server, Claude Code, and Codex. Choosing an integration is explicit consent for document processing, including in Sandbox. There is no automatic CLI fallback. Existing installs that relied on that fallback must select a provider once after upgrading; disabling every AI integration disables AI.
An API key or an OpenAI-compatible/local server is recommended. Ollama on the host needs a /v1 base URL
reachable from the container; 127.0.0.1 inside Docker points to the container, not the host. Restrict the
server's listener to the installation. Receipt images need an API provider with image support; CLIs handle text.
The native installer (bin/selfhost-install.sh, also reached by bin/install.sh for an existing registered service) installs both official
npm packages in ~/.local/share/fiscalm-ai using deploy/ai-cli/package-lock.json. Node 22+ is required.
The generated web/tick units include this directory in PATH, including on upgrades; an explicit PATH in service.env takes precedence. CLI auto-updates are disabled by default.
The Docker image builds both packages from the same lockfile: Claude Code 2.1.316 and Codex 0.166.0.
No CLI is downloaded by the web app, and no login or credential is included in an image. Users build
images locally with bin/docker-build.sh; this project does not publish them.
For personal installs, select “Folosește Claude Code de pe acest calculator” or “Folosește Codex de pe acest calculator”. The subscription is for a personal computer or a home/office server. A cloud server (VPS) or an installation serving other firms should use an API key. This note is always shown for CLI choices; fiscalm makes no IP, ASN or cloud-metadata lookup.
Administrators can connect and disconnect in the web UI, including in Sandbox. fiscalm relays the official
CLI's login prompts: only provider-owned HTTPS URLs and a short device code are shown. Claude's pinned flow also uses the official claude.com / platform.claude.com domains. A pasted authorization
code is sent directly to the CLI's stdin. No raw CLI output or credential is returned or logged. Only one
login can run at a time; it expires after three minutes, and cancellation kills its process group. Status
and logout also use the official CLI. fiscalm never uses setup-token or sets CLAUDE_CODE_OAUTH_TOKEN.
The CLI writes its own configuration and credentials. Native installs use the install user's home; the
units allow the CLI directories to refresh credentials. In Compose, web and scheduler share the named
ai-cli-home volume at /home/contahub. It is outside /data and /secrets, is not mounted by the backup
service, and is not part of fiscalm backups. fiscalm does not read or copy those files. Back up or reauthenticate
the CLI separately if the volume is replaced.
Terminal login is an alternative, as the install user:
# Native Claude Code
~/.local/share/fiscalm-ai/node_modules/.bin/claude auth login --claudeai
# Native Codex
~/.local/share/fiscalm-ai/node_modules/.bin/codex login --device-auth
# Docker Claude Code
docker compose exec -it web claude auth login --claudeai
# Docker Codex
docker compose exec -it web codex login --device-auth
If an executable is missing, Integrări shows bin/install.sh --data … --secrets … with the current native paths, or
bin/docker-build.sh && docker compose up -d for Docker. Use the same installation paths on native upgrades.
Judge requests use structured JSON and checked answers, with no firm choice or settlement by AI. Claude Code runs with built-in tools disabled. Codex 0.166.0 has no switch removing every tool: the maintainer explicitly accepted read-only sandboxing with shell and web search disabled; other tools remain exposed by the CLI. User configuration, apps, plugins and agent tools are disabled for judge calls, and untrusted text is supplied as document data. A failed call or invalid JSON produces no decision.
Statement imports
Supported bank statements: BT monthly PDF (encrypted or unprotected) and ISO 20022 CAMT.053 XML
(camt.053.001.02 through .08), selected by content. CAMT.053 is accepted by an administrator in Tranzacții, or from the operator CLI:
finrec ingest FILE (store and reconcile) or finrec statement FILE (inspect). Mail and public
receipt links do not accept CAMT. XML is unsigned text: a matching IBAN and consistent balances
do not authenticate its author. Only full calendar months are accepted; a different document
covering an already imported account-period is refused.
Only booked entries reconcile. Each account must belong to the configured company and its OPBD (or PRCD)/CLBD
balances must agree with parsed credits and debits; an untrustworthy account settles nothing.
Batches require individual amounts in the account currency and stable bank references. Reversals,
missing references, mixed batch directions and unsupported precision require manual review.
MT940 follows the same rules as CAMT: the accounts are the company's, opening and closing balances chain and add
up, one full calendar month, booked lines only; reversals are refused. CSV exports are read with the columns picked
once per account in fiscalm (date, amount or debit and credit, details, optionally the other party, its IBAN and the
reference); a CSV names no account, so it is uploaded on one, and it carries no balances, so its document says its
totals can't be checked. A CSV is a partial import: it proves only the days it holds (CSVs of one account add up day
by day, overlapping days are refused), it never stands for the month's statement (the payment file still waits for
one), and two identical rows are refused (map the bank's reference column). Amounts must be written as the mapping
says (thousands only by threes, at most two decimals). The same CSV read on another account or with other columns is
another document. MT940 needs final opening and closing balances (60F, 62F), no missing days between daily
statements, and lines that add up to the balances. MT940 and CSV are accepted from the operator CLI (MT940) and from an administrator's upload
in fiscalm (both); never from mail or public receipt links. A stored CSV keeps its account and columns beside it, so
it can be read again.
Hardened systemd integrations
The generated units make the home directory read-only, with writable exceptions for FINREC_HOME, FINREC_SECRETS, backups and the official CLI configuration paths above. CLI credentials remain outside the data and secrets directories and outside backups.
rclone refreshes OAuth credentials by rewriting its config, including temporary files in the config directory. Add a narrow drop-in for the actual rclone directory if that optional integration is used:
# systemctl --user edit finrec-tick.service
[Service]
ReadWritePaths=-%h/.config/rclone
Create the directory with private permissions as the service account, then run
systemctl --user daemon-reload. Do not make all of HOME writable.
The web action runs systemctl --user start finrec-tick.service. This requires
the same service account's running systemd user manager, a discoverable user bus
(typically XDG_RUNTIME_DIR=/run/user/<uid> and its bus socket), and permission to
connect to it from the unit's sandbox. Lingering keeps the manager available
without a login. The generated sandbox's end-to-end D-Bus activation has not
been verified; rendering tests and mocked systemctl calls do not establish it.
Before relying on the action, test a check request from the running hardened
web service and inspect the tick's user journal. Do not relax the sandbox
globally to bypass an unexplained failure.
Backup and restore
Run as the service account, with the same FINREC_* paths as the service:
finrec backup /safe/place/books.zip
finrec backup /safe/place/books-with-secrets.zip --include-secrets
# The second command prompts for a passphrase on the terminal; never put it in argv.
The ZIP contains a manifest (format, schema version, UTC creation time, source home and SHA-256 per member), an SQLite online snapshot, company documents, e-Factura files, history, inbox, pending mail documents, payment files, and firm facts in the ledger. Logs, scheduler state, service.env and the LIVE marker are excluded. Only the configured company directories and product document roots are included; arbitrary files placed elsewhere in FINREC_HOME are not a document store. Symlinks and special files refuse the backup. A changing document tree refuses publication; retry after the import finishes. SQLite uses its online backup API in small page steps; document copying and compression hold no database lock, so writers can continue. Unfinished file-move journals refuse backup and restore; finish source recovery first. The completed archive is private (0600) and never overwritten. Keep the destination outside document roots. Allow free disk space for staging.
Secrets are excluded by default. --include-secrets puts FINREC_SECRETS into an authenticated AES-256-GCM payload, with a random salt
and nonce and scrypt (N=32768, r=8, p=1) passphrase derivation. The secrets ZIP is
built in memory, never staged in clear on disk. Books and company configuration
remain in clear in the outer ZIP: store archives on a private encrypted disk.
Keep the passphrase separately; it cannot be recovered.
Stop the services for the destination, then restore into a new empty directory:
finrec --home /new/finance restore /safe/place/books.zip
# For an archive containing secrets:
finrec --home /new/finance restore /safe/place/books-with-secrets.zip --with-secrets
Restore works into an empty home. It validates every checksum,
SQLite integrity and foreign keys, and refuses schemas newer than this code.
It refuses nonempty homes, duplicate members, path escapes, symlinks, unexpected
runtime state and wrong/missing passphrases. Validation happens in a private
sibling directory; failures leave the destination untouched. Document references under the original home (including its symlink alias) are
relocated to the destination. Ledger file references outside that home or containing
path traversal refuse restore; documents must live in the product storage roots. It never
runs migrations or restores state/LIVE: the copy starts in Sandbox.
Set FINREC_SECRETS=/new/finance/secrets for the restored files, using a
directory isolated from every running installation. Before starting the restored
web service, run finrec --home /new/finance auth forget-sessions to revoke restored
cookies, tickets and setup links. Review integrations and paths before explicitly selecting Live in Setări › Mod.
Without secrets, existing passkeys remain usable at the same DNS HTTPS origin.
To replace a missing TOTP seed, sign in with an owned passkey and choose
Cont › Schimbă aplicația de autentificare. If no working sign-in method is
available, run finrec --home /new/finance user reset admin and configure a new
password/TOTP pair or passkey through its setup link. Reconfigure integration
credentials separately.
The installer enables finrec-backup.timer (daily, persistent, random delay up to
15 minutes). It runs finrec backup --scheduled, keeping the newest seven local
archives only after a successful backup. FINREC_BACKUPS in service.env defaults
to <FINREC_HOME>/backups; an external path must also be allowed by the unit's
ReadWritePaths. To change retention, override ExecStart in a backup service drop-in
with finrec backup --scheduled --keep N. Backups exclude secrets so the timer
needs no passphrase. Inspect journalctl --user -u finrec-backup.service for failures.
Compose includes a separate backup service. Its archives live in /data/backups
in the data named volume, initialized for the image's user. Losing that volume
also loses these local snapshots; keep an independent off-host copy. No host-directory
setup is needed. Copy archives independently of the installation: docker compose down -v removes these local snapshots too. Set FINREC_BACKUP_INTERVAL (seconds,
at least 60, default 86400) and FINREC_BACKUP_KEEP (default 7).
The service backs up on startup and at the interval, logs failures, and retries at
the next interval. A manual scheduled snapshot uses the web service's CLI entrypoint:
docker compose run --rm --no-deps web backup --scheduled.
Administrators see the last completed local backup and can download it in Setări;
members cannot read metadata or download books. Retain an independent off-host copy; document uploads are not a backup.
Container backup and restore test
# Paths here are inside the container. Keep web running for cp/exec below.
docker compose run --rm --no-deps web backup /data/manual-backup.zip
# Copy privately to the host before replacing volumes.
docker compose cp web:/data/manual-backup.zip "$HOME/fiscalm-manual-backup.zip"
# Restore into an empty directory, never over the running data.
docker compose run --rm --no-deps web --home /data/restore-check restore /data/manual-backup.zip
A restore refuses a non-empty destination, damaged archive or newer schema. It
rewrites stored document paths for the new root. To exercise the restored copy,
use both data and secrets overrides, because --home preserves environment variables that are already set:
docker compose exec -e FINREC_HOME=/data/restore-check \
-e FINREC_SECRETS=/data/restore-check/secrets web finrec live status
# After exporting and testing, remove only these temporary copies.
docker compose exec web rm -rf /data/restore-check /data/manual-backup.zip
Use those overrides for every command against the restored copy. Accounts, password hashes
and recovery-code hashes are in the ledger; TOTP seeds are in FINREC_SECRETS. If seeds
were excluded, use finrec user reset <username> to set up the affected accounts again.
Never enable LIVE in a restore test. Full stopped-volume backups additionally preserve credentials
and integration configuration outside the snapshot. Protect both kinds as private
financial data; a snapshot without secrets still contains company documents.
Release notices
On by default, each day's first scheduled tick (finrec update-check) makes an anonymous
GET to the fixed public endpoint https://api.github.com/repos/GeorgeFL7/fiscalm/releases/latest.
This read also runs in Sandbox. No authentication, install data, company facts,
version, identifiers or telemetry are sent (GitHub sees the connecting IP).
The request has a four-second socket timeout (ten seconds for the tick command), a 64 KiB response cap and refuses redirects.
Only tag_name, html_url and published_at are retained; malformed responses
are ignored and retried the next day. Prereleases are ignored. A newer stable
SemVer appears in Setări, with a dot on its entry and a validated GitHub release
page link. A failed check never prevents accounting work.
Administrators can turn off Verificarea versiunilor noi in Setări; the preference
is stored in the ledger for the whole installation. Turning it off also hides any
cached notice. No update is downloaded or executed. The Docker update notice currently displays
git pull && docker compose up -d --build, but that shortcut omits the required
build argument. Use git pull --ff-only, bin/docker-build.sh, then
docker compose up -d --wait instead (stop services first as below). Native installs display
git pull && bin/install.sh --data <current-data> --secrets <current-secrets>
with shell-quoted current paths. The update entry point resolves the registered
web unit, preserves its units directory (including custom paths) and refuses if
that unit cannot be found. Fresh native installs use bin/selfhost-install.sh.
Compose sets FINREC_INSTALL_KIND=docker; native is the default.
Upgrade and uninstall
Back up before upgrades. For a stopped-volume backup, stop all services, copy all
FINREC_HOME plus external config/secrets and mail/storage tool configuration,
preserving private permissions and container uid 10001, then restart. Docker:
docker compose stop / docker compose start. Script: stop web and both timers,
then any running tick or backup service before copying; restart web and timers.
For Docker, archive both data and secrets, not just the data volume. Encrypt full copies, restrict access and test
restores in an isolated Sandbox installation. Full copies include LIVE: remove its
marker before a restore test.
Read CHANGELOG.md. Docker: stop the old installation's services,
then extract the reviewed archive into a fresh directory, never over old source
files. Compose derives its project name and volume names from the directory name
unless overridden. Copy the old .env privately into the new directory and set
COMPOSE_PROJECT_NAME there to the old project name (fiscalm for the public clone above, finance-recon for the private clone,
unless explicitly overridden), so the new directory reuses the existing volumes. Existing clones can
instead use git pull --ff-only. Run bin/docker-build.sh in the updated source,
then apply migrations explicitly before restarting (all commands must use the
same project name):
docker compose stop
docker compose run --rm --no-deps web init
docker compose up -d --wait
init uses the same ordered migrations as web startup; repeated initialization
preserves existing firms, credentials and Live/Sandbox mode.
Script: update the clone with git pull --ff-only, then rerun bin/selfhost-install.sh with the same paths; service.env is preserved.
Restart web after rebuilding the static export: its inline-script CSP hashes are
cached at startup. Scheduler interval changes apply on scheduler restart; an old
due time beyond the new interval is recomputed.
Before upgrading an existing schema, startup creates a full document and ledger
archive with finrec.backup in FINREC_BACKUPS (default <FINREC_HOME>/backups).
Names include the UTC date and from/to schema versions; the newest five
pre-migration-*.zip archives are retained independently of scheduled backups.
Secrets are excluded, as for scheduled backups. No archive is needed for a fresh
or current schema. If backup fails, startup refuses to migrate: check free space,
permissions and other upgrading processes, then retry. Pending file-move journals
must be recovered with the previous release first. Schemas older than the company
registry (1–4) refuse automatic migration because a complete backup cannot be
guaranteed; keep the previous release and make and verify a full installation copy. A crash during migration leaves
the archive intact. To roll back, stop services, use the matching release and
finrec --home /new/empty/finance restore /path/to/pre-migration-backup.zip
as described under Backup and restore.
Web/tick startup applies ordered SQLite migrations under a write lock. Each
migration and recorded version commit together; crashes roll back unfinished work.
Fresh initialization and upgrades share a per-ledger startup lock. A concurrent starter waits,
then rechecks the schema before deciding whether a backup is needed. SQLite lock timeouts leave
the schema unchanged; stop old
services and restart the new release. A newer ledger is refused by older code;
downgrades are unsupported. Rollback may require restoring the matching backup
with its release. finrec.migrations.SCHEMA_VERSION is the current version;
the product ledger is not a conversion of the private MVP.
There is no stable release cadence or downgrade compatibility promise yet.
docker compose down preserves volumes.
down -v also deletes local snapshots in the data volume: use it only after
exporting and verifying an independent backup. Script uninstall: disable/stop
web and both timers, stop running tick/backup services, remove generated units,
then systemctl --user daemon-reload. Remove the clone only after its processes
stop. Keep data/secrets until explicitly choosing to delete them.
Shopify Payments payouts
shopify-sync also reads payouts and their balance transactions through the existing
Shopify channel, configured domain and custom-app token, pinned to GraphQL 2026-10.
Grant read_shopify_payments_accounts, read_shopify_payments_payouts and the user's view_payouts permission in addition
to the order scopes. Only payouts issued on or after the channel connection date are imported.
Both connections paginate completely (at most 100 pages each);
a failed read retries on the next tick, without importing a partial report. Imported or withdrawn payouts are never read again;
a withdrawn report stays withdrawn. Older payouts can use manual report intake.
Only paid deposits made entirely of non-test charges with order references and exact,
same-currency gross/fees/net become company/channel-scoped settlement reports. Refunds,
adjustments, missing orders and inconsistent totals refuse the whole payout; the tick's
payout_refused count reports refusals without remote contents. These require manual
review. The payer identity is Shopify Payments; a different bank payer name refuses
matching and requires a manually described report with the actual payer identity.
Local import/reconciliation also works in Sandbox, with no new remote writes.
Collection uses the existing whole-report bank checks and first-three-confirmations
rule; Shopify's paid flag alone never collects an invoice. Order references resolve
only through that company's channel and issued invoices linked from sent SPV documents.
Open banking: each install's own Enable Banking account
In Setări › Integrări › Open banking, the five-step guide prepares the public certificate on the server,
accepts the application ID and return addresses, and checks the provider's application. Opening the guide
makes no provider request; “Pregătește certificatul” generates the key once, and “Verifică configurarea”
explicitly checks the saved application. The private key never leaves FINREC_SECRETS.
- Create this install's own free Enable Banking account.
- In API applications, add a Production application named fiscalm and import the public certificate prepared by fiscalm. Register every return address shown in the guide.
- Save the application's ID and return addresses in fiscalm.
- Authorize the company's own accounts in Enable Banking's restricted mode. For BT, choose BT Go and confirm in BT Go. Connecting from fiscalm later requires another bank confirmation.
- Run the guide's checks. Each failed row explains what remains; only a complete successful check
says the setup is ready. The same checks are available as
finrec bank check.
Restricted mode is free for the owner's own accounts. A hosted fiscalm serving clients needs the commercial tier. Open banking is read-only and works in Sandbox. These written steps are the guide's authority; no third-party panel screenshots are distributed or claimed to be current.
The primary return address is the install's origin followed by /bank/return. Additional addresses can be
entered in “Alte adrese de întoarcere”, separated by whitespace, for example a domain and a Tailscale name.
All must also be registered in Enable Banking. finrec bank check checks all registrations. A connection
selects the configured address matching the browser's Origin; an unknown origin is refused before the
bank call. No action body can supply a return address. Use HTTPS, or HTTP only on localhost, 127.0.0.1 or ::1.
A flow started on a computer at localhost returns to that computer. Starting on a phone requires an HTTPS
address the phone can reach.
The bank callback only prepares an account choice, valid for 15 minutes. The administrator who started the connection reopens fiscalm at the same firm and explicitly selects the firm's accounts before reading starts. No account is selected automatically; new accounts use the same firm/partner and IBAN checks as manual entry and do not become payment accounts. Only opaque choice IDs are accepted, never IBANs or provider account IDs from the request. On phones, the bank may return to a different browser than the installed web app: no callback cookie is required, and returning alone never activates access. Reconnecting keeps the old connection until the selected accounts and new session commit together.
After adding the first firm, fiscalm opens its bank section. Choose the catalogue bank before entering an IBAN, or continue without connecting. The same section is available through “Bănci” in Setări › Firme. Before leaving for bank confirmation, fiscalm keeps this firm's bank-section link in the app address; reopening the app restores account selection, including after a phone handoff to a different browser.
Only booked bank transactions can confirm payments. Pending lines, non-finite amounts and amounts that would need rounding to cents are refused. Statement and direct-bank proofs with the same account-servicer reference count once, including copies held while sources overlap across month boundaries. Conflicting amount, currency or direction holds the evidence; rereading a stable key never replaces its original monetary proof or settles another item. Historical statement uploads remain available as fallback.
An administrator can use “Oprește citirea directă” on the firm's bank card. This removes the local session and cancels pending connections and account choices for that firm and bank. Imported history, firm accounts, other banks, and mail/upload statement intake remain. A read already started can finish. This makes no provider request and does not revoke bank-side consent; revoke that separately in the bank or Enable Banking if needed.