# Invoice "Set" Pricing — Design & Front-/Back-end Contract > Governed by ADR [0019](Decisions/0019-set-price-modes-are-keyed-on-the-set.md) > (the two menu modes — **supersedes** ADR > [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md), which had keyed them on > the service-request block), ADR > [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md) (everything > server-side, PDF == editor) and ADR [0016](Decisions/0016-user-defined-set-structure.md) (set > membership is an explicit, user-editable parent link; sets may nest — see "Set structure" below). There are **three** set-price operations. All three are **irreversible mutations** of the authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a display toggle — and all three group by the **same key: the set** (ADR 0019). | # | Operation | Trigger | Scope | Effect | |---|---|---|---|---| | **1** | **Set-item switch** | row context button on a single `typ == "set"` row (`$inv.toSetPrice`) | that one set | sums the set's transitive descendants onto the set line and sets their prices to `null` | | **2** | **"Set mit Preis – Positionen ohne Preis"** | editor menu ("Set-Preisanzeige") | **every still-unconverted top-level set** of every block | same conversion, applied to all of them at once; the members' rows are **kept**, showing an empty price cell | | **3** | **"Nur Set mit Preis – Positionen ausgeblendet"** | editor menu ("Set-Preisanzeige") | **every top-level set** of every block | same conversion, and the set's whole subtree is **removed** from the dataset | What the two menu modes explicitly do **not** do (they did, under the superseded ADR 0009): they do not treat a service request as an implicit set, do not insert a "Gesamtumfang pauschal" row, and do not clear anyone's set membership. - **Lines outside every set stay untouched** — individual price, listed as ordinary positions. Sets and standalone lines together make up the invoice total. - **Several sets stay several separate lines**, in the same block or across blocks. - **Only top-level sets are priced.** A nested set belongs to its parent's subtree, so it is blanked (mode 2) or removed (mode 3) with it and never priced twice. - **No sets, or a set without members → no-op** (no version bump, no history). The editor checks the authoritative state first and, when the invoice has no set at all, points the user at "Set-Struktur bearbeiten" instead of opening the mode dialog. - **Repeatable, still one-way.** Running a mode again only converts sets that are still unconverted; nothing converts back. The only ways back are discarding the draft (reloads the DB state) or hand-editing the resulting rows — the set line is a real, editable item precisely for that. > **Totals are unaffected.** The invoice total is taken from the registration balance > (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines, and every operation > conserves it anyway: the set line receives exactly the sum of the lines it blanks or removes. > `null` (not `0`) marks "no price" — an empty cell excluded from the sum, distinct from a genuine > `0,00 €`. > **The `ItemPrices` / `admin.setmode` display-flag model is gone.** The pre-0009 design persisted a > `setmode:` token in `InvoiceOptions` and re-grouped at render time. The modes are one-shot > mutations, so there is no display state to persist or toggle; a stale `setmode:`/`itemprices` > token degrades safely (ignored) and is never re-persisted. ## Set structure (ADR 0016): which line belongs to which set `SetItmId` is no longer only what `fds__prepInvoice` derived from row order — it is the **explicit parent link** of every cached line, and the user can edit it: - **Initial structure** = the implicit derivation (every item after a zero-priced Set header until the next one). Nothing changes for drafts the user never touches. - **"Set-Struktur bearbeiten"** (editor menu → `$inv.sets`, `fis.inv_sets.js`) shows every line per service-request block (Pos., Typ, Menge, Bezeichnung, indented by level). Drag a line onto a set → `set.assign { parent }` (it becomes the set's last member); drag it onto the block heading / the "oberste Ebene" zone → `set.assign { parent: "" }` (pulled out, placed after the subtree it came from). Drag a line to the **top/bottom edge** of another row → `set.move { anchor, where: before|after }` (reorder: it becomes that row's sibling; a set travels with its members, folded or not); ▲ / ▼ post `set.move` −1 / +1 (one step among siblings). The ◀ / ▶ buttons post `set.level` −1 / +1 (outdent to the grandparent / indent under the nearest preceding sibling set). Sets can be folded via the triangle (view state only). The per-block bar creates a new **Set** (zero-priced header, convertible via the operations above) or **Text** line inside any set or at the top level → `set.create { typ, title, parent }`. - **Sets may contain sets.** A set dropped onto another set keeps its own members (one level deeper). Every conversion sums the header's **transitive** descendants and nulls them all; converting inner-then-outer or outer directly conserves the total either way. - **Only sets are drop targets; a line never leaves its block.** Cycles (a set onto its own descendant) and no-op moves are rejected server-side (`InvoiceSetStructure`), the browser merely greys them out. - **The server linearises** after each structural delta (depth-first: set, then its members, recursively; siblings keep their order) and publishes `lvl`/`level`; `InvoiceSetPricing.Build` is order-preserving, so editor and PDF show the same sequence and the same per-level indent. Every change is applied immediately to the cached session and pushed back via `draftReady`/`dstate`; the dialog re-renders from that state. ## Back-end - `Fuchs/Services/InvoiceDraftEditService.cs` — the three triggers: - `ConvertSetHeader(block, lines, header)` is the **single conversion primitive**: sums the header's transitive descendants (`InvoiceSetStructure.Descendants`) onto the header (`v`/`vt`/`vv`/`vs`/`vsv`, adopting the members' VAT rate when the header has none), nulls every descendant's price fields, and mirrors the result onto the `items` contract shape (`total_net` on the header, `null` on the members) so the editor and the PDF see the conversion without a reload. - `ApplyItemSetPrice` (`item.setprice`) resolves one header by `Ref` and delegates. It is a no-op when the `Ref` is unknown, is not a set line, or the set is **already converted** (own price non-zero) — that guard keeps a repeated delta from re-summing already-nulled members to 0. - `ApplySetPricing(removeMembers)` (`block.setprice` / `block.setonly`) walks every block, takes the **top-level** set lines (`IsSet` && `ParentOf == null`), converts the unconverted ones via the primitive, and — for `setonly` — removes each such set's whole subtree from `itm` and `items` (`RemoveById`). Sets with no members, and blocks/invoices without sets, are skipped. - `Fuchs/code/InvoiceSetStructure.cs` — the pure set forest (parent links, levels, depth-first linearisation, assign / change level / create); `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs` maps the `set.*` patch targets onto it. Covered by `InvoiceSetStructureTests` + `InvoiceDraftServiceTests`. - `Fuchs/code/InvoiceSetPricing.cs` — the render transformation: `SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with `ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest *converted* ancestor set (blank in SetPrice, dropped in SetOnly); an unconverted header is blank with individually priced members. Because the menu modes preserve set membership, this is what renders their result — no special case is needed for them. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`. - `InvoiceDraftEditService.BuildSetDisplay` runs the same `Build` for the editor and publishes `setDisplay[id] = { p: showPrice, h: isSetHeader }`, so the rows always match the PDF without any client-side pricing rule. - `FuchsPdf.ApplyInvoice` renders blocks that carry a set through `InvoiceSetPricing.Build` and set-free blocks flat; lines with `ShowPrice == false` (or a `null` `total_net`) render **blank** price/total cells (not `0,00 €`), and set header lines are emphasised. ## Front-end contract (implemented in the invoice editor) Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to `wwwroot/web/fis.inv.de.js` via gulp `min:js`): 1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`, menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit Preis". `$inv.hasSets(state)` first checks the authoritative `dstate` for a line that points at a set line; if there is none, the user is told to define a set first ($rct.toSetMn) and nothing is posted. Otherwise selecting a mode posts a **mutating delta** (`block.setprice` / `block.setonly`) to `inv/dpatch`; the server rewrites the sets in the cached session and pushes the new state back via `draftReady`/`dstate`. The browser performs **no** grouping or pricing math (ADR 0008/0019) — it only posts the chosen mode and re-renders the server's `req`/`sums`. Pending local edits are flushed first (`$inv.t_fds_inv()`) so a delayed `block.replace` cannot land after the conversion. The menu entry is always offered (`$inv.emOpt`), because a mode can be run again for a set added later. 2. **Item shape** — `$inv.invSumUpdate` posts each request block's `items[]` in the back-end contract shape via `$inv.itemToContract`: `{ id, type, title, desc, qty, price_net, total_net, vat }`. (Previously the editor only posted the legacy on-screen `itm`/`co` objects, which `FdsInvoiceData.InvoiceItems` does not read — so line items never reached the C# PDF. This change closes that gap for **all** invoices, not just sets.) 3. **Set flags** — `invSumUpdate` tags items as it builds `items[]`: an item with `type === 'set'` is a header (`id` = its set id); a member's `setId` is taken directly from the `SetItmId` field on the row (`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window function, anchored on the still-unconverted, zero-priced `'set'` header that owns it, and thereafter maintained by the server per ADR 0016, including for a **nested set**, which is a member of its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself still has no explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the item list, so only items the server actually attributes to a set are tagged, and unrelated items following a set in the list are never swept in. The header row's own `SetItmId` self-references its own id (rather than being `null`); it is explicitly excluded from being its own member both here (`sid !== citem.id`) and in `InvoiceSetStructure.ParentOf`. These flags feed **all three** set-price operations (ADR 0019). ### Editor → backend field normalization (`$inv.invcPayload`) The editor's internal model keeps the long-standing key names, but the migrated C# `BuildInvoiceParams` reads different ones. At post time `$inv.invcPayload(d)` maps the working model onto the exact field names the back-end reads (non-destructively): `sms.ttn → new.total_net`, `sms.ttb → new.total_gross`, each `sms.vat` rate → `new.vat__net`, `new.invoicetitle → new.title`, `new.loc → new.provisionlocation`, `admin.paymentterms → new.paymentterm`, `admin.CustomerId → admin.customerid`. Both `req/save` and `req/sprep|sedit` post through it, so titles/balances/VAT now reach the backend correctly. VAT (rate **and** amount) is taken by the backend directly from the posted `sms.vat` map via `FdsInvoiceData.HighestVat` (highest rate wins) — `invcPayload` therefore emits no per-rate `vat_*` keys. **Back-end fixes applied alongside the wiring:** - Heading/free-text lines (`type` `text`/`title`) now render a **blank** price/total (`InvoiceSetPricing.IsNoPriceLine`), instead of `0,00 €`. - VAT rate detection no longer reads line items (the old `items is List` test failed on Newtonsoft `JArray` and pinned `@InvoiceVAT_1` to `19`); it now comes from `sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only the highest rate. The editor's running **total is unaffected by any set-price operation**, matching the registration balance — each operation conserves the total. ### Why the trigger lives in the editor The choice of *when* to apply a set-price operation is only known where the invoice is being composed (front-end), but the operation itself is executed **server-side** against the cached draft session — the editor merely names the target (a set-item `Ref` for function 1, or the chosen menu mode for functions 2 & 3) and re-renders the server's result. The back-end stays the single, tested authority for how each operation rewrites the lines; the editor never re-implements the grouping, the aggregation, or the pricing rules (ADR 0008/0019). ## Before/after per operation ### 1. The set-item switch (`item.setprice`, one set) Triggered from the invoice editor's row context menu (`$inv.toSetPrice`). The context button is shown — and the operation available — **only** on a row that is `typ == "set"` and is still unconverted (own price `0`). | Aspect | Before | After | |---|---|---| | Set line (`id == Ref`) price | `0` (as delivered by `fds__prepInvoice`, or as created in the structure dialog) | `v`/`vt`/`vv`/`vs`/`vsv` (and `items[].total_net`) = the sum of its transitive descendants | | Descendant lines | each shows its own price | every price field set to **`null`** (not `0`) → empty cell, excluded from the sum; the rows stay | | Membership | fixed by the server (`fds__prepInvoice` + the user's edits, ADR 0016) | **unchanged** — never re-derived or reassigned | | Lines outside the set | untouched | **still untouched**, regardless of row order | | Draft version / history | — | version +1, `item.setprice` entry with old/new header value | | Invoice total | — | **unchanged** | | No-ops | unknown `Ref`, `Ref` is not a set line, **or the set is already converted** → no version bump, no history | | ### 2 & 3. The two menu modes (every set, one action) | Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) | |---|---|---| | Grouping key | the set (top-level sets of every block) | the set (top-level sets of every block) | | Set line | receives the sum of its subtree | receives the sum of its subtree | | Its member rows | **kept**, every price field `null` → empty price/total cell | **removed** from `itm` and `items` entirely (nested set lines included) | | Already-converted set | left as it is | keeps its price, members still removed | | Lines in no set | **untouched**, individually priced | **untouched**, individually priced | | Several sets | each stays its own priced line | each stays its own priced line | | Reversibility | irreversible (discard draft or hand-edit) | irreversible (discard draft or hand-edit) | | Invoice total | **unchanged** | **unchanged** | | Invoice without sets / empty set | no-op (editor tells the user to define a set) | no-op (same) | ## Persistence note Draft/preview PDFs render straight from the cached draft session, so the contract works end-to-end for previews and creation. The menu modes bake their result directly into the session's items (set line priced, members blanked or removed), so no `setmode:` display token is needed or persisted; the finalised document is rendered once and stored as a file. Persisting the per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is only required if a finalised invoice must be **re-generated** from stored items later — not done here.