Playwright Tests / test (push) Canceled after 0s
- Added a new SQL script to address the "String or binary data would be truncated" error in mfr sync by widening the Note fields in service objects and companies to NVARCHAR(MAX). - Updated the table types and associated stored procedures to accommodate the changes, ensuring compatibility with existing data. - Queued a re-sync for previously rejected rows due to truncation issues. - Created application logins for fuchs_fds and fuchs_web with placeholder passwords for initial setup. - Introduced functions for invoice backdating and invoice date retrieval to streamline invoice processing. - Developed a password generation function with customizable parameters for length and character types. - Added a random number generation function to support password creation.
419 lines
23 KiB
Markdown
419 lines
23 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.
|
||
- **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()`).
|
||
|
||
```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** (`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).
|
||
|
||
```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->>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
|
||
|
||
```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 |