@kreiseck/kasseneck-api 1.11.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +2 -1
  3. package/dist/cjs/client/aufrufe.d.ts +4 -4
  4. package/dist/cjs/client/aufrufe.js +15 -0
  5. package/dist/cjs/client/receipts.d.ts +5 -0
  6. package/dist/cjs/client/receipts.js +65 -6
  7. package/dist/cjs/enums/voucher-action.d.ts +2 -0
  8. package/dist/cjs/enums/voucher-action.js +2 -0
  9. package/dist/cjs/enums/voucher-type.d.ts +10 -0
  10. package/dist/cjs/enums/voucher-type.js +10 -0
  11. package/dist/cjs/index.d.ts +3 -3
  12. package/dist/cjs/index.js +8 -3
  13. package/dist/cjs/models/index.d.ts +2 -2
  14. package/dist/cjs/models/index.js +6 -2
  15. package/dist/cjs/models/receipt-errors.d.ts +1 -1
  16. package/dist/cjs/models/receipt-errors.js +2 -0
  17. package/dist/cjs/models/receipt-item.d.ts +11 -3
  18. package/dist/cjs/models/receipt-item.js +22 -1
  19. package/dist/cjs/models/receipt.d.ts +13 -1
  20. package/dist/cjs/models/receipt.js +18 -2
  21. package/dist/cjs/models/voucher.d.ts +57 -4
  22. package/dist/cjs/models/voucher.js +111 -3
  23. package/dist/cjs/partner/fehler.d.ts +1 -1
  24. package/dist/cjs/partner/fehler.js +2 -0
  25. package/dist/cjs/partner/index.d.ts +1 -1
  26. package/dist/cjs/partner/typen.d.ts +7 -1
  27. package/dist/cjs/partner/typen.js +3 -0
  28. package/dist/cjs/pos/client.d.ts +2 -1
  29. package/dist/cjs/pos/client.js +3 -1
  30. package/dist/cjs/pos/gutschein.d.ts +90 -0
  31. package/dist/cjs/pos/gutschein.js +164 -0
  32. package/dist/cjs/pos/index.d.ts +3 -2
  33. package/dist/cjs/pos/index.js +13 -2
  34. package/dist/cjs/pos/settings.d.ts +3 -0
  35. package/dist/cjs/pos/settings.js +12 -10
  36. package/dist/cjs/pos/texte.d.ts +156 -0
  37. package/dist/cjs/pos/texte.js +72 -1
  38. package/dist/cjs/react/index.js +8 -2
  39. package/dist/cjs/receipt/due.d.ts +83 -2
  40. package/dist/cjs/receipt/due.js +202 -36
  41. package/dist/cjs/receipt/grid.js +13 -1
  42. package/dist/cjs/receipt/gutschein-plan.d.ts +104 -0
  43. package/dist/cjs/receipt/gutschein-plan.js +360 -0
  44. package/dist/cjs/receipt/index.d.ts +1 -1
  45. package/dist/cjs/receipt/index.js +3 -0
  46. package/dist/cjs/receipt/layout.js +175 -8
  47. package/dist/cjs/register/pairing.d.ts +21 -1
  48. package/dist/cjs/register/pairing.js +17 -0
  49. package/dist/cjs/stored/bon-layout.d.ts +8 -0
  50. package/dist/cjs/stored/bon-layout.js +23 -0
  51. package/dist/cjs/stored/draht.d.ts +3 -1
  52. package/dist/cjs/stored/draht.js +53 -6
  53. package/dist/cjs/stored/einstellungen.js +2 -1
  54. package/dist/cjs/stored/gutscheine.d.ts +3 -0
  55. package/dist/cjs/stored/gutscheine.js +60 -0
  56. package/dist/cjs/stored/index.js +2 -17
  57. package/dist/cjs/stored/vokabular.d.ts +6 -4
  58. package/dist/cjs/stored/vokabular.js +48 -2
  59. package/dist/cjs/version.d.ts +1 -1
  60. package/dist/cjs/version.js +1 -1
  61. package/dist/esm/client/aufrufe.d.ts +4 -4
  62. package/dist/esm/client/aufrufe.js +15 -0
  63. package/dist/esm/client/receipts.d.ts +5 -0
  64. package/dist/esm/client/receipts.js +66 -8
  65. package/dist/esm/enums/voucher-action.d.ts +2 -0
  66. package/dist/esm/enums/voucher-action.js +2 -0
  67. package/dist/esm/enums/voucher-type.d.ts +10 -0
  68. package/dist/esm/enums/voucher-type.js +10 -0
  69. package/dist/esm/index.d.ts +3 -3
  70. package/dist/esm/index.js +3 -3
  71. package/dist/esm/models/index.d.ts +2 -2
  72. package/dist/esm/models/index.js +2 -2
  73. package/dist/esm/models/receipt-errors.d.ts +1 -1
  74. package/dist/esm/models/receipt-errors.js +2 -0
  75. package/dist/esm/models/receipt-item.d.ts +11 -3
  76. package/dist/esm/models/receipt-item.js +20 -1
  77. package/dist/esm/models/receipt.d.ts +13 -1
  78. package/dist/esm/models/receipt.js +18 -2
  79. package/dist/esm/models/voucher.d.ts +57 -4
  80. package/dist/esm/models/voucher.js +108 -3
  81. package/dist/esm/partner/fehler.d.ts +1 -1
  82. package/dist/esm/partner/fehler.js +2 -0
  83. package/dist/esm/partner/index.d.ts +1 -1
  84. package/dist/esm/partner/typen.d.ts +7 -1
  85. package/dist/esm/partner/typen.js +3 -0
  86. package/dist/esm/pos/client.d.ts +2 -1
  87. package/dist/esm/pos/client.js +3 -1
  88. package/dist/esm/pos/gutschein.d.ts +90 -0
  89. package/dist/esm/pos/gutschein.js +158 -0
  90. package/dist/esm/pos/index.d.ts +3 -2
  91. package/dist/esm/pos/index.js +4 -2
  92. package/dist/esm/pos/settings.d.ts +3 -0
  93. package/dist/esm/pos/settings.js +2 -0
  94. package/dist/esm/pos/texte.d.ts +156 -0
  95. package/dist/esm/pos/texte.js +70 -0
  96. package/dist/esm/react/index.js +8 -2
  97. package/dist/esm/receipt/due.d.ts +83 -2
  98. package/dist/esm/receipt/due.js +198 -34
  99. package/dist/esm/receipt/grid.js +13 -1
  100. package/dist/esm/receipt/gutschein-plan.d.ts +104 -0
  101. package/dist/esm/receipt/gutschein-plan.js +352 -0
  102. package/dist/esm/receipt/index.d.ts +1 -1
  103. package/dist/esm/receipt/index.js +1 -1
  104. package/dist/esm/receipt/layout.js +175 -8
  105. package/dist/esm/register/pairing.d.ts +21 -1
  106. package/dist/esm/register/pairing.js +16 -0
  107. package/dist/esm/stored/bon-layout.d.ts +8 -0
  108. package/dist/esm/stored/bon-layout.js +20 -0
  109. package/dist/esm/stored/draht.d.ts +3 -1
  110. package/dist/esm/stored/draht.js +52 -6
  111. package/dist/esm/stored/einstellungen.js +3 -2
  112. package/dist/esm/stored/gutscheine.d.ts +3 -0
  113. package/dist/esm/stored/gutscheine.js +56 -0
  114. package/dist/esm/stored/index.js +2 -17
  115. package/dist/esm/stored/vokabular.d.ts +6 -4
  116. package/dist/esm/stored/vokabular.js +48 -2
  117. package/dist/esm/version.d.ts +1 -1
  118. package/dist/esm/version.js +1 -1
  119. package/fixtures/expected/voucher-cancellation.grid32.txt +30 -0
  120. package/fixtures/expected/voucher-cancellation.grid48.txt +29 -0
  121. package/fixtures/expected/voucher-cancellation.lines.json +252 -0
  122. package/fixtures/expected/voucher-cancellation.sheet32.json +206 -0
  123. package/fixtures/expected/voucher-cancellation.sheet48.json +200 -0
  124. package/fixtures/expected/voucher-multi-purpose-redeem.grid32.txt +25 -0
  125. package/fixtures/expected/voucher-multi-purpose-redeem.grid48.txt +25 -0
  126. package/fixtures/expected/voucher-multi-purpose-redeem.lines.json +259 -0
  127. package/fixtures/expected/voucher-multi-purpose-redeem.sheet32.json +176 -0
  128. package/fixtures/expected/voucher-multi-purpose-redeem.sheet48.json +176 -0
  129. package/fixtures/expected/voucher-multi-purpose-sale.grid32.txt +24 -0
  130. package/fixtures/expected/voucher-multi-purpose-sale.grid48.txt +23 -0
  131. package/fixtures/expected/voucher-multi-purpose-sale.lines.json +216 -0
  132. package/fixtures/expected/voucher-multi-purpose-sale.sheet32.json +170 -0
  133. package/fixtures/expected/voucher-multi-purpose-sale.sheet48.json +164 -0
  134. package/fixtures/expected/voucher-payout.grid32.txt +23 -0
  135. package/fixtures/expected/voucher-payout.grid48.txt +22 -0
  136. package/fixtures/expected/voucher-payout.lines.json +216 -0
  137. package/fixtures/expected/voucher-payout.sheet32.json +164 -0
  138. package/fixtures/expected/voucher-payout.sheet48.json +158 -0
  139. package/fixtures/expected/voucher-service-redeem.grid32.txt +26 -0
  140. package/fixtures/expected/voucher-service-redeem.grid48.txt +25 -0
  141. package/fixtures/expected/voucher-service-redeem.lines.json +261 -0
  142. package/fixtures/expected/voucher-service-redeem.sheet32.json +182 -0
  143. package/fixtures/expected/voucher-service-redeem.sheet48.json +176 -0
  144. package/fixtures/expected/voucher-single-purpose-redeem-exempt.grid32.txt +30 -0
  145. package/fixtures/expected/voucher-single-purpose-redeem-exempt.grid48.txt +29 -0
  146. package/fixtures/expected/voucher-single-purpose-redeem-exempt.lines.json +331 -0
  147. package/fixtures/expected/voucher-single-purpose-redeem-exempt.sheet32.json +206 -0
  148. package/fixtures/expected/voucher-single-purpose-redeem-exempt.sheet48.json +200 -0
  149. package/fixtures/expected/voucher-single-purpose-redeem.grid32.txt +25 -0
  150. package/fixtures/expected/voucher-single-purpose-redeem.grid48.txt +24 -0
  151. package/fixtures/expected/voucher-single-purpose-redeem.lines.json +246 -0
  152. package/fixtures/expected/voucher-single-purpose-redeem.sheet32.json +176 -0
  153. package/fixtures/expected/voucher-single-purpose-redeem.sheet48.json +170 -0
  154. package/fixtures/expected/voucher-single-purpose-sale.grid32.txt +26 -0
  155. package/fixtures/expected/voucher-single-purpose-sale.grid48.txt +24 -0
  156. package/fixtures/expected/voucher-single-purpose-sale.lines.json +225 -0
  157. package/fixtures/expected/voucher-single-purpose-sale.sheet32.json +182 -0
  158. package/fixtures/expected/voucher-single-purpose-sale.sheet48.json +170 -0
  159. package/fixtures/hobex-hps-codes.json +1 -1
  160. package/fixtures/invoice-api.schema.json +1 -1
  161. package/fixtures/invoice-texts.json +1 -1
  162. package/fixtures/manifest.json +64 -0
  163. package/fixtures/pos-texts.json +170 -1
  164. package/fixtures/receipt-due-generated.json +1 -1
  165. package/fixtures/receipt-due-voucher-generated.json +494 -0
  166. package/fixtures/receipts/voucher-cancellation.json +76 -0
  167. package/fixtures/receipts/voucher-multi-purpose-redeem.json +62 -0
  168. package/fixtures/receipts/voucher-multi-purpose-sale.json +63 -0
  169. package/fixtures/receipts/voucher-payout.json +65 -0
  170. package/fixtures/receipts/voucher-service-redeem.json +82 -0
  171. package/fixtures/receipts/voucher-single-purpose-redeem-exempt.json +86 -0
  172. package/fixtures/receipts/voucher-single-purpose-redeem.json +71 -0
  173. package/fixtures/receipts/voucher-single-purpose-sale.json +88 -0
  174. package/fixtures/surface.json +35 -5
  175. package/fixtures/v3/antworten/belege.json +3405 -3
  176. package/fixtures/v3/antworten/belegmail.json +5 -2
  177. package/fixtures/v3/antworten/kasse-belege.json +3423 -3
  178. package/fixtures/v3/antworten/kasse.json +621 -14
  179. package/fixtures/v3/antworten/lager.json +1 -1
  180. package/fixtures/v3/antworten/rechnungen.json +5 -2
  181. package/fixtures/v3/antworten/storno.json +214 -3
  182. package/fixtures/v3/stored/belege.json +5 -2
  183. package/fixtures/v3/stored/kasse.json +225 -4
  184. package/fixtures/v3/stored/rechnungen.json +5 -2
  185. package/fixtures/v3/v3-vokabular.json +1375 -23
  186. package/fixtures/v3/zahlbetrag-faelle.json +293 -3
  187. package/package.json +4 -1
