Files
Fuchs_Intranet/Fuchs/Docs/INVOICE_LIFECYCLE.md
T
Stefan 24b94b05c2
Playwright Tests / test (push) Canceled after 0s
Add invoice provision-period PDF handling
Normalize invoice provision periods in PDF generation, switch admin labels between single-date and range modes, add end-to-end tests, update package and frontend bundle versions, and note the invoice lifecycle docs update.
2026-10-09 12:03:06 +02:00

23 KiB
Raw Blame History

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 and printed in the PDF's admin block (InvoiceService.ApplyProvisionPeriod; legacy ISO values are shown as German dates).
  • §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.
    • Invoice date = DateFinalized — printed on the PDF and used as the e-invoice issue date (InvoiceDates.IssueDate, ADR 0032).
    • Backdating (ADR 0032): users with fds_inv > 2 see a checkbox in the preview dialog (inv/dpreview returns backdate) to date the invoice to the last working day (Mon–Fri) of the previous month, as long as no invoice has been finalised after that day. req/sconf then passes @backdate = 1; fds__fn_invoiceBackdate is re-checked and the proc throws 60001 (nothing finalised) if it is no longer allowed. Number year and DueDate follow the backdated day. The proc serializes all finalisations (transaction + sp_getapplock) so the check and the numbering cannot interleave with a concurrent finalisation. The controller maps 60001 to the "create again without backdating" message; a 0-row result (already final/sent, e.g. double submit) is reported as "already finalised".
    • Same date everywhere (ADR 0035): invoice lists, revenue reports, the DATEV export (period + Belegdatum), request details and reminders all use [dbo].[fds__fn_invoiceDate] — DateFinalized as German calendar day, drafts fall back to DateCreated — so a backdated invoice appears in the previous month everywhere, including DATEV. The reminder PDF's own date follows the same rule (reminder DateFinalized).
    • Abschlagsrechnungen (ADR 0034): IntInvoiceAllocation lines carry the credited intermediate invoice in reference_InvId (seeded by fds__prepInvoice and fds__prepStorno_recreate, round-tripped by the editor as item ref); finalisation records it in fds__invoice_details.AllocatedTo_InvId so the same Abschlagsrechnung is not offered again. The claim only counts while the crediting invoice is alive: once it is cancelled (storno, or replaced via storno + recreate) fds__fn_IntermediateIsAllocatedToOther treats the Abschlagsrechnung as free again and the next finalisation takes the allocation over. A Stornorechnung ('c') never claims an allocation, even though a storno copy mirrors the credit lines.
  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 (POST /api/mailer/push_com, comType="e"; contract: https://processweb.de/api/mailer/fc, client side described in Docs/Concepts/processweb-mailer-interface.md).

  • 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 pre-validated against the Mailer's limits (size, count, no empty file, extension rules) and POSTed as multipart/form-data — envelope JSON in an unnamed field plus one file part per attachment, with attachmentCount in the envelope.
    • The Mailer's 200 response must confirm attachmentsProcessed equal to the number sent; otherwise the send is reported as failed (ADR 0028).
  • 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->>Com: MailerAttachmentPolicy.Validate(pdf)
		Com->>API: POST /api/mailer/push_com (multipart: envelope{comType:"e", attachmentCount:1} + pdf part)
		API-->>Com: 200 {comStackID, attachmentsProcessed} / 4xx/5xx
		Com->>Com: attachmentsProcessed == 1 ? 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 (first inv/pget: refreshes mfr entities only for numeric mfr ids, no-op for Intranet ids) 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