Files
Fuchs_Intranet/Fuchs/Docs/INVOICE_LIFECYCLE.md
T
StefanandClaude Sonnet 5 99bf5337f8 fix: invoice/reminder PDF preview 404s and their unlogged root causes
An invoice's finalisation-time PDF render/store (fds__setInvoiceFile) can
fail silently; the row stays finalised with [file] = NULL and inv/rdoc
(the invoice list's preview icon) 404'd forever with no trace in any log.

- inv/rdoc and rem/idoc now regenerate and persist a missing document on
  read, mirroring the legacy VB "idoc" fallback (Fuchs_Database ADR 0025),
  gated to finalised + non-external invoices only — a draft render must
  never be persisted, since fds__setInvoiceFile/fds__setReminderFile only
  write WHERE [file] IS NULL and finalisation would then silently no-op.
- StoreInvoiceDocumentFileAsync/StoreReminderDocumentFileAsync now log
  every distinct failure (render threw, render produced 0 bytes, DB write
  threw, DB write matched 0 rows) instead of collapsing them into a single
  silent empty-array return. The 0-rows case needed switching from
  setSQLValue_async (reports success once the command completes, whatever
  the row count) to setSQLValue_async_result, whose AffectedRows makes a
  no-op UPDATE distinguishable from an actual write for the first time.
- HandleRequestIdoc/HandleReminderIdoc no longer treat
  StoreInvoiceDocumentFileAsync's Array.Empty<byte>() failure signal as
  success (a bare `ct == null` check never caught it), which would have
  served a 0-byte "PDF".
- rdoc/HandleRequestIdoc reject a missing 'id' form field instead of doing
  wasted work with an empty id.
- _BackupAndClearInvoiceFile.sql is annotated as a manual-only maintenance
  utility, never called from app code, so it's clear why running it leaves
  no application log trail.
- Client-side: $inv.pdfPreviewTimeout was 120 (ms) instead of 120000 — the
  preview dialog was timing out client-side after 120ms on every call.
- Fixed a genuine process-wide test flake: PdfPipelineTests and
  ERechnungMapperTests both install PdfSharp's global font resolver, and
  whichever ran first would win, making the other's tests fail
  deterministically once eRechnungLib's SystemFontResolver got there
  first. FuchsPdf.EnsureFontResolver is now also race-safe under
  concurrent first use (a check-then-set without a lock). Also removed
  the InvoiceService.PublishAsync failure logging added mid-session after
  code review found it duplicated every call site's own, more detailed
  logging against ADR 0024's single-write-path rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:07:24 +02:00

20 KiB

Invoice Lifecycle — From First Click to Customer Email

Concept document describing the full, end-to-end invoicing process in the Fuchs Intranet: how an office user turns a completed service request into an invoice, how the invoice is drafted/previewed/edited, how it is finalised, and how it reaches the customer by email (including resend and reminders). This complements INVOICE_SET_PRICING.md (pricing/display rules for set items) and EVAL_live_invoice_editing.md (why the editor is stateless).

Scope note: this document describes the implemented flow in Fuchs (ASP.NET Core MVC intranet, jQuery front-end). It does not cover the Razor Pages areas of the workspace — the intranet/invoice module predates those and is intentionally kept as-is (see .github/copilot-instructions.md).

1. Actors & building blocks

Layer Files Responsibility
Browser UI js/intranet/modules/fis.req.js, fis.inv_shared.js, fis.inv.js (bundled to wwwroot/web/fis.req.de.js / fis.inv.de.js via gulp min:js) Request list, invoice editor dialog, PDF preview rendering, all user interaction
Controller Controllers/IntranetController.Requests.cs, .Invoices.cs, .Invoices2.cs, .Reminder.cs Thin action dispatch (req/*, inv/*, rem/*), auth checks, request/response shaping
Services (DI) Services/IInvoiceService + InvoiceService, Services/IComService + ProcessWebComService, Services/IPdfService + FuchsPdfService, Services/IReminderService + ReminderService Register/render/store invoices, send email, render PDF documents
Data model code/FdsInvoiceData.cs Pure data holder: parses the posted invc JSON, builds SQL parameters (BuildInvoiceParams), exposes registration fields
PDF rendering code/FuchsPdf.cs (ApplyInvoice, CreatePage_Letter, AddGirocode), code/InvoiceSetPricing.cs MigraDoc layout, draft watermark, GiroCode SEPA QR, set-pricing line transformation
Database Fuchs_Database SSDT project — fds__prepInvoice, fds__createInvoice, fds__setInvoice, fds__createInvoice_Details, fds__setInvoiceFinal, fds__setInvoiceFile, fds__setInvoiceSent, fds__getInvoice, fds__newInvoiceId SQL-first persistence, invoice numbering, finalisation, sent/paid status

All backend I/O is SQL-first via OCORE helpers (getSQLDataSet_async, setSQLValue_async) and stored procedures — there is no EF Core in this path (see .github/instructions/ocore.instructions.md).

2. High-level lifecycle

flowchart LR
	A[Service request\ncompleted] --> B[Create invoice\nfrom request]
	B --> C[Draft invoice\nregistered]
	C --> D[Preview / Edit\nloop]
	D -->|adjust items,\naddress, set-mode...| D
	D -->|confirm| E[Finalise\ninvoice]
	E --> F[Render + store\nfinal PDF]
	F --> G[Email to\ncustomer]
	G --> H[Mark as sent]
	H -.optional.-> I[Resend email]
	H -.optional.-> J[Reminder /\nMahnung]
	E -.optional.-> K[Storno / Credit\nnote]

3. Entry point — from the request list

Invoices are not created from a standalone "new invoice" wizard; they are always created from one or more completed service requests.

  1. The office user opens the Aufträge (requests) list, rendered by fis.req.js. $req.init2/init3 load the list from req/reql (HandleRequestList → fds__getRequests_list[2]).
  2. Selecting a request row and clicking the invoice icon calls $inv.cInv (bound in fis.req.js via .click({ id: rw.Id }, $inv.cInv)), which — after an auth check (fds_inv level 2) — calls $inv.cInv2({ id }).
  3. $inv.cInv2 posts to req/get (HandleRequestGet → fds__getRequest_details, mode r) to load the request(s) plus any already-linked invoice, then renders the invoice editor dialog (invoice_layout) and wires $inv.eM (the contextual top menu: save, set-mode switch, §13b toggle, contact-person edit).
  4. Multiple requests can be bundled onto a single invoice (the editor supports several request "blocks", each becoming its own item group).
sequenceDiagram
	participant U as User (browser)
	participant JS as fis.req.js / fis.inv_shared.js
	participant C as IntranetController
	participant DB as SQL (fds__*)

	U->>JS: click invoice icon on request row
	JS->>JS: $inv.cInv -> $inv.cInv2({id})
	JS->>C: POST req/get {id, mode:'r'}
	C->>DB: EXEC fds__getRequest_details
	DB-->>C: admin, requests, items, inv
	C-->>JS: JSON {admin, requests, inv}
	JS->>U: render invoice editor dialog (items, totals, address, email)

4. Drafting, preview and editing

The invoice editor is stateless: the browser holds the working model (table.invi jQuery .data()), the server never caches a partial invoice between requests. Every preview/save round-trip posts the entire invoice payload; see EVAL_live_invoice_editing.md for the rationale.

4.1 What the user can change

  • Line items — quantities, prices, notes, combine into one sum ($inv.rendersrq, $inv.quantChange).
  • Recipient fields — invoice title, email, provision location/period (inline edit fields, fm(...) helper in fis.inv_shared.js). The recipient address is edited via a structured dialog ($inv.eAddress: name, street, PLZ, city, country, optional VAT id) prefilled from fds__prepInvoice's invoiceaddressData; it drives the EN 16931 eRechnung and composes the free-text SendToAddress for the PDF (see Concepts/erechnung-output.md). The service date/period (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog ($inv.eProvisionPeriod) — a single date or a from/to range — mapped to BT-72 / BG-14.
  • §13b reverse-charge toggle ($inv.sp13b) — suppresses VAT lines/columns.
  • Set-pricing display mode ($inv.ssetmode / setSetmode) — SetPrice (default) / SetOnly; see INVOICE_SET_PRICING.md. Purely presentational while the set header itself has no own price; once a set is converted via the item switch (item.setprice), the conversion is one-way and totals recompute from that point on.
  • Contact person for the invoice ($inv.sctp, stored in CustomValues).

All of this recalculates client-side totals live via the fds.inv event ($inv.invSumUpdate), which also builds the backend item contract ($inv.itemToContract: { id, type, title, desc, qty, price_net, total_net, vat } plus set header/member tagging) that is posted to the server.

4.2 Posting a draft / preview

User action JS entry point Endpoint Controller handler Effect
First save without preview $inv.ssave req/save Do_Process_Requests case save RegisterInvoiceAsync(change: id present) — creates or updates the draft row, no PDF rendered
Preview a new invoice $inv.sprev(false) req/sprep case sprep Registers draft, then GenerateInvoicePdfAsync → renders preview images
Preview an existing draft $inv.sprev(true) (aka $inv.sedit) req/sedit case sedit Same as sprep but requires id, updates existing draft
Cancel out of preview dialog cancel handler req/sdel case sdel fds__remInvoice — deletes the (unfinalised) draft row

Before posting, $inv.invcPayload(d) normalizes the editor's internal field names into the exact names FdsInvoiceData.BuildInvoiceParams reads (totals from sms.ttn/ttb, invoicetitle→title, loc→provisionlocation, paymentterms→paymentterm, etc. — see INVOICE_SET_PRICING.md for the full mapping table). The posted shape is always { admin, req, sms, new }.

On the server, RegisterInvoiceAsync (in InvoiceService) turns the posted JSON into SQL parameters and calls, in one batch:

  • New invoice: fds__createInvoice (allocates the Id, returns a fresh row) → fds__createInvoice_Details (service net/VAT + InvoiceOptions, e.g. setmode:setonly, §13b).
  • Existing draft: fds__setInvoice (same parameter set, updates in place) → fds__createInvoice_Details again.

GenerateInvoicePdfAsync (still in InvoiceService) builds FuchsPdf.FdsTextBlocks, resolves any whitelisted mfr-portal images from the line-item text (ADR 0018), and calls IPdfService.WriteLetterAsync + ApplyInvoice, which internally uses InvoiceSetPricing.Build(...) to turn the posted item contract into ordered, priced/unpriced lines. While the invoice is not yet final (IsDraft == true), the rendered PDF carries a diagonal "Entwurf"/draft overlay watermark (CreatePage_Letter(..., draft: true) stamps Data/overlay.png) and — importantly — no GiroCode payment QR is added (AddGirocode is only called if (!inv.IsDraft && payAmount > 0 ...)). The preview images are returned as base64 (DocToImageCollectionAsync) and shown in a modal ($ocms.dlg(... form:false, button:'Rechnung erstellen' ...)).

sequenceDiagram
	participant U as User
	participant JS as fis.inv_shared.js
	participant C as IntranetController.Requests
	participant S as InvoiceService
	participant DB as SQL

	U->>JS: edit items / address / set-mode
	JS->>JS: fds.inv event -> invSumUpdate (recalculate + build items[])
	U->>JS: click "Vorschau" (preview)
	JS->>C: POST req/sprep or req/sedit {invc: JSON, id?}
	C->>S: RegisterInvoiceAsync(invoice, change, invId)
	S->>DB: fds__createInvoice / fds__setInvoice + fds__createInvoice_Details
	DB-->>S: registered invoice row (Id, ...)
	S->>S: GenerateInvoicePdfAsync (draft=true -> watermark, no GiroCode)
	S-->>C: MigraDoc Document
	C-->>JS: {id, img[] (base64 pages), total}
	JS->>U: show PDF preview modal (confirm / cancel)

From the preview modal the user can loop back to editing (close and adjust), cancel (deletes the draft via req/sdel), or confirm to finalise.

5. Finalisation ("Rechnung erstellen")

Confirming the preview modal posts to req/sconf (HandleRequestSconf):

  1. EXEC fds__setInvoiceFinal @Id, @authuser — guarded by isFinal = 0 AND isSent = 0 so it can only fire once per draft. This is also where the real, sequential invoice number is assigned: [InvoiceId] = fds__newInvoiceId(YEAR()) → format R<year>-<0000> (the draft only ever had the internal numeric Id). Version is bumped and any linked "replaces" bookkeeping (storno/credit chains) is updated.
  2. On success (IsFinal == true returned), the controller:
    • Reloads the invoice via LoadInvoiceAsync.
    • Calls StoreInvoiceDocumentFileAsync → renders the final PDF (draft now reflects IsFinal, so the watermark disappears and the GiroCode payment QR is added) → fds__setInvoiceFile persists the PDF bytes on the invoice row → IBlobStorageService.UploadInvoicePdfAsync archives a copy to blob storage.
    • Re-fetches the invoice (fds__getInvoice) to get the final SendToEmail / DocumentName / InvoiceBalance.
  3. If a recipient email is present and the PDF rendered, the controller emails it immediately (see §6) and marks the invoice Sent via fds__setInvoiceSent @auto=true.
  4. The browser opens the stored PDF in a new tab (req/idoc), returns to the request list, and reloads it ($ocms.init('req'), $inv.rReload()).
flowchart TD
	A[POST req/sconf] --> B{fds__setInvoiceFinal\nisFinal=0 AND isSent=0?}
	B -- no --> Z[500 - Aktion war nicht erfolgreich]
	B -- yes --> C[Assign real InvoiceId\nR-yyyy-nnnn, bump Version]
	C --> D[LoadInvoiceAsync]
	D --> E[Render final PDF\n[no watermark, + GiroCode]]
	E --> F[fds__setInvoiceFile\npersist PDF bytes]
	F --> G[Upload to blob storage\nIBlobStorageService]
	G --> H{SendToEmail set\nand PDF non-empty?}
	H -- yes --> I[IComService.SendEmailAsync\nattach PDF inline]
	I --> J[fds__setInvoiceSent auto=true]
	H -- no --> K[Skip email - PDF only]
	J --> L[Open PDF in new tab / reload request list]
	K --> L

6. Sending the invoice by email

Email delivery is handled by IComService / ProcessWebComService, which talks to the ProcessWeb Mailer API (push_com, comType=email).

  • HandleRequestSconf builds the HTML body via BuildInvoiceBody(balance, paymentTerms) — a fixed German thank-you text, the amount (if non-zero), and payment instructions (IBAN/BIC) with the invoice's payment term (fdInv.PaymentTerms, e.g. 10wd → "10 Werktagen").
  • The rendered PDF bytes are attached as { [DocumentName] = filebyte } — a single-entry dictionary of filename → bytes.
  • SendEmailAsync(reference, subject, htmlBody, recipient, displayName, attachments) is called with reference = "inv_<InvoiceId>", subject = "SanitärFuchs - <DocumentName>".
  • Inside ProcessWebComService.SendEmailAsync:
    • The recipient email is validated; a configured Fuchs:Email:OverrideRecipient (dev/test safety net, must stay empty in Production) redirects all outbound mail to a single address.
    • A signature is appended to the HTML body.
    • If the mailer is disabled (_settings.Enabled == false), sending is a documented no-op.
    • Attachments are base64-encoded inline ({ filename, mimeType, contentBase64 }) and POSTed to the Mailer API.
  • Only on a successful send does the controller call fds__setInvoiceSent @auto=true, which sets IsSent = 1 — so a failed mailer call leaves the invoice finalised but not marked sent (visible via the "sis" — "als versendet markieren" — manual action, see §7).
sequenceDiagram
	participant C as IntranetController
	participant Com as IComService (ProcessWebComService)
	participant API as ProcessWeb Mailer API
	participant DB as SQL

	C->>C: BuildInvoiceBody(balance, paymentTerms)
	C->>Com: SendEmailAsync("inv_<Id>", subject, body, email, "", {pdf})
	Com->>Com: validate email, apply OverrideRecipient (dev), append signature
	alt mailer enabled
		Com->>API: POST push_com {comType:"email", attachments:[base64 pdf]}
		API-->>Com: success/failure
	else mailer disabled
		Com-->>Com: no-op (logged)
	end
	Com-->>C: sent: true/false
	opt sent == true
		C->>DB: EXEC fds__setInvoiceSent @auto=true
	end

7. After finalisation — manual actions

Once an invoice is IsFinal, the request/invoice list menus ($inv.iMn/$inv.iMnr in fis.inv_shared.js) expose:

Action JS Endpoint Notes
Resend email $inv.ccInv/context menu → (re-)send → HandleRequestResend req/resend Re-renders the stored/registered PDF bytes (RenderInvoicePdfBytesAsync, no re-registration) and re-sends via IComService, without re-touching IsSent
Mark as sent manually $inv.sis(id) inv/sis fds__setInvoiceSent @auto=false — for invoices sent outside the system (e.g. printed/posted)
View / display PDF $inv.disp(id, 'inv') inv/rdoc Renders page images for on-screen display only. If a finalised, non-external invoice has no stored PDF, it is regenerated and persisted first (ADR 0025)
Download / open PDF req/idoc or inv/pget-derived req/idoc Serves the stored (or freshly rendered) PDF inline
Mark paid / unpaid $inv.setPyd/setUpd inv/setpyd / inv/setupd fds__setInvoicePayed / fds__setInvoiceUNPayed
Storno (cancel) $inv.storno → $inv.cSt inv/storno (mode simple/copy) fds__createStorno_simple / fds__createStorno_copy; the replaced invoice is cross-linked (Replaces_InvId) and cancelled once the storno itself is finalised
Credit note $inv.credit → $inv.cSt inv/credit (mode credit) fds__createCredit_simple — same preview/finalise/email loop as a normal invoice
Continue editing a draft $inv.cntInv / clCntInv inv/get Only available while isFinal == false; re-opens the same stateless editor

Storno/credit invoices are new invoice drafts created from the original one — they go through the exact same preview → finalise → email pipeline described in §4–§6, just seeded from fds__createStorno_*/fds__createCredit_simple instead of from a service request.

8. Reminders (Mahnungen) — downstream of a sent, unpaid invoice

Not part of the invoice creation flow itself, but the natural continuation once an invoice is sent and remains unpaid:

  • $inv.ccRem(id, InvoiceId) → rem/lrem (load prior reminder history) → reminder editor ($inv.ccRem_s2) → rem/get → preview via rem/prep ($inv.rprev, same stateless pattern as invoice preview) → finalise via rem/conf → PDF opened, list reloaded.
  • $inv.dspRem(id) lists existing reminders (inv/getrem); each can be resent (rem/resend, mirrors req/resend) or downloaded.
  • $inv.srs(id) → rem/srs marks a reminder as sent manually (mirrors inv/sis).
  • Reminder PDFs use the same draft-watermark/GiroCode rule as invoices (FuchsPdf.ApplyReminder, rem.IsDraft).

This is handled by IReminderService/ReminderService and IntranetController.Reminder.cs, following the identical draft-preview-finalise-email shape as invoices — intentionally, so the two flows share the same mental model for the office user.

9. End-to-end summary diagram

flowchart TD
	subgraph Requests
		R1[Service request completed] --> R2[Open request list - req/reql]
		R2 --> R3[Select request(s), click invoice icon]
	end

	subgraph Draft & Edit
		R3 --> D1[req/get - load request + existing invoice]
		D1 --> D2[Edit items, address, email,\nset-mode, §13b, contact]
		D2 --> D3[req/save - persist draft only]
		D2 --> D4[req/sprep or req/sedit -\nregister + render draft PDF]
		D4 --> D5[Preview modal\ndraft watermark, no GiroCode]
		D5 -->|edit more| D2
		D5 -->|cancel| D6[req/sdel - delete draft]
	end

	subgraph Finalise & Send
		D5 -->|confirm| F1[req/sconf -\nfds__setInvoiceFinal]
		F1 --> F2[Assign real InvoiceId\nR-yyyy-nnnn]
		F2 --> F3[Render final PDF\nno watermark, + GiroCode]
		F3 --> F4[fds__setInvoiceFile +\nblob storage upload]
		F4 --> F5{SendToEmail set?}
		F5 -- yes --> F6[IComService.SendEmailAsync\nProcessWeb Mailer API]
		F6 --> F7[fds__setInvoiceSent auto=true]
		F5 -- no --> F8[PDF stored, not emailed]
	end

	subgraph After Sending
		F7 --> A1[req/resend - resend email]
		F7 --> A2[inv/setpyd / setupd - payment status]
		F7 --> A3[inv/storno or inv/credit -\nnew linked invoice draft]
		F7 --> A4[rem/* - reminder / Mahnung flow]
		A3 -.re-enters.-> D2
	end

10. Key invariants worth remembering

⚠️ Updated (2026-07-10): the "stateless editor" invariant below describes the legacy draft-editing flow. Invoice draft editing is being moved to a backend-authoritative model where the server holds the draft in an in-memory cache (the single source of truth), the browser posts single edits and re-fetches on a SignalR signal, and totals are computed server-side. See ADR Decisions/0006-backend-authoritative-draft-editing.md and Concepts/live-draft-editing.md. Finalise/email (§5–§6) are unchanged. The remaining invariants below still hold.

  • Stateless editor (legacy — see the note above; superseded by ADR 0006): every preview/save/finalise call re-posts the full invc JSON; the server never holds a partial invoice in memory or session between requests (see EVAL_live_invoice_editing.md).
  • Totals come from the registration, not the rendered lines: sms.ttn /sms.ttb (posted) become InvoiceBalance/InvoiceBalance_net; display mode (set pricing) never changes what the customer owes.
  • Numbering happens only at finalisation: the draft has an internal Id but no InvoiceId until fds__setInvoiceFinal assigns R<year>-<sequence> — so previews never "burn" an invoice number.
  • Draft vs. final changes the rendered PDF: draft = watermark overlay, no GiroCode; final = no watermark, GiroCode payment QR added when there's a positive balance.
  • Final invoices can be emitted as eRechnung: when Fuchs:ERechnung:Enabled, the final PDF is a ZUGFeRD/Factur-X PDF/A-3 hybrid (eRechnungLib embeds the CII XML into the render-only visual PDF; any failure falls back to the plain PDF/A). Off by default. See Concepts/erechnung-output.md.
  • Email is best-effort and tracked: IsSent is only set true automatically after a successful send; a failed send still leaves a correctly finalised, stored invoice that staff can resend or mark sent manually.
  • Storno/credit/reminder all reuse the same pipeline: they are not special cases in the UI/backend contract — they are just differently-seeded drafts going through the identical preview → finalise → email sequence. 5