@@ -0,0 +1,352 @@
1
+ import { VoucherCode } from '@kreiseck/validator';
2
+ import { isNewFormVoucher, voucherIsValid, voucherServerFields } from '../models/voucher.js';
3
+ /**
4
+ * Stichtag des 0-%-Topfs fuer Mehrzweck-Verkaeufe (Backend
5
+ * `gutschein-core.NULLTOPF_AB`). Solange er in der Zukunft liegt, zaehlt ein
6
+ * Mehrzweck-Verkauf als Fluss; ab dem Tag als Position im 0-%-Satz. Der
7
+ * Zahlbetrag bleibt gleich, Toepfe und Fluss verschieben sich.
8
+ */
9
+ export const VOUCHER_ZERO_RATE_BUCKET_FROM = '2099-12-31';
10
+ const KINDS = ['voucher', 'voucher_float'];
11
+ const ARTEN = ['multi_purpose', 'single_purpose', 'service', 'promotion'];
12
+ /** `vat-buckets.bucketFuerSatz`. */
13
+ export function topfFuerSatz(vat) {
14
+ switch (vat) {
15
+ case 20: return 'amountRateStandard';
16
+ case 10: return 'amountRateReduced1';
17
+ case 13: return 'amountRateReduced2';
18
+ case 0: return 'amountRateZero';
19
+ case 19: return 'amountRateSpecial';
20
+ case 4.9: return 'amountRateSpecial';
21
+ default: return 'amountRatOthers';
22
+ }
23
+ }
24
+ const TOPF_REIHENFOLGE = ['amountRateStandard', 'amountRateReduced1', 'amountRateReduced2', 'amountRateZero', 'amountRateSpecial', 'amountRatOthers'];
25
+ function istGutscheinZeile(p) {
26
+ return p.kind !== undefined && KINDS.includes(p.kind);
27
+ }
28
+ /** Position, die ein Gutschein decken darf: weder Trinkgeld noch Gutscheinzeile. */
29
+ export function istDeckbar(p) {
30
+ return p.tip == null && p.kind !== 'tip' && !istGutscheinZeile(p);
31
+ }
32
+ /** Wie beleg-toepfe: Euro in Positionsreihenfolge aufsummieren, erst die Summe auf Cent runden. */
33
+ export function centSumme(liste) {
34
+ return Math.round(liste.reduce((s, it) => s + it.priceOne * it.amount, 0) * 100);
35
+ }
36
+ /** `gutschein-core.satzBpAusVat`. */
37
+ function satzBp(vat) {
38
+ return Math.round(Number(vat) * 100);
39
+ }
40
+ function position(satzBpWert, cents, kind) {
41
+ return { amount: 1, priceOne: cents / 100, vat: satzBpWert / 100, tip: null, kind };
42
+ }
43
+ /** Code in Normalform (tolerant eingelesen, Pruefzeichen stimmt) oder null. */
44
+ function normalerCode(code) {
45
+ if (typeof code !== 'string')
46
+ return null;
47
+ const v = VoucherCode.validate(code);
48
+ return v.ok ? v.normalized : null;
49
+ }
50
+ /** Ist der Eintrag ein Wunsch der neuen Form (nicht vom Server gelesen)? */
51
+ function istWunsch(v) {
52
+ return isNewFormVoucher(v) && voucherServerFields(v).length === 0;
53
+ }
54
+ /** Ein gelesener Eintrag (Serverfelder gesetzt): nur Wert und Rabatt tragen Geld ausserhalb der Positionen. */
55
+ function istGelesen(v) {
56
+ return voucherServerFields(v).length > 0;
57
+ }
58
+ const STATUS_FEHLER = {
59
+ used_up: 'voucher_used_up', paid_out: 'voucher_used_up', cancelled: 'voucher_cancelled', blocked: 'voucher_blocked', expired: 'voucher_expired',
60
+ };
61
+ /** Die Saetze, auf die `kuTeile` verteilt: je Satz `centSumme` der deckbaren Positionen, nur positive. */
62
+ export function kuSaetze(kunde) {
63
+ const jeSatz = new Map();
64
+ for (const it of kunde) {
65
+ if (!istDeckbar(it))
66
+ continue;
67
+ const s = satzBp(it.vat);
68
+ if (!jeSatz.has(s))
69
+ jeSatz.set(s, []);
70
+ jeSatz.get(s).push(it);
71
+ }
72
+ return [...jeSatz].map(([s, liste]) => ({ satzBp: s, basis: centSumme(liste) })).filter((x) => x.basis > 0);
73
+ }
74
+ /** `gutschein-core.kuTeile`: anteilig zur Positionssumme je Satz, groesster Rest, Topf-Reihenfolge bei Gleichstand. */
75
+ function kuTeile(kunde, betragCents) {
76
+ const jeSatz = new Map();
77
+ for (const it of kunde) {
78
+ if (!istDeckbar(it))
79
+ continue;
80
+ const s = satzBp(it.vat);
81
+ if (!jeSatz.has(s))
82
+ jeSatz.set(s, []);
83
+ jeSatz.get(s).push(it);
84
+ }
85
+ const rang = (s) => TOPF_REIHENFOLGE.indexOf(topfFuerSatz(s / 100));
86
+ const saetze = [...jeSatz]
87
+ .map(([s, liste]) => ({ satzBp: s, basis: centSumme(liste), cents: 0, rest: 0 }))
88
+ .filter((s) => s.basis > 0)
89
+ .sort((a, b) => (rang(a.satzBp) - rang(b.satzBp)) || (b.satzBp - a.satzBp));
90
+ const summe = saetze.reduce((s, x) => s + x.basis, 0);
91
+ if (summe <= 0 || betragCents > summe)
92
+ return null;
93
+ for (const s of saetze) {
94
+ s.cents = Math.floor((betragCents * s.basis) / summe);
95
+ s.rest = (betragCents * s.basis) % summe;
96
+ }
97
+ let offen = betragCents - saetze.reduce((s, x) => s + x.cents, 0);
98
+ const nachRest = saetze.map((s, i) => ({ s, i })).sort((a, b) => (b.s.rest - a.s.rest) || (a.i - b.i));
99
+ for (let k = 0; offen > 0; k++, offen--)
100
+ nachRest[k].s.cents += 1;
101
+ return saetze.filter((s) => s.cents > 0).map((s) => ({ satzBp: s.satzBp, cents: s.cents }));
102
+ }
103
+ /**
104
+ * Wuensche und gelesene Eintraege in Positionen und Belegeintraege, wie
105
+ * `plane` sie baut. `kunde` sind Ware und Trinkgeld in innerer Form.
106
+ */
107
+ export function planeGutscheine(kunde, vouchers, art, o) {
108
+ const nein = (fehler, text) => ({ fehler, text });
109
+ const storno = art === 'cancellation';
110
+ const wuensche = vouchers.filter(istWunsch);
111
+ const zusatz = [];
112
+ const gutscheine = [];
113
+ // Wert und Rabatt der Altform bzw. gelesener Belege: Euro aus ganzen Cent.
114
+ const altEintrag = (v, i) => {
115
+ if (!Number.isSafeInteger(v.valueCents))
116
+ return nein('voucher_invalid', `Gutschein ${i + 1} hat keinen ganzen Cent-Wert.`);
117
+ return { action: String(v.action), type: String(v.type), value: Math.round(v.valueCents) / 100, ...(v.zeroRateBucket === true ? { imNullTopf: true } : {}) };
118
+ };
119
+ if (wuensche.length === 0) {
120
+ const nulltopf = !storno && o.today >= (o.zeroRateBucketFrom ?? VOUCHER_ZERO_RATE_BUCKET_FROM);
121
+ // `gutschein-transaktion.brauchtPlan`: ab dem Stichtag laeuft plane auch fuer einen Altform-Verkauf.
122
+ const mitPlan = nulltopf && vouchers.some((v) => !istGelesen(v) && v.action === 'sell' && v.type === 'value');
123
+ if (mitPlan && kunde.some(istGutscheinZeile))
124
+ return nein('voucher_invalid', 'Gutschein-Positionen setzt nur der Server.');
125
+ for (const [i, v] of vouchers.entries()) {
126
+ // Gelesene Eintraege der neuen Form ohne Wert/Rabatt: ihr Geld steht in den Positionen.
127
+ if (istGelesen(v) && v.type !== 'value' && v.type !== 'promo')
128
+ continue;
129
+ const g = altEintrag(v, i);
130
+ if ('fehler' in g)
131
+ return g;
132
+ // Altform-Verkauf ab dem Stichtag: Betrag in den 0-%-Topf.
133
+ if (nulltopf && !istGelesen(v) && g.action === 'sell' && g.type === 'value') {
134
+ zusatz.push(position(0, Math.round(v.valueCents), 'voucher_float'));
135
+ g.imNullTopf = true;
136
+ }
137
+ gutscheine.push(g);
138
+ }
139
+ return { zusatz, gutscheine, auszahlungCents: 0, mitPlan, minusJeTopf: new Map() };
140
+ }
141
+ // Ab hier: mindestens ein Wunsch der neuen Form (createReceipt fuehrt `plane` aus).
142
+ if (storno)
143
+ return nein('voucher_invalid', 'Gutscheine der neuen Form gibt es am Storno nur gespiegelt vom Server.');
144
+ if (art === 'training')
145
+ return nein('voucher_invalid', 'Gutscheine der neuen Form gibt es nicht auf Schulungsbelegen.');
146
+ if (kunde.some(istGutscheinZeile))
147
+ return nein('voucher_invalid', 'Gutschein-Positionen setzt nur der Server.');
148
+ for (const [i, v] of vouchers.entries()) {
149
+ if (istGelesen(v))
150
+ return nein('voucher_invalid', `Gutschein ${i + 1}: Serverfelder an einem Wunsch.`);
151
+ if (isNewFormVoucher(v) && !voucherIsValid(v))
152
+ return nein('voucher_invalid', `Gutschein ${i + 1} ist unvollstaendig.`);
153
+ }
154
+ // Kein Gutschein mit Gutschein bezahlen, sobald einer der beiden die neue Form hat.
155
+ const handel = vouchers.filter((v) => v.action === 'sell' || v.action === 'redeem');
156
+ if (handel.some((v) => v.action === 'sell') && handel.some((v) => v.action === 'redeem') && handel.some(isNewFormVoucher))
157
+ return nein('voucher_invalid', 'Gutscheine verkaufen und einloesen gehen nicht am selben Beleg.');
158
+ if (wuensche.some((v) => v.type === 'promotion') && vouchers.length > 1)
159
+ return nein('voucher_invalid', 'Ein Aktionsgutschein steht allein am Beleg.');
160
+ if (wuensche.some((v) => v.action === 'payout') && vouchers.some((v) => !isNewFormVoucher(v) && v.type === 'promo')) {
161
+ return nein('voucher_invalid', 'Rabatt und Auszahlung gehen nicht am selben Beleg.');
162
+ }
163
+ const codes = wuensche.filter((v) => v.action !== 'sell').map((v) => normalerCode(v.code));
164
+ if (codes.some((c) => c == null))
165
+ return nein('voucher_invalid', 'Ein Gutscheincode ist ungueltig.');
166
+ if (new Set(codes).size !== codes.length)
167
+ return nein('voucher_invalid', 'Derselbe Gutschein steht zweimal am Beleg.');
168
+ const ku = o.smallBusiness === true;
169
+ const nulltopf = o.today >= (o.zeroRateBucketFrom ?? VOUCHER_ZERO_RATE_BUCKET_FROM);
170
+ const minusJeTopf = new Map();
171
+ const minus = (s, c) => {
172
+ const topf = topfFuerSatz(s / 100);
173
+ minusJeTopf.set(topf, (minusJeTopf.get(topf) ?? 0) + c);
174
+ };
175
+ const belegt = new Set();
176
+ let auszahlungCents = 0;
177
+ const einstellung = (gewollt) => {
178
+ const e = o.voucherSettings;
179
+ // Der Server verkauft nur mit bestaetigter Einstellung (`voucher_settings_missing`), auch Mehrzweck.
180
+ if (e == null || e.valueVoucherType == null) {
181
+ return nein('voucher_settings_missing', 'Die Gutschein-Einstellung der Kasse fehlt (voucherSettings).');
182
+ }
183
+ return e.valueVoucherType === gewollt ? null : nein('voucher_invalid', 'Die Gutschein-Einstellung der Kasse verkauft eine andere Art.');
184
+ };
185
+ const pruefung = (v, i) => {
186
+ const code = normalerCode(v.code);
187
+ const info = (o.voucherInfo ?? []).find((x) => normalerCode(x.code) === code);
188
+ if (info == null)
189
+ return nein('voucher_not_found', `Gutschein ${i + 1}: keine Pruefung zum Code (checkVoucher).`);
190
+ if (info.check.type !== v.type)
191
+ return nein('voucher_invalid', `Gutschein ${i + 1}: die Art passt nicht zur Pruefung.`);
192
+ return info.check;
193
+ };
194
+ const saldo = (check, c, i) => Number.isInteger(check.balanceCents) && c <= check.balanceCents
195
+ ? null
196
+ : nein('voucher_insufficient_balance', `Gutschein ${i + 1}: der Betrag uebersteigt den Saldo.`);
197
+ for (const [i, v] of vouchers.entries()) {
198
+ if (!isNewFormVoucher(v)) {
199
+ const g = altEintrag(v, i);
200
+ if ('fehler' in g)
201
+ return g;
202
+ if (nulltopf && g.action === 'sell' && g.type === 'value') {
203
+ zusatz.push(position(0, Math.round(v.valueCents), 'voucher_float'));
204
+ g.imNullTopf = true;
205
+ }
206
+ gutscheine.push(g);
207
+ continue;
208
+ }
209
+ if (!ARTEN.includes(String(v.type)))
210
+ return nein('voucher_invalid', `Gutschein ${i + 1}: unbekannte Art.`);
211
+ if (v.action === 'sell') {
212
+ if (v.type === 'multi_purpose') {
213
+ const f = einstellung('multi_purpose');
214
+ if (f)
215
+ return f;
216
+ const c = v.amountCents;
217
+ if (nulltopf) {
218
+ zusatz.push(position(0, c, 'voucher_float'));
219
+ gutscheine.push({ action: 'sell', type: 'value', value: c / 100, imNullTopf: true });
220
+ }
221
+ else {
222
+ gutscheine.push({ action: 'sell', type: 'value', value: c / 100 });
223
+ }
224
+ }
225
+ else if (v.type === 'single_purpose') {
226
+ const f = einstellung('single_purpose');
227
+ if (f)
228
+ return f;
229
+ const satz = ku ? 0 : o.voucherSettings.vatRateBp;
230
+ if (!Number.isInteger(satz) || satz < 0)
231
+ return nein('voucher_settings_missing', 'Der Satz des Einzweckgutscheins fehlt (Gutschein-Einstellung der Kasse).');
232
+ zusatz.push(position(satz, v.amountCents, 'voucher'));
233
+ }
234
+ else {
235
+ const a = (o.voucherArticles ?? []).find((x) => x.id === v.articleId);
236
+ if (a == null)
237
+ return nein('voucher_item_mismatch', `Gutschein ${i + 1}: der Artikel fehlt (voucherArticles).`);
238
+ if (!Number.isInteger(a.priceCents) || a.priceCents <= 0 || !Number.isInteger(a.vatRateBp) || a.vatRateBp < 0) {
239
+ return nein('voucher_invalid', `Gutschein ${i + 1}: Artikel ohne Preis oder Satz.`);
240
+ }
241
+ const c = v.quantity * a.priceCents;
242
+ if (c > 1_000_000)
243
+ return nein('voucher_invalid', `Gutschein ${i + 1}: Betrag zu hoch.`);
244
+ zusatz.push(position(ku ? 0 : a.vatRateBp, c, 'voucher'));
245
+ }
246
+ continue;
247
+ }
248
+ const check = pruefung(v, i);
249
+ if ('fehler' in check)
250
+ return check;
251
+ if (v.action === 'payout') {
252
+ // `gutschein-core.darfAuszahlen`: aktiv, oder abgelaufen bis zum Ende der Rueckzahlbarkeit.
253
+ if (!((check.balanceCents ?? 0) > 0))
254
+ return nein('voucher_used_up', `Gutschein ${i + 1}: kein Saldo.`);
255
+ if (check.status === 'expired') {
256
+ const bis = check.validity?.refundableUntil;
257
+ if (bis && o.today > bis)
258
+ return nein('voucher_expired', `Gutschein ${i + 1}: nicht mehr rueckzahlbar.`);
259
+ }
260
+ else if (check.status !== 'active') {
261
+ return nein(STATUS_FEHLER[check.status] ?? 'voucher_invalid', `Gutschein ${i + 1}: Status ${check.status}.`);
262
+ }
263
+ const c = v.amountCents;
264
+ const f = saldo(check, c, i);
265
+ if (f)
266
+ return f;
267
+ if (v.type === 'multi_purpose') {
268
+ zusatz.push(position(0, -c, 'voucher_float'));
269
+ gutscheine.push({ action: 'payout', type: 'value', value: c / 100, imNullTopf: true });
270
+ }
271
+ else {
272
+ if (!Number.isInteger(check.vatRateBp))
273
+ return nein('voucher_invalid', `Gutschein ${i + 1}: Satz fehlt in der Pruefung.`);
274
+ zusatz.push(position(check.vatRateBp, -c, 'voucher'));
275
+ }
276
+ auszahlungCents += c;
277
+ continue;
278
+ }
279
+ if (check.status !== 'active')
280
+ return nein(STATUS_FEHLER[check.status] ?? 'voucher_invalid', `Gutschein ${i + 1}: Status ${check.status}.`);
281
+ if (v.type === 'multi_purpose' || v.type === 'single_purpose') {
282
+ const c = v.amountCents;
283
+ const f = saldo(check, c, i);
284
+ if (f)
285
+ return f;
286
+ if (v.type === 'multi_purpose') {
287
+ gutscheine.push({ action: 'redeem', type: 'value', value: c / 100 });
288
+ }
289
+ else if (check.exempt === true || ku) {
290
+ // Befreit verkauft (`kuEingefroren`) oder Kleinunternehmer-Konto: der gedeckte Teil
291
+ // verlaesst anteilig alle Toepfe des Korbs.
292
+ const teile = kuTeile(kunde, c);
293
+ if (!teile)
294
+ return nein('voucher_rate_mismatch', `Gutschein ${i + 1}: der Korb deckt den Betrag nicht.`);
295
+ for (const t of teile) {
296
+ zusatz.push(position(t.satzBp, -t.cents, 'voucher'));
297
+ minus(t.satzBp, t.cents);
298
+ }
299
+ }
300
+ else {
301
+ if (!Number.isInteger(check.vatRateBp))
302
+ return nein('voucher_invalid', `Gutschein ${i + 1}: Satz fehlt in der Pruefung.`);
303
+ zusatz.push(position(check.vatRateBp, -c, 'voucher'));
304
+ minus(check.vatRateBp, c);
305
+ }
306
+ }
307
+ else if (v.type === 'service') {
308
+ const artikelId = check.article?.id;
309
+ let stueck = 0;
310
+ const jeSatz = new Map();
311
+ for (const k of v.positions ?? []) {
312
+ const it = kunde[k];
313
+ if (it == null || it.kind !== undefined || it.tip != null || !artikelId || it.articleId !== artikelId
314
+ || !Number.isInteger(it.amount) || it.amount < 1 || belegt.has(k)) {
315
+ return nein('voucher_item_mismatch', `Gutschein ${i + 1}: Position ${k + 1} passt nicht zum Leistungsgutschein.`);
316
+ }
317
+ belegt.add(k);
318
+ stueck += it.amount;
319
+ const s = satzBp(it.vat);
320
+ if (!jeSatz.has(s))
321
+ jeSatz.set(s, []);
322
+ jeSatz.get(s).push(it);
323
+ }
324
+ if (!Number.isInteger(check.remainingQuantity) || stueck > check.remainingQuantity) {
325
+ return nein('voucher_insufficient_balance', `Gutschein ${i + 1}: nicht genug offene Stueck.`);
326
+ }
327
+ for (const [s, liste] of jeSatz) {
328
+ const c = centSumme(liste);
329
+ zusatz.push(position(s, -c, 'voucher'));
330
+ minus(s, c);
331
+ }
332
+ }
333
+ else {
334
+ // Aktion: Rabatt ueber die Altmechanik promo.
335
+ const basis = centSumme(kunde.filter(istDeckbar));
336
+ const roh = Number.isInteger(check.discountCents) && check.discountCents > 0
337
+ ? check.discountCents
338
+ : Math.floor((basis * (Number(check.discountPercentBp) || 0)) / 10000);
339
+ const rabatt = Math.min(roh, basis);
340
+ if (!(rabatt > 0))
341
+ return nein('voucher_invalid', `Gutschein ${i + 1}: kein Rabatt auf diesen Korb.`);
342
+ gutscheine.push({ action: 'redeem', type: 'promo', value: rabatt / 100 });
343
+ }
344
+ }
345
+ // Einloese-Minuszeilen eines Topfs uebersteigen nie die deckbaren Positionen dieses Topfs.
346
+ for (const [topf, c] of minusJeTopf) {
347
+ const basis = centSumme(kunde.filter((it) => istDeckbar(it) && topfFuerSatz(it.vat) === topf));
348
+ if (c > basis)
349
+ return nein('voucher_rate_mismatch', 'Ein Gutschein deckt mehr als die Positionen seines Steuersatzes.');
350
+ }
351
+ return { zusatz, gutscheine, auszahlungCents, mitPlan: true, minusJeTopf };
352
+ }
@@ -7,4 +7,4 @@ export { rasterizeLogo } from './bild.js';
7
7
  export { type BrandMarkRaster, BRAND_MARK_RASTERS, BRAND_MARK_PATHS } from './marke-daten.js';
