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>
381 lines
20 KiB
Markdown
381 lines
20 KiB
Markdown
# 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`](INVOICE_SET_PRICING.md) (pricing/display
|
|
rules for set items) and [`EVAL_live_invoice_editing.md`](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
|
|
|
|
```mermaid
|
|
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).
|
|
|
|
```mermaid
|
|
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`](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' ...)`).
|
|
|
|
```mermaid
|
|
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()`).
|
|
|
|
```mermaid
|
|
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).
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```mermaid
|
|
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`](Decisions/0006-backend-authoritative-draft-editing.md)
|
|
> and [`Concepts/live-draft-editing.md`](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`](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 |