Extension Points
Invoicerr had an in-app, DB-backed, Settings-configurable plugin mechanism (PluginRegistry/
PluginType, the Plugin table, a Settings > Plugins screen) with two categories that were ever
actually registered: STORAGE (a company/instance choosing between an S3 bucket and local disk for
a signed quote or paid invoice PDF) and SIGNING (dead since quote e-signature was removed). It was
removed (2026-09-17): a per-deployment S3 storage toggle a tenant admin could point at their own
bucket is not a product this app offers any more — periodic backup of every document is now handled
at the INSTANCE level instead (backend/src/modules/backup/), never a Settings screen. See
History below for the earlier, also-removed git-clone plugin mechanism this one itself
replaced.
Document storage today is unconditional: received-invoice attachments, company branding assets, and
the legal archive each write to their own fixed backend (local disk by default; the legal archive
alone also supports ARCHIVE_STORAGE=s3, an unrelated, env-configured mechanism — see
backend/src/modules/documents/archive/s3-storage.ts's own header for why it shares nothing with
the removed plugin).
The narrow-interface-at-the-core pattern
What replaces PluginRegistry as this codebase's way to add a genuinely new capability — one that
isn't a country's rules or a document type's shape (see Extending Invoicerr
for those two axes) — is a narrow interface at the core, with a registry the feature itself owns,
never a generic, instance-wide, single-active-provider table.
The reference example is received-document OCR extraction (ReceivedDocumentExtractor,
backend/src/modules/documents/received-invoices/ocr/extractor.ts) and its implementation
(backend/src/plugins/ocr/providers/local/local.ts). Its whole configuration is one environment
variable, OCR_SERVICE_URL, naming a container — never a database row, never a Settings screen. A
test double (FakeReceivedInvoiceOcrExtractor) is registered instead under NODE_ENV=test, the same
swap discipline clients.module.ts's VAT_VALIDATION_FAKE already establishes elsewhere in this
codebase. backend/src/plugins/index.ts is the composition root: the only file allowed to import
both the core's extension point and a real (or fake) implementation of it — received-invoices/
itself never imports the OCR provider directly.
OCR is fully local and opt-in. There is no cloud engine and no API key anywhere: the backend talks
directly to our own OCR image (ghcr.io/invoicerr-app/ocr-image, ocrmypdf plus a broad Tesseract
language-pack set — docker-compose.yml's ocr service, --profile ocr), which reads the PDF and
returns plain text. ocr-service/local-client.ts maps that text to the proposal shape with regex
heuristics over amount, date, VAT-id and invoice-number keyword proximity; its own header documents
exactly what that can and cannot get right. Leave OCR_SERVICE_URL unset and a scanned PDF is
simply stored with empty fields for a human to fill in.
apply-ocr-fallback.ts treats the result as an editable PROPOSAL, never an auto-commit — the
heuristic's limits are an acceptable trade for costing nothing and staying fully offline, precisely
because a human always reviews the pre-filled screen before it is saved.
Adding a new extension point means: define a narrow interface for exactly what callers need, give it
its own small registry (a Map, like receivedDocumentExtractorRegistry), and register exactly one
real implementation per process (swapped for a fake in tests) — never a generic, DB-configurable
plugin table.
History
Two mechanisms preceded the one above, both removed:
- A second, EARLIER mechanism let a user install a plugin from a Git URL at runtime
(
POST /api/pluginscloned the repository and dynamicallyimport()ed its entrypoint). It was removed: itsIPluginshape ({__uuid, __filepath, name, description}) had no real extension point behind it — the only two generic consumers a loaded plugin could reach (canGenerateXml/generateXml) were permanent stubs (return false/throw), so an externally installed plugin could not actually do anything. Keeping a code-loading endpoint alive with no capability behind it was pure attack surface (arbitrary Git URL → arbitrary code execution in the backend process) for zero product value. - The in-app
PluginRegistry/Pluginmechanism this page used to document in full (see above) — its ownSTORAGE/SIGNINGcategories never grew beyond the two providers described above, and neither survived to be worth a configurable, per-instance toggle.