8
8
  export { brandMarkImage } from './marke.js';
9
9
  export { type CodeTableTestSheetInput, type CodeTableTestSheetRow, type CodeTableTestSheet, CODE_TABLE_TEST_SHEET_CHARS, codeTableTestSheet, codeTableTestSheetBytes, codeTableReferenceImage, } from './code-table-test-sheet.js';
10
- export { type ReceiptDueTip, type ReceiptDueTipRecipient, type ReceiptDueOptions, type ReceiptDueBuckets, type ReceiptDueBreakdown, type ReceiptDueErrorReason, receiptDueCents, receiptDueBreakdown, ReceiptDueError, RECEIPT_DUE_ERROR_REASONS, isReceiptDueError, } from './due.js';
10
+ export { type ReceiptDueTip, type ReceiptDueTipRecipient, type ReceiptDueOptions, type ReceiptDueBuckets, type ReceiptDueBreakdown, type ReceiptDueErrorReason, receiptDueCents, receiptDueBreakdown, ReceiptDueError, RECEIPT_DUE_ERROR_REASONS, isReceiptDueError, type VoucherDueInfo, type VoucherDueArticle, type VoucherRedeemContext, voucherRedeemMaxCents, VOUCHER_ZERO_RATE_BUCKET_FROM, } from './due.js';
@@ -7,4 +7,4 @@ export { rasterizeLogo } from './bild.js';
7
7
  export { BRAND_MARK_RASTERS, BRAND_MARK_PATHS } from './marke-daten.js';
