-
Notifications
You must be signed in to change notification settings - Fork 3
ADR 032 Paperless First Invoice Creation
Accepted
Story #1679 ("Paperless-first invoice creation with LLM auto-itemize") introduces a new invoice-creation entry point: the user picks a Paperless-ngx document first, the app's LLM auto-extracts the line items, document metadata, and best-matching vendor, and the user reviews everything before the invoice is created.
The existing auto-itemize feature (EPIC-16, ADR-031, Story #1547) is keyed on an existing invoice:
-
POST /api/invoices/:invoiceId/auto-itemizeloads the invoice up front (invoiceAutoItemizeService.ts~line 110), seeds extraction hints frominvoice.vendorIdandinvoice.amount, and verifies thepaperlessDocumentIdis already linked to that invoice viadocument_links. - On commit (
dryRun: false), it creates budget lines on that invoice and optionally patches it.
The new flow is extract-first, create-on-confirm: there is no invoice (and no document link) when the user picks a document. Per the product-owner's acceptance criteria (AC23–AC26), the invoice must be created only after the human confirms, and abandoning the review must leave no orphan invoice, document link, or budget line.
Three sub-problems must be solved without breaking the existing invoice-keyed flow:
- Stateless extraction — extract from a document with no invoice context, and let the LLM choose the best-matching app vendor from a server-provided list.
- Atomic create-on-confirm — create the invoice, link the document, and itemize the reviewed lines in a single transaction with all-or-nothing semantics.
- Correspondent listing — a new Paperless proxy endpoint to populate the picker's vendor-filter dropdown.
-
(A) Extend the existing
/:invoiceId/auto-itemizeroute to accept a nullinvoiceId. Rejected: the route's params schema requiresinvoiceId, the service body assumes a loaded invoice for hints and the document-link check, and overloading dry-run vs. commit semantics for two fundamentally different lifecycles (patch-existing vs. create-new) would make the service a tangle of branches. Backward-compatibility risk is high. - (B) Persist a draft invoice immediately, then finalize on confirm. Rejected by the product-owner (AC23/AC25): a draft would be an orphan if the user abandons review, and the draft would pollute the invoices list and budget aggregations.
- (C) Two dedicated stateless endpoints — preview (extraction) and commit (atomic create) — that share extraction internals with the existing flow. Chosen. Clean separation of lifecycles, no change to the existing endpoint's contract, and maximal reuse of the LLM extraction + line-persistence internals.
Add two new stateless endpoints plus one Paperless proxy endpoint, and refactor invoiceAutoItemizeService.ts to extract its reusable internals into shared helpers.
- No
invoiceId. Request:{ paperlessDocumentId, locale? }. - Server fetches the document OCR text (existing
paperlessService.getDocument), runs the LLM provider (existinggetProvider), maps category names to IDs (existingmapCategoryNameToId), and computes the due-date fallback (existingcomputeDueDateFallback) — identical to the existing dry-run path. -
Vendor matching: the server loads all app vendors as
{ id, name }, injects the list into the LLM prompt, and instructs the LLM to returnchosenVendorName(a verbatim copy of one provided name, ornull). The server resolveschosenVendorNameback to a vendoridby exact case-insensitive name match against the provided list. If the LLM returnsnullor a name that does not exactly match a provided vendor,suggestedVendorIdisnull(AC22). Matching is performed server-side against the canonical list — the LLM never invents an id. - No
warningsarray (there is no invoice total to compare against yet). The review UI computes the extracted-line total locally. - Response:
{ lines, suggestedVendorId, extractedInvoiceNumber?, extractedInvoiceDate?, extractedDueDate?, extractedNotes? }.
A dedicated endpoint (not an extension of createInvoice, which is vendor-scoped and knows nothing about document links or itemization). In a single SQLite transaction:
- Validate
vendorIdexists (vendor is required — AC21). - Create the invoice (
createInvoiceinternals: vendor, amount, date, dueDate, invoiceNumber, notes, status). - Create the
document_linksrow linking the selected Paperless document to the new invoice (entity_type='invoice',entity_id=<new invoice id>). - Itemize the reviewed
linesintoinvoice_budget_lines/work_item_budgetsusing the same line-persistence helper extracted from the existing commit path (mode: 'append'semantics — there are never pre-existing lines on a brand-new invoice). - Validate Σ itemized ≤ invoice amount (existing
ItemizedSumExceedsInvoiceError).
If any step throws, db.transaction() rolls back the entire unit: no invoice, no document link, no budget lines persist (AC24/AC26). The endpoint is not idempotent — each successful call creates a new invoice; the client must guard against double-submit (the review screen disables confirm while the request is in flight). Because there is no invoice before confirm, abandoning the review is a pure client-side discard — nothing to clean up (AC25).
Response (201 Created): { invoice, budgetLines, remainingAmount } — the created invoice plus the itemization result, so the client can navigate straight to the invoice without a follow-up fetch.
Mirrors GET /api/paperless/tags exactly: returns { correspondents: { id, name }[] }, requires auth, returns 503 PAPERLESS_NOT_CONFIGURED when Paperless is not configured, 502 on upstream failure. Fetched on demand (paginated page_size=1000 like tags). Not cached initially — correspondent lists are small (<~100) and the picker fetches once on open; if profiling later shows pressure, a short-TTL cache mirroring the filter-tag cache can be added (deferred, noted for future review).
The existing invoice-keyed flow stays unchanged in behavior. The following internals are extracted into reusable, non-invoice-coupled helpers (same file or a sibling module) and shared by both flows:
-
Line persistence — the per-line loop that resolves assignment mode, creates
work_item_budgets(origin'auto') or assigns existing budget lines, insertsinvoice_budget_linesjunction rows, and accumulatestotalItemized. Refactored to take(db, invoiceId, vendorId, userId, lines)instead of reading them off a loaded invoice object. The existing commit path calls it with the loaded invoice's id/vendorId; the new commit path calls it with the freshly-created invoice's id/vendorId. -
Extraction core — fetch document, truncate OCR to
MAX_OCR_CHARS, run provider, map categories, compute due-date fallback. Shared by both dry-run paths. The only difference is hint seeding (existing flow seeds from the loaded invoice; the new flow seeds from the document + provided locale and additionally injects the vendor list). -
Vendor-list prompt augmentation — new code: gather
{ id, name }[], render into the user prompt, parsechosenVendorName, resolve to id. Used only by the new preview endpoint.
Genuinely new code: the two route handlers, the two thin service entry points (previewAutoItemize, commitAutoItemizeCreate), the vendor-list prompt augmentation + parsing, and the correspondents proxy. The bulk of persistence and extraction logic is shared.
Easier:
- The existing
/:invoiceId/auto-itemizecontract is untouched — zero regression risk to the EPIC-16 flow and its tests. - Atomicity is trivially correct: a single
db.transaction()wraps create + link + itemize, so partial failure cannot leave orphans (AC26), and there is no draft-cleanup machinery to maintain (AC25). - Vendor resolution is deterministic and safe — the LLM can only echo a name from the server-provided list, and the id is resolved server-side, so the LLM cannot fabricate a vendor id.
- The correspondents endpoint follows the established
/tagspattern, so it is consistent and quick to implement/test.
More difficult / trade-offs:
- Two extraction entry points now exist (
/:invoiceId/auto-itemizedry-run and/auto-itemize/preview). They share an extraction core but diverge in hint seeding and the vendor-list step; this divergence must be kept in sync if the prompt evolves. Mitigated by extracting the shared core into one helper. - The
commitendpoint duplicates the "create invoice" surface thatcreateInvoicealready owns. We deliberately callcreateInvoice's internals rather than re-implementing validation, but the transaction boundary lives in the new endpoint. If invoice-creation rules change, both call sites must be considered (they share the underlying helper, so the risk is contained). - Exact-match vendor resolution may under-match (e.g., "ACME GmbH" vs. "ACME"); the user override (AC21) is the safety net. Fuzzy matching is intentionally not done server-side to keep resolution predictable; the LLM is the fuzzy layer and is constrained to echo a provided name.
- Correspondents are not cached; a picker open triggers one upstream call. Acceptable at <5 users; revisit if it becomes a hot path.
- ADR-015 (Paperless-ngx Integration) — proxy pattern, polymorphic
document_links. - ADR-018 (Invoice-Budget-Line Junction) —
invoice_budget_linesmodel, ON DELETE CASCADE. - ADR-031 (Outbound LLM Provider Integration) — OpenAI-compatible gateway,
autoItemizeEnabled.