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>
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.
- The office user opens the Aufträge (requests) list, rendered by
fis.req.js.$req.init2/init3load the list fromreq/reql(HandleRequestList→fds__getRequests_list[2]). - Selecting a request row and clicking the invoice icon calls
$inv.cInv(bound infis.req.jsvia.click({ id: rw.Id }, $inv.cInv)), which — after an auth check (fds_invlevel 2) — calls$inv.cInv2({ id }). $inv.cInv2posts toreq/get(HandleRequestGet→fds__getRequest_details, moder) 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).- 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 infis.inv_shared.js). The recipient address is edited via a structured dialog ($inv.eAddress: name, street, PLZ, city, country, optional VAT id) prefilled fromfds__prepInvoice'sinvoiceaddressData; it drives the EN 16931 eRechnung and composes the free-textSendToAddressfor the PDF (seeConcepts/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; seeINVOICE_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 inCustomValues).
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 theId, 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_Detailsagain.
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):
EXEC fds__setInvoiceFinal @Id, @authuser— guarded byisFinal = 0 AND isSent = 0so it can only fire once per draft. This is also where the real, sequential invoice number is assigned:[InvoiceId] = fds__newInvoiceId(YEAR())→ formatR<year>-<0000>(the draft only ever had the internal numericId).Versionis bumped and any linked "replaces" bookkeeping (storno/credit chains) is updated.- On success (
IsFinal == truereturned), the controller:- Reloads the invoice via
LoadInvoiceAsync. - Calls
StoreInvoiceDocumentFileAsync→ renders the final PDF (draftnow reflectsIsFinal, so the watermark disappears and the GiroCode payment QR is added) →fds__setInvoiceFilepersists the PDF bytes on the invoice row →IBlobStorageService.UploadInvoicePdfAsyncarchives a copy to blob storage. - Re-fetches the invoice (
fds__getInvoice) to get the finalSendToEmail/DocumentName/InvoiceBalance.
- Reloads the invoice via
- If a recipient email is present and the PDF rendered, the controller emails
it immediately (see §6) and marks the invoice
Sentviafds__setInvoiceSent @auto=true. - 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).
HandleRequestSconfbuilds the HTML body viaBuildInvoiceBody(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 withreference = "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.
- The recipient email is validated; a configured
- Only on a successful send does the controller call
fds__setInvoiceSent @auto=true, which setsIsSent = 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 viarem/prep($inv.rprev, same stateless pattern as invoice preview) → finalise viarem/conf→ PDF opened, list reloaded.$inv.dspRem(id)lists existing reminders (inv/getrem); each can be resent (rem/resend, mirrorsreq/resend) or downloaded.$inv.srs(id)→rem/srsmarks a reminder as sent manually (mirrorsinv/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.mdandConcepts/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
invcJSON; the server never holds a partial invoice in memory or session between requests (seeEVAL_live_invoice_editing.md). - Totals come from the registration, not the rendered lines:
sms.ttn/sms.ttb(posted) becomeInvoiceBalance/InvoiceBalance_net; display mode (set pricing) never changes what the customer owes. - Numbering happens only at finalisation: the draft has an internal
Idbut noInvoiceIduntilfds__setInvoiceFinalassignsR<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. SeeConcepts/erechnung-output.md. - Email is best-effort and tracked:
IsSentis only settrueautomatically 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