8
8
  export { brandMarkImage } from './marke.js';
9
9
  export { CODE_TABLE_TEST_SHEET_CHARS, codeTableTestSheet, codeTableTestSheetBytes, codeTableReferenceImage, } from './code-table-test-sheet.js';
10
- export { receiptDueCents, receiptDueBreakdown, ReceiptDueError, RECEIPT_DUE_ERROR_REASONS, isReceiptDueError, } from './due.js';
10
+ export { receiptDueCents, receiptDueBreakdown, ReceiptDueError, RECEIPT_DUE_ERROR_REASONS, isReceiptDueError, voucherRedeemMaxCents, VOUCHER_ZERO_RATE_BUCKET_FROM, } from './due.js';
@@ -561,6 +561,152 @@ function gutscheinText(voucher) {
561
561
  }
562
562
  return text;
563
563
  }
564
+ /*
565
+ * Gutscheine der neuen Form (Spec 2026-10-14, „Bon und Beleg“). Der Dart-
566
+ * Zwilling uebernimmt Texte und Spaltenbreiten (Zwoelftel) genau so:
567
+ *
568
+ * Verkauf Einzweck/Leistung Positionszeile 7:5 „1 x <Name> <Code>“ | „50,00 A“
569
+ * Verkauf Mehrzweck dieselbe Zeile (Position im 0-%-Topf oder, vor dem
570
+ * Stichtag, aus dem Eintrag) + Text „ USt bei Einlösung“
571
+ * Einloesung Einzweck/ 8:4 „Gutschein ····-9DWT“ | „-30,50 A“, nach der letzten
572
+ * Leistung Zeile dieses Gutscheins Text „ Einzweck, versteuert mit
573
+ * Beleg K1-ID-4711“ (bzw. „Leistung, …“, „…, befreit
574
+ * verkauft (Kleinunternehmer)“, 47 Zeichen: passt auf
575
+ * 80 mm) und 8:4 „ Rest am Gutschein“ | „19,50 “ bzw.
576
+ * „2 Stück“
577
+ * Einloesung Mehrzweck nach „Zwischensumme“ 8:4 „Gutschein ····-9DWT“ |
578
+ * „-17,50 €“ und „ Rest am Gutschein“ | „32,50 €“
579
+ * Auszahlung 8:4 „Auszahlung Gutschein ····-9DWT“ | „-12,00 D“,
580
+ * Text „ Grund: <Grund>“
581
+ * Aktion Rabattzeile wie bisher (Promotionsgutschein)
582
+ *
583
+ * Vermerke sind Textzeilen mit zwei fuehrenden Leerzeichen; das Raster bricht
584
+ * sie mit demselben Einzug um (grid.ts, haengender Einzug), auf 58 mm etwa
585
+ * „ Einzweck, versteuert mit“ / „ Beleg K1-ID-4711“.
586
+ *
587
+ * Die Zusatzzeilen kommen nur aus dem Eintrag: ein Beleg, den der Server fuer
588
+ * ein aelteres Paket aufbereitet hat (Eintraege mit Position entfernt, Code an
589
+ * der Position, functions/beleg-layout.js `gutscheineFuerPaket`), zeigt seine
590
+ * Positionen wie jede andere Position. Ein Betrag zaehlt immer genau einmal:
591
+ * Positionen in der Steuertabelle, Eintraege mit `zeroRateBucket` nie.
592
+ */
593
+ /** Steht der Betrag des Eintrags als Position am Beleg (Einzweck, Leistung, 0-%-Topf)? */
594
+ function gutscheinStehtAlsPosition(voucher) {
595
+ return voucher.type === VoucherType.single_purpose || voucher.type === VoucherType.service || voucher.zeroRateBucket === true;
596
+ }
597
+ /** Mehrzweckgutschein mit Code (Altart `value`, vom Server als `multi_purpose` gekennzeichnet). */
598
+ function istMehrzweckMitCode(voucher) {
599
+ return voucher.type === VoucherType.value && voucher.voucherType === VoucherType.multi_purpose;
600
+ }
601
+ /**
602
+ * Code fuer die Belegzeile: die Maske des Eintrags, ohne den Platzhalter
603
+ * `no-code`, den der Server Alt-Eintraegen ohne Code setzt (index.js
604
+ * checkVouchersIsValid). Leer, wenn es keinen gibt.
605
+ */
606
+ function gutscheinCodeZusatz(voucher) {
607
+ return typeof voucher.code === 'string' && voucher.code !== '' && voucher.code !== 'no-code' ? ` ${voucher.code}` : '';
608
+ }
609
+ /** Die vier letzten Zeichen des Codes (`codeSuffix`, sonst aus der Maske), oder null. */
610
+ function gutscheinEndung(voucher) {
611
+ if (typeof voucher.codeSuffix === 'string' && voucher.codeSuffix !== '')
612
+ return voucher.codeSuffix;
613
+ return typeof voucher.code === 'string' && voucher.code.length >= 4 && voucher.code !== 'no-code' ? voucher.code.slice(-4) : null;
614
+ }
615
+ const MEHRZWECK_UST_TEXT = ' USt bei Einlösung';
616
+ /**
617
+ * Welche Gutscheinposition (`voucher`/`voucher_float`) gehoert zu welchem
618
+ * Eintrag? Jede Position hoechstens einem Eintrag, Eintraege in ihrer
619
+ * Reihenfolge (der Server haengt die Positionen in derselben Reihenfolge an).
620
+ * Verkauf: erste freie Position der Art mit demselben Betrag und ohne Code im
621
+ * Namen (wie `gutscheineFuerPaket` des Servers). Einloesung: freie Positionen
622
+ * mit genau dem Namen `Gutschein ····-<Endung>`, Auszahlung: `Auszahlung
623
+ * Gutschein ····-<Endung>` (gutschein-beleg.plane) -- der Reihe nach, bis
624
+ * ihr Betrag den des Eintrags erreicht (ein Kleinunternehmer-Einzweck oder
625
+ * eine Leistung ueber mehrere Saetze hat je Satz eine Position; zwei
626
+ * Gutscheine mit derselben Endung teilen sich so nie eine Position). Ohne
627
+ * Betrag am Eintrag genau eine Position.
628
+ */
629
+ function gutscheinPositionen(receipt) {
630
+ const zuordnung = new Map();
631
+ const istGutscheinzeile = (it) => it.kind === 'voucher' || it.kind === 'voucher_float';
632
+ for (const voucher of receipt.vouchers) {
633
+ if (!gutscheinStehtAlsPosition(voucher))
634
+ continue;
635
+ if (voucher.action === VoucherAction.sell) {
636
+ const art = voucher.zeroRateBucket === true ? 'voucher_float' : 'voucher';
637
+ const cents = Number.isInteger(voucher.amountCents) ? Math.abs(voucher.amountCents) : Math.abs(voucher.valueCents ?? 0);
638
+ const i = receipt.items.findIndex((it, k) => !zuordnung.has(k) && it.kind === art
639
+ && Math.abs(receiptItemTotalCents(it)) === cents && !it.name.includes('····'));
640
+ if (i >= 0)
641
+ zuordnung.set(i, { voucher, letzte: true });
642
+ continue;
643
+ }
644
+ const endung = gutscheinEndung(voucher);
645
+ if (endung == null || (voucher.action !== VoucherAction.redeem && voucher.action !== VoucherAction.payout))
646
+ continue;
647
+ const name = `${voucher.action === VoucherAction.payout ? 'Auszahlung ' : ''}Gutschein ····-${endung}`;
648
+ const ziel = Number.isInteger(voucher.amountCents) ? Math.abs(voucher.amountCents) : Math.abs(voucher.valueCents ?? 0);
649
+ const genommen = [];
650
+ let summe = 0;
651
+ receipt.items.forEach((it, k) => {
652
+ if (zuordnung.has(k) || !istGutscheinzeile(it) || it.name !== name)
653
+ return;
654
+ if (genommen.length > 0 && (ziel <= 0 || summe >= ziel))
655
+ return;
656
+ genommen.push(k);
657
+ summe += Math.abs(receiptItemTotalCents(it));
658
+ });
659
+ genommen.forEach((k, n) => zuordnung.set(k, { voucher, letzte: n === genommen.length - 1 }));
660
+ }
661
+ return zuordnung;
662
+ }
663
+ /**
664
+ * Zeilen einer Gutscheinposition mit ihrem Eintrag. Verkauf als gewoehnliche
665
+ * Positionszeile mit dem Code des Eintrags; Einloesung und Auszahlung ohne
666
+ * Menge in der Breite der Rabattzeilen (8:4), damit „Gutschein ····-9DWT“ auch
667
+ * auf 58 mm in einer Zeile steht.
668
+ */
669
+ function gutscheinPositionsZeilen(item, satz, zu) {
670
+ const v = zu.voucher;
671
+ if (v.action === VoucherAction.sell) {
672
+ const zeilen = [positionsZeile({ ...item, name: `${item.name}${gutscheinCodeZusatz(v)}` }, satz)];
673
+ if (v.type === VoucherType.value)
674
+ zeilen.push(textZeile(MEHRZWECK_UST_TEXT));
675
+ return zeilen;
676
+ }
677
+ const zeilen = [{
678
+ kind: 'columns',
679
+ columns: [
680
+ { text: item.name, width: 8, align: 'left' },
681
+ { text: `${formatCents(receiptItemTotalCents(item))} ${satz.category}`, width: 4, align: 'right' },
682
+ ],
683
+ }];
684
+ if (!zu.letzte)
685
+ return zeilen;
686
+ if (v.action === VoucherAction.payout) {
687
+ const grund = typeof v.reason === 'string' ? v.reason.trim() : '';
688
+ if (grund !== '')
689
+ zeilen.push(textZeile(` Grund: ${grund}`));
690
+ return zeilen;
691
+ }
692
+ const art = v.type === VoucherType.service ? 'Leistung' : 'Einzweck';
693
+ if (typeof v.taxedWith === 'string' && v.taxedWith !== '') {
694
+ zeilen.push(textZeile(` ${art}, versteuert mit Beleg ${v.taxedWith}`));
695
+ }
696
+ else if (v.exempt === true) {
697
+ zeilen.push(textZeile(` ${art}, befreit verkauft (Kleinunternehmer)`));
698
+ }
699
+ // Rest: Betrag fluchtet mit den Positionsbetraegen (zwei Zeichen statt
700
+ // „ A“), Stueck rechtsbuendig. Die Gesamtzahl kennt der Beleg nicht.
701
+ if (v.type === VoucherType.service) {
702
+ if (Number.isInteger(v.remainingQuantityAfter))
703
+ zeilen.push(paarZeile(' Rest am Gutschein', `${v.remainingQuantityAfter} Stück`, 8, 4));
704
+ }
705
+ else if (Number.isInteger(v.balanceAfterCents)) {
706
+ zeilen.push(paarZeile(' Rest am Gutschein', `${formatCents(v.balanceAfterCents)} `, 8, 4));
707
+ }
708
+ return zeilen;
709
+ }
564
710
  // ----------------------------------------------------------------- Bauteile
565
711
  function textZeile(text, align = 'left', bold = false) {
566
712
  return { kind: 'text', text, align, bold };
@@ -905,13 +1051,15 @@ export function buildReceiptLayout(receipt, company, options = {}) {
905
1051
  .filter((it) => !isTipItem(it) && !isDiscountItem(it))
906
1052
  .reduce((summe, it) => summe + receiptItemTotalCents(it), 0);
907
1053
  let zwischensummeGezeigt = false;
1054
+ const gutscheinZuordnung = gutscheinPositionen(receipt);
908
1055
  let i = 0;
909
1056
  while (i < receipt.items.length) {
910
1057
  const item = receipt.items[i];
911
1058
  if (!isTipItem(item) && !isDiscountItem(item)) {
912
1059
  const satz = steuersatz(item.vat);
913
1060
  gruppeVon(satz).bruttoCents += receiptItemTotalCents(item);
914
- lines.push(positionsZeile(item, satz));
1061
+ const gutschein = gutscheinZuordnung.get(i);
1062
+ lines.push(...(gutschein ? gutscheinPositionsZeilen(item, satz, gutschein) : [positionsZeile(item, satz)]));
915
1063
  i += 1;
916
1064
  continue;
917
1065
  }
@@ -948,22 +1096,31 @@ export function buildReceiptLayout(receipt, company, options = {}) {
948
1096
  // Promotionsgutscheine mindern die Steuergruppen anteilig.
949
1097
  // Am Storno-Beleg zaehlen Wertgutscheine mit umgekehrtem Vorzeichen
950
1098
  // (receiptValueVoucherSign): der verkaufte wird zurueckgezahlt.
1099
+ // Eintraege mit `zeroRateBucket` stehen schon als Position (0-%-Topf).
951
1100
  const wgVz = receiptValueVoucherSign(receipt);
952
1101
  let promoCents = 0;
953
1102
  for (const voucher of receipt.vouchers) {
1103
+ // Betrag steht als Position (Einzweck, Leistung, 0-%-Topf): weder Zeile noch 0-%-Zeile aus dem Eintrag.
1104
+ if (gutscheinStehtAlsPosition(voucher))
1105
+ continue;
954
1106
  const wert = voucher.valueCents ?? 0;
955
1107
  const vz = voucher.type === VoucherType.value ? wgVz : 1;
956
1108
  if (voucherIsValid(voucher) && voucher.action === VoucherAction.sell) {
957
1109
  gruppeVon(steuersatz(VatRate.vat0)).bruttoCents += vz * wert;
958
1110
  }
959
1111
  if (voucher.action === VoucherAction.sell && voucher.type === VoucherType.value) {
1112
+ // Mehrzweck mit Code (vor dem Stichtag nur als Eintrag): wie die Position im 0-%-Topf.
1113
+ const mehrzweck = istMehrzweckMitCode(voucher);
1114
+ const code = gutscheinCodeZusatz(voucher);
960
1115
  lines.push({
961
1116
  kind: 'columns',
962
1117
  columns: [
963
- { text: `1 x ${gutscheinText(voucher)}`, width: 7, align: 'left' },
1118
+ { text: `1 x ${mehrzweck ? `Mehrzweckgutschein${code}` : gutscheinText(voucher)}`, width: 7, align: 'left' },
964
1119
  { text: `${formatCents(vz * wert)} ${VatRate.vat0.category}`, width: 5, align: 'right' },
965
1120
  ],
966
1121
  });
1122
+ if (mehrzweck)
1123
+ lines.push(textZeile(MEHRZWECK_UST_TEXT));
967
1124
  }
968
1125
  if (voucher.action === VoucherAction.redeem && voucher.type === VoucherType.promo) {
969
1126
  // Am Storno-Beleg steht der Promotionsgutschein nur als Bezug: den Rabatt
@@ -1022,12 +1179,22 @@ export function buildReceiptLayout(receipt, company, options = {}) {
1022
1179
  if (summeCents !== zwischensummeCents) {
1023
1180
  lines.push(paarZeile('Zwischensumme', `${formatCents(zwischensummeCents)} €`));
1024
1181
  for (const voucher of receipt.vouchers) {
1025
- if (voucher.action === VoucherAction.redeem && voucher.type === VoucherType.value) {
1026
- // Breite 8:4 — "Wertgutschein GS-1" (Label + Code) passt so auf 58 mm
1027
- // in die Zeile, statt den Code in eine Folgezeile fliessen zu lassen.
1028
- // Am Storno-Beleg geht der Wert auf den Gutschein zurueck: ohne Minus.
1029
- const wert = voucher.valueCents ?? 0;
1030
- lines.push(paarZeile(gutscheinText(voucher), wgVz === 1 ? `-${formatCents(wert)} €` : `${formatCents(wert)} €`, 8, 4));
1182
+ if (voucher.type !== VoucherType.value || voucher.zeroRateBucket === true)
1183
+ continue;
1184
+ if (voucher.action !== VoucherAction.redeem && voucher.action !== VoucherAction.payout)
1185
+ continue;
1186
+ // Breite 8:4 — "Wertgutschein GS-1" (Label + Code) passt so auf 58 mm
1187
+ // in die Zeile, statt den Code in eine Folgezeile fliessen zu lassen.
1188
+ // Am Storno-Beleg geht der Wert auf den Gutschein zurueck: ohne Minus.
1189
+ // Mehrzweck mit Code: „Gutschein ····-9DWT“ und darunter der Rest
1190
+ // (Zahlungszeile laut Spec); eine Auszahlung ohne `zeroRateBucket`
1191
+ // (schreibt der Server nicht) zaehlt wie in receiptSumCents.
1192
+ const wert = voucher.valueCents ?? 0;
1193
+ const endung = istMehrzweckMitCode(voucher) ? gutscheinEndung(voucher) : null;
1194
+ const text = endung != null ? `Gutschein ····-${endung}` : gutscheinText(voucher);
1195
+ lines.push(paarZeile(voucher.action === VoucherAction.payout ? `Auszahlung ${text}` : text, wgVz === 1 ? `-${formatCents(wert)} €` : `${formatCents(wert)} €`, 8, 4));
1196
+ if (endung != null && wgVz === 1 && Number.isInteger(voucher.balanceAfterCents)) {
1197
+ lines.push(paarZeile(' Rest am Gutschein', `${formatCents(voucher.balanceAfterCents)} €`, 8, 4));
1031
1198
  }
1032
1199
  }
1033
1200
  lines.push({ kind: 'rule', char: '-' });
@@ -199,6 +199,12 @@ export interface RegisterUserPerms {
199
199
  stocktakeClose?: boolean;
200
200
  /** Standort der Kasse waehlen (setMyCashregisterStockLocation). */
201
201
  stockLocation?: boolean;
202
+ /** Gutschein verkaufen (fehlt bei altem Server: gilt wie beim Kassier, siehe [voucherPermsOf]). */
203
+ voucherSell?: boolean;
204
+ /** Gutschein einloesen (fehlt bei altem Server: gilt wie beim Kassier). */
205
+ voucherRedeem?: boolean;
206
+ /** Gutscheine verwalten, z. B. Etikett nachdrucken (fehlt: verweigert). */
207
+ voucherManage?: boolean;
202
208
  [weiteresRecht: string]: boolean | RegisterScope | undefined;
203
209
  }
204
210
  /**
@@ -209,7 +215,7 @@ export interface RegisterUserPerms {
209
215
  * nicht fuehrt (Tippfehler, umbenanntes Recht), kommt hier nicht durch.
210
216
  * Die Gegenrichtung schliesst [_RechteVollstaendig] direkt darunter.
211
217
  */
212
- export declare const REGISTER_PERMS: readonly ["sell", "cancel", "articles", "layout", "reports", "takeover", "cancelScope", "receiptsScope", "drawer", "discount", "tipAssign", "stockView", "stockCosts", "stockMove", "stockLoss", "stocktakeCount", "stocktakeClose", "stockLocation"];
218
+ export declare const REGISTER_PERMS: readonly ["sell", "cancel", "articles", "layout", "reports", "takeover", "cancelScope", "receiptsScope", "drawer", "discount", "tipAssign", "stockView", "stockCosts", "stockMove", "stockLoss", "stocktakeCount", "stocktakeClose", "stockLocation", "voucherSell", "voucherRedeem", "voucherManage"];
213
219
  /** Reichweite lesen, mit der Migration des Backends (register-auth.js). */
214
220
  export declare function cancelScopeOf(perms: RegisterUserPerms | null | undefined): RegisterScope;
215
221
  export declare function receiptsScopeOf(perms: RegisterUserPerms | null | undefined): RegisterScope;
@@ -219,6 +225,20 @@ export declare function receiptsScopeOf(perms: RegisterUserPerms | null | undefi
219
225
  * sperrt; alle anderen Lager-Rechte gelten ohne Schluessel als verweigert.
220
226
  */
221
227
  export declare function stockViewOf(perms: RegisterUserPerms | null | undefined): boolean;
228
+ /** Die drei Gutschein-Rechte, aufgeloest (kein Schluessel fehlt mehr). */
229
+ export interface VoucherPerms {
230
+ voucherSell: boolean;
231
+ voucherRedeem: boolean;
232
+ voucherManage: boolean;
233
+ }
234
+ /**
235
+ * Gutschein-Rechte lesen. Das Backend loest sie bei der Anmeldung auf; ein
236
+ * alter Server (und `/api`-Antworten) kennt sie nicht. Dann gilt je fehlendem
237
+ * Schluessel die Vorgabe des Kassiers: verkaufen und einloesen ja, verwalten
238
+ * nein. Ein ausdrueckliches `false` sperrt; was kein Wahrheitswert ist,
239
+ * erteilt nichts.
240
+ */
241
+ export declare function voucherPermsOf(perms: Partial<RegisterUserPerms> | null | undefined): VoucherPerms;
222
242
  /** Der angemeldete Kassen-Benutzer. */
223
243
  export interface RegisterUser {
224
244
  id: string;