@design.estate/dees-document 3.1.2 → 3.3.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.
@@ -61,6 +61,14 @@ const accountingDocContents: Record<
61
61
  requestsPayment: true,
62
62
  printsRelatedDocuments: false,
63
63
  },
64
+ // a complete invoice in place of one that lacked a detail or stated one
65
+ // incorrectly (§ 31 Abs. 5 UStDV); it names that invoice in its own block,
66
+ // renderCorrection(), not among the related documents
67
+ "corrected-invoice": {
68
+ introStatement: "invoice@@introStatement",
69
+ requestsPayment: true,
70
+ printsRelatedDocuments: false,
71
+ },
64
72
  // reduces what the recipient owes: nothing to pay, and it names the document it corrects
65
73
  creditnote: {
66
74
  introStatement: "creditnote@@introStatement",
@@ -97,6 +105,160 @@ const relatedDocumentLabels: Record<
97
105
  references: "relatedDocument.references",
98
106
  };
99
107
 
108
+ /** A column that takes the width of its widest cell. */
109
+ interface IFittedColumn {
110
+ /** the cells of the column, the header included */
111
+ cellSelector: string;
112
+ /** the custom property the grid reads the column's width from */
113
+ widthProperty: string;
114
+ /** the width the column never goes below, in px */
115
+ minWidth: number;
116
+ }
117
+
118
+ /** The fitted columns of a table and the room the rest of a row takes. */
119
+ interface IColumnLayout {
120
+ columns: IFittedColumn[];
121
+ /** the columns of fixed width, together, in px */
122
+ fixedWidth: number;
123
+ /** the least room the flexible column (the description) keeps, in px */
124
+ flexibleMinWidth: number;
125
+ }
126
+
127
+ /**
128
+ * The positions: number 40 px and VAT rate 50 px fixed, the description
129
+ * flexible with at least 120 px (as the grid in the styles declares); the
130
+ * last column is the one the sums line up with.
131
+ */
132
+ const positionColumns: IColumnLayout = {
133
+ columns: [
134
+ { cellSelector: ".quantityCell", widthProperty: "--quantity-column-width", minWidth: 50 },
135
+ { cellSelector: ".unitCell", widthProperty: "--unit-column-width", minWidth: 50 },
136
+ { cellSelector: ".unitPriceCell", widthProperty: "--unit-price-column-width", minWidth: 100 },
137
+ { cellSelector: ".amountCell", widthProperty: "--amount-column-width", minWidth: 100 },
138
+ ],
139
+ fixedWidth: 90,
140
+ flexibleMinWidth: 120,
141
+ };
142
+
143
+ /**
144
+ * A reminder's claims, and its charges and sums, which share the last column;
145
+ * the document label is flexible with at least 150 px (as the grid in the
146
+ * styles declares).
147
+ */
148
+ const reminderColumns: IColumnLayout = {
149
+ columns: [
150
+ { cellSelector: ".issueDateCell", widthProperty: "--issue-date-column-width", minWidth: 80 },
151
+ { cellSelector: ".dueDateCell", widthProperty: "--due-date-column-width", minWidth: 80 },
152
+ { cellSelector: ".grossCell", widthProperty: "--gross-column-width", minWidth: 100 },
153
+ { cellSelector: ".amountCell", widthProperty: "--amount-column-width", minWidth: 100 },
154
+ ],
155
+ fixedWidth: 0,
156
+ flexibleMinWidth: 150,
157
+ };
158
+
159
+ /** The smallest font a cell is set in to fit its column, in px. */
160
+ const MIN_FITTED_FONT_SIZE = 8;
161
+
162
+ /**
163
+ * The width a cell's text needs, its padding included, measured on the text
164
+ * itself, so it is the same whether the text fits its column or overflows it.
165
+ */
166
+ const neededWidth = (cellArg: HTMLElement): number => {
167
+ const range = document.createRange();
168
+ range.selectNodeContents(cellArg);
169
+ const style = getComputedStyle(cellArg);
170
+ return Math.ceil(
171
+ range.getBoundingClientRect().width +
172
+ parseFloat(style.paddingLeft) +
173
+ parseFloat(style.paddingRight)
174
+ );
175
+ };
176
+
177
+ /**
178
+ * Widths for columns that want `wantedArg` and get at least `minimumsArg`,
179
+ * within `availableArg`: what is left above the minimums is granted in equal
180
+ * shares, a column never getting more than it wants.
181
+ */
182
+ const shareWidth = (
183
+ wantedArg: number[],
184
+ minimumsArg: number[],
185
+ availableArg: number
186
+ ): number[] => {
187
+ const widths = [...minimumsArg];
188
+ let remaining =
189
+ availableArg - minimumsArg.reduce((sumArg, widthArg) => sumArg + widthArg, 0);
190
+ let open = widths.map((_, index) => index).filter((index) => wantedArg[index] > widths[index]);
191
+ while (open.length > 0 && remaining >= 1) {
192
+ const share = remaining / open.length;
193
+ for (const index of open) {
194
+ const grant = Math.min(share, wantedArg[index] - widths[index]);
195
+ widths[index] += grant;
196
+ remaining -= grant;
197
+ }
198
+ open = open.filter((index) => wantedArg[index] - widths[index] >= 1);
199
+ }
200
+ return widths.map((widthArg) => Math.floor(widthArg));
201
+ };
202
+
203
+ /**
204
+ * Sets a cell whose text is wider than its column in a smaller font, down to
205
+ * `MIN_FITTED_FONT_SIZE`, and lets it wrap within the column when even that
206
+ * is too wide.
207
+ */
208
+ const fitCellText = (cellArg: HTMLElement, columnWidthArg: number): void => {
209
+ const needed = neededWidth(cellArg);
210
+ if (needed <= columnWidthArg) {
211
+ return;
212
+ }
213
+ const style = getComputedStyle(cellArg);
214
+ const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
215
+ const fontSize =
216
+ (parseFloat(style.fontSize) * (columnWidthArg - padding)) / (needed - padding);
217
+ if (fontSize >= MIN_FITTED_FONT_SIZE) {
218
+ cellArg.style.fontSize = `${Math.floor(fontSize * 10) / 10}px`;
219
+ return;
220
+ }
221
+ cellArg.style.fontSize = `${MIN_FITTED_FONT_SIZE}px`;
222
+ cellArg.style.whiteSpace = "normal";
223
+ cellArg.style.overflowWrap = "anywhere";
224
+ };
225
+
226
+ /** The kind of a reminded document, as the claims table names it. */
227
+ const claimDocumentLabels: Record<
228
+ plugins.tsclass.finance.TPaymentReminderClaim["documentType"],
229
+ plugins.shared.translation.TranslationKey
230
+ > = {
231
+ invoice: "paymentReminder@@claim.invoice",
232
+ "corrected-invoice": "paymentReminder@@claim.correctedInvoice",
233
+ debitnote: "paymentReminder@@claim.debitnote",
234
+ };
235
+
236
+ /** The words for a detail of § 14 Abs. 4 Satz 1 UStG a corrected invoice supplies or corrects. */
237
+ const correctionDetailLabels: Record<
238
+ plugins.tsclass.finance.TInvoiceMandatoryDetail,
239
+ plugins.shared.translation.TranslationKey
240
+ > = {
241
+ parties: "correctedInvoice.detail.parties",
242
+ "supplier-tax-number": "correctedInvoice.detail.supplierTaxNumber",
243
+ "issue-date": "correctedInvoice.detail.issueDate",
244
+ "invoice-number": "correctedInvoice.detail.invoiceNumber",
245
+ "supply-description": "correctedInvoice.detail.supplyDescription",
246
+ "supply-date": "correctedInvoice.detail.supplyDate",
247
+ "net-amount": "correctedInvoice.detail.netAmount",
248
+ tax: "correctedInvoice.detail.tax",
249
+ "retention-note": "correctedInvoice.detail.retentionNote",
250
+ "special-case-detail": "correctedInvoice.detail.specialCaseDetail",
251
+ };
252
+
253
+ /** What was done with a detail: supplied when it was missing, corrected when it was incorrect. */
254
+ const correctionDefectLabels: Record<
255
+ plugins.tsclass.finance.TInvoiceCorrection["defect"],
256
+ plugins.shared.translation.TranslationKey
257
+ > = {
258
+ missing: "correctedInvoice.defect.missing",
259
+ incorrect: "correctedInvoice.defect.incorrect",
260
+ };
261
+
100
262
  @customElement("dedocument-contentinvoice")
101
263
  export class DeContentInvoice extends DeesElement {
102
264
  public static demo = () => html`
@@ -135,6 +297,18 @@ export class DeContentInvoice extends DeesElement {
135
297
  : null;
136
298
  }
137
299
 
300
+ /** The payment reminder this content prints, or null. */
301
+ private get paymentReminder(): plugins.tsclass.finance.TPaymentReminder | null {
302
+ return plugins.shared.isPaymentReminder(this.letterData)
303
+ ? this.letterData
304
+ : null;
305
+ }
306
+
307
+ /** The currency of the letter's amounts: an accounting document's or a reminder's. */
308
+ private get currency(): string | undefined {
309
+ return this.accountingDoc?.currency ?? this.paymentReminder?.currency;
310
+ }
311
+
138
312
  /**
139
313
  * The document builds this element for every letter; one that is no
140
314
  * accounting document, and one of a type this version does not know, keep
@@ -174,7 +348,7 @@ export class DeContentInvoice extends DeesElement {
174
348
 
175
349
  .grid {
176
350
  display: grid;
177
- grid-template-columns: 40px auto 50px 50px 100px 50px 100px;
351
+ grid-template-columns: 40px minmax(120px, 1fr) var(--quantity-column-width, 50px) var(--unit-column-width, 50px) var(--unit-price-column-width, 100px) 50px var(--amount-column-width, 100px);
178
352
  }
179
353
 
180
354
  .topLine {
@@ -194,6 +368,16 @@ export class DeContentInvoice extends DeesElement {
194
368
  border-right: none;
195
369
  }
196
370
 
371
+ /*
372
+ * the description takes the room the other columns leave, at least its
373
+ * minimum, and wraps within it, so a long one never widens the table
374
+ * beyond the page
375
+ */
376
+ .lineItem.descriptionCell {
377
+ white-space: normal;
378
+ overflow-wrap: anywhere;
379
+ }
380
+
197
381
  .value.rightAlign,
198
382
  .lineItem.rightAlign {
199
383
  text-align: right;
@@ -211,6 +395,36 @@ export class DeContentInvoice extends DeesElement {
211
395
  box-sizing: content-box;
212
396
  }
213
397
 
398
+ .claimGrid {
399
+ display: grid;
400
+ grid-template-columns: minmax(150px, 1fr) var(--issue-date-column-width, 80px) var(--due-date-column-width, 80px) var(--gross-column-width, 100px) var(--amount-column-width, 100px);
401
+ }
402
+
403
+ .chargeGrid {
404
+ display: grid;
405
+ grid-template-columns: auto var(--amount-column-width, 100px);
406
+ }
407
+
408
+ .chargeGrid.dataHeader {
409
+ margin-top: 12px;
410
+ }
411
+
412
+ /* a charge's description and basis wrap: the basis of default interest is longer than a line */
413
+ .lineItem.chargeText {
414
+ white-space: normal;
415
+ }
416
+
417
+ .chargeBasis {
418
+ margin-top: 2px;
419
+ font-size: 11px;
420
+ color: #555;
421
+ }
422
+
423
+ /* a provision is not split across lines */
424
+ .citation {
425
+ white-space: nowrap;
426
+ }
427
+
214
428
  .sums {
215
429
  margin-top: 5px;
216
430
  font-size: 12px;
@@ -220,7 +434,7 @@ export class DeContentInvoice extends DeesElement {
220
434
  .sums .sumline {
221
435
  margin-top: 3px;
222
436
  display: grid;
223
- grid-template-columns: auto 100px;
437
+ grid-template-columns: auto var(--amount-column-width, 100px);
224
438
  }
225
439
 
226
440
  .sums .sumline .label {
@@ -264,6 +478,19 @@ export class DeContentInvoice extends DeesElement {
264
478
  margin-top: 4px;
265
479
  }
266
480
 
481
+ .correction {
482
+ margin-bottom: 12px;
483
+ }
484
+
485
+ .correctionTitle {
486
+ font-weight: bold;
487
+ }
488
+
489
+ .correctionDetails ul {
490
+ margin: 2px 0 0 0;
491
+ padding-left: 18px;
492
+ }
493
+
267
494
  .relatedDocuments .label {
268
495
  font-weight: bold;
269
496
  }
@@ -294,7 +521,7 @@ export class DeContentInvoice extends DeesElement {
294
521
  * an unknown or missing currency falls back to a plain decimal number.
295
522
  */
296
523
  protected formatPrice(value: number): string {
297
- const currency = this.accountingDoc?.currency;
524
+ const currency = this.currency;
298
525
  const locale = localeForLanguage(this.documentSettings?.languageCode);
299
526
  if (currency) {
300
527
  try {
@@ -306,6 +533,39 @@ export class DeContentInvoice extends DeesElement {
306
533
  return new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
307
534
  }
308
535
 
536
+ /**
537
+ * Formats a quantity in the document's language with every decimal it
538
+ * carries: 12.5 prints as "12,5" in German and "12.5" in English, and no
539
+ * stated quantity is rounded. The decimals are those of the shortest
540
+ * representation of the number, the one `String()` gives, as the unit name
541
+ * counts them for its singular or plural.
542
+ */
543
+ protected formatQuantity(quantityArg: number): string {
544
+ const fractionDigits = plugins.shared.quantityFractionDigits(quantityArg);
545
+ return new Intl.NumberFormat(
546
+ localeForLanguage(this.documentSettings?.languageCode),
547
+ {
548
+ minimumFractionDigits: fractionDigits,
549
+ maximumFractionDigits: fractionDigits,
550
+ }
551
+ ).format(quantityArg);
552
+ }
553
+
554
+ /** Formats a rate in percent, with as many decimals as it states, up to four. */
555
+ protected formatPercent(percentArg: number): string {
556
+ return new Intl.NumberFormat(
557
+ localeForLanguage(this.documentSettings?.languageCode),
558
+ { style: "percent", maximumFractionDigits: 4 }
559
+ ).format(percentArg / 100);
560
+ }
561
+
562
+ /** Formats a day in the document's language and date style. */
563
+ protected formatDay(timestampArg: number): string {
564
+ return new Intl.DateTimeFormat(this.documentSettings.languageCode, {
565
+ dateStyle: this.documentSettings.dateStyle,
566
+ }).format(new Date(timestampArg));
567
+ }
568
+
309
569
  public getTotalNet = (): number => {
310
570
  let totalNet = 0;
311
571
 
@@ -485,24 +745,25 @@ export class DeContentInvoice extends DeesElement {
485
745
  }
486
746
  }
487
747
 
488
- /** The due date, which only an accounting document has: it counts from its date. */
489
- private renderPaymentTerms(
490
- accountingDocArg: plugins.tsclass.finance.TAccountingDoc
491
- ): TemplateResult {
748
+ /**
749
+ * The due date, which only an accounting document and a payment reminder
750
+ * have: it counts `dueInDays` from the letter's date.
751
+ */
752
+ private renderPaymentTerms(optionsArg: {
753
+ date: number;
754
+ dueInDays: number;
755
+ labelKey: plugins.shared.translation.TranslationKey;
756
+ textKey: plugins.shared.translation.TranslationKey;
757
+ }): TemplateResult {
492
758
  return html`<div class="infoBox">
493
759
  <div>
494
760
  <div>
495
- <div class="label">
496
- ${this.translateKey("invoice@@payment.terms")}
497
- </div>
761
+ <div class="label">${this.translateKey(optionsArg.labelKey)}</div>
498
762
  <span>
499
- ${this.translateKey("invoice@@payment.terms.direct")}
500
- ${new Intl.DateTimeFormat(this.documentSettings.languageCode, {
501
- dateStyle: this.documentSettings.dateStyle,
502
- }).format(
503
- new Date(accountingDocArg.date).setDate(
504
- new Date(accountingDocArg.date).getDate() +
505
- accountingDocArg.dueInDays
763
+ ${this.translateKey(optionsArg.textKey)}
764
+ ${this.formatDay(
765
+ new Date(optionsArg.date).setDate(
766
+ new Date(optionsArg.date).getDate() + optionsArg.dueInDays
506
767
  )
507
768
  )}
508
769
  </span>
@@ -511,15 +772,39 @@ export class DeContentInvoice extends DeesElement {
511
772
  </div>`;
512
773
  }
513
774
 
514
- private renderPaymentInfo(
515
- accountingDocArg: plugins.tsclass.finance.TAccountingDoc
516
- ): TemplateResult {
517
- const bic = accountingDocArg.from.sepaConnection?.bic;
518
- const name = accountingDocArg.from.name;
519
- const iban = accountingDocArg.from.sepaConnection?.iban;
520
- const currency = accountingDocArg.currency;
521
- const totalGross = this.getTotalGross();
522
- const reference = accountingDocArg.id;
775
+ /**
776
+ * The QR pay box. It is printed only when the payment has a valid EPC QR
777
+ * code: a euro amount to the sender's IBAN, which a document in another
778
+ * currency or without the sender's IBAN does not have.
779
+ */
780
+ private renderPaymentInfo(optionsArg: {
781
+ /** the sender, who is paid */
782
+ from: plugins.tsclass.business.TContact;
783
+ currency: string;
784
+ /** the amount to pay */
785
+ amount: number;
786
+ /** the number of the letter, the remittance information */
787
+ reference: string;
788
+ }): TemplateResult | null {
789
+ const bic = optionsArg.from.sepaConnection?.bic;
790
+ const name = optionsArg.from.name;
791
+ const iban = optionsArg.from.sepaConnection?.iban;
792
+ const currency = optionsArg.currency;
793
+ const totalGross = optionsArg.amount;
794
+ const reference = optionsArg.reference;
795
+ if (
796
+ typeof iban !== "string" ||
797
+ plugins.shared.buildEpcQrPayload({
798
+ bic,
799
+ name,
800
+ iban,
801
+ currency,
802
+ amount: totalGross,
803
+ remittanceInformation: reference,
804
+ }) === null
805
+ ) {
806
+ return null;
807
+ }
523
808
 
524
809
  return html`<div class="infoBox">
525
810
  <div>
@@ -539,6 +824,66 @@ export class DeContentInvoice extends DeesElement {
539
824
  </div>`;
540
825
  }
541
826
 
827
+ /**
828
+ * The reverse-charge statement. § 14a UStG requires the words
829
+ * "Steuerschuldnerschaft des Leistungsempfängers": Abs. 5 for a domestic
830
+ * supply whose recipient owes the tax under § 13b, Abs. 1 for a supply in
831
+ * another member state whose recipient owes the tax there, such as a
832
+ * business-to-business service under Art. 196 of the VAT Directive. They are
833
+ * printed on every reverse-charge document, and a document in another language adds its own
834
+ * line, in English "Reverse charge", the words of Art. 226 Nr. 11a of the VAT
835
+ * Directive (2006/112/EC).
836
+ */
837
+ private renderReverseChargeNote(): TemplateResult {
838
+ const localizedLine = this.translateKey("invoice@@vat.reverseCharge.note");
839
+ return html`<div class="taxNote">
840
+ <div>${plugins.shared.translation.REVERSE_CHARGE_STATUTORY_PHRASE_DE}</div>
841
+ ${localizedLine === plugins.shared.translation.REVERSE_CHARGE_STATUTORY_PHRASE_DE
842
+ ? null
843
+ : html`<div>${localizedLine}</div>`}
844
+ </div>`;
845
+ }
846
+
847
+ /**
848
+ * What makes a corrected invoice a correction (§ 31 Abs. 5 UStDV): its
849
+ * title, the invoice it corrects by number and issue date when the document
850
+ * states them ("spezifisch und eindeutig", Satz 2), and the details it
851
+ * supplies or corrects, in words. A received corrected invoice may name
852
+ * neither; then only the title is printed, and no reference is made up.
853
+ */
854
+ private renderCorrection(): TemplateResult | null {
855
+ const accountingDoc = this.accountingDoc;
856
+ if (accountingDoc?.accountingDocType !== "corrected-invoice") {
857
+ return null;
858
+ }
859
+ const reference = accountingDoc.correctedInvoice;
860
+ const corrections = accountingDoc.corrections ?? [];
861
+ return html`<div class="correction">
862
+ <div class="correctionTitle">
863
+ ${this.translateKey("correctedInvoice.title")}${reference
864
+ ? html` ${this.translateKey("correctedInvoice.reference")}
865
+ ${reference.documentId}${reference.issueDate !== undefined
866
+ ? html` ${this.translateKey("correctedInvoice.referenceDate")}
867
+ ${this.formatDay(reference.issueDate)}`
868
+ : null}`
869
+ : null}
870
+ </div>
871
+ ${corrections.length > 0
872
+ ? html`<div class="correctionDetails">
873
+ <div>${this.translateKey("correctedInvoice.corrections")}:</div>
874
+ <ul>
875
+ ${corrections.map(
876
+ (correctionArg) => html`<li>
877
+ ${this.translateKey(correctionDetailLabels[correctionArg.detail])}:
878
+ ${this.translateKey(correctionDefectLabels[correctionArg.defect])}
879
+ </li>`
880
+ )}
881
+ </ul>
882
+ </div>`
883
+ : null}
884
+ </div>`;
885
+ }
886
+
542
887
  /** The earlier documents a correction answers, by number and, when known, by date. */
543
888
  private renderRelatedDocuments(): TemplateResult | null {
544
889
  const relatedDocuments = this.accountingDoc?.relatedDocuments;
@@ -572,8 +917,8 @@ export class DeContentInvoice extends DeesElement {
572
917
  * pages and cannot split one, so a note is handed over token by token: a
573
918
  * paragraph in one piece could be taller than a page and would never fit.
574
919
  */
575
- private renderNotes(): TemplateResult | null {
576
- const notes = (this.accountingDoc?.notes ?? [])
920
+ private renderNotes(notesArg: string[] | undefined): TemplateResult | null {
921
+ const notes = (notesArg ?? [])
577
922
  .filter((noteArg): noteArg is string => typeof noteArg === "string")
578
923
  .map((noteArg) => noteArg.replace(/\r\n?/g, "\n").trim())
579
924
  .filter((noteArg) => noteArg.length > 0);
@@ -588,6 +933,240 @@ export class DeContentInvoice extends DeesElement {
588
933
  )}`;
589
934
  }
590
935
 
936
+ /**
937
+ * The document a reminder claims, as the claims table and the charges name
938
+ * it: its kind and its number.
939
+ */
940
+ private claimLabel(
941
+ reminderArg: plugins.tsclass.finance.TPaymentReminder,
942
+ documentIdArg: string
943
+ ): string {
944
+ const claim = reminderArg.claims.find(
945
+ (claimArg) => claimArg.documentId === documentIdArg
946
+ );
947
+ if (!claim) {
948
+ return documentIdArg;
949
+ }
950
+ // a kind this version does not know is named as an invoice, as the letterhead and the content do
951
+ const label =
952
+ claimDocumentLabels[claim.documentType] ?? claimDocumentLabels.invoice;
953
+ return `${this.translateKey(label)} ${documentIdArg}`;
954
+ }
955
+
956
+ /**
957
+ * The ground of the claim: every reminded document with its number, its
958
+ * date, the date it fell due when it stated one, its gross total and what is
959
+ * still open on it.
960
+ */
961
+ private renderReminderClaims(
962
+ reminderArg: plugins.tsclass.finance.TPaymentReminder
963
+ ): TemplateResult {
964
+ return html`<div class="claimGrid topLine dataHeader">
965
+ <div class="lineItem descriptionCell">
966
+ ${this.translateKey("paymentReminder@@claim.document")}
967
+ </div>
968
+ <div class="lineItem rightAlign issueDateCell">
969
+ ${this.translateKey("paymentReminder@@claim.issueDate")}
970
+ </div>
971
+ <div class="lineItem rightAlign dueDateCell">
972
+ ${this.translateKey("paymentReminder@@claim.dueDate")}
973
+ </div>
974
+ <div class="lineItem rightAlign grossCell">
975
+ ${this.translateKey("paymentReminder@@claim.totalGross")}
976
+ </div>
977
+ <div class="lineItem rightAlign amountCell">
978
+ ${this.translateKey("paymentReminder@@claim.outstanding")}
979
+ </div>
980
+ </div>
981
+ ${reminderArg.claims.map(
982
+ (claimArg) => html`<div class="claimGrid needsDataHeader">
983
+ <div class="lineItem descriptionCell">
984
+ ${this.claimLabel(reminderArg, claimArg.documentId)}
985
+ </div>
986
+ <div class="lineItem rightAlign issueDateCell">
987
+ ${this.formatDay(claimArg.issueDate)}
988
+ </div>
989
+ <div class="lineItem rightAlign dueDateCell">
990
+ ${claimArg.dueDate !== undefined
991
+ ? this.formatDay(claimArg.dueDate)
992
+ : "–"}
993
+ </div>
994
+ <div class="lineItem rightAlign grossCell">
995
+ ${this.formatPrice(claimArg.totalGross)}
996
+ </div>
997
+ <div class="lineItem rightAlign amountCell">
998
+ ${this.formatPrice(claimArg.outstandingAmount)}
999
+ </div>
1000
+ </div>`
1001
+ )}`;
1002
+ }
1003
+
1004
+ /**
1005
+ * What a charge rests on, as the reminder states it: default interest its
1006
+ * computation (principal, rate a year with the base rate and the surcharge
1007
+ * points of § 288 Abs. 1 or 2 BGB, first and last day), the default lump sum
1008
+ * its provision, and every charge the claim it accrues on. A cost with lump
1009
+ * sums credited against it lists them (§ 288 Abs. 5 Satz 3 BGB); its amount
1010
+ * is already the cost less them.
1011
+ */
1012
+ private renderChargeBasis(
1013
+ reminderArg: plugins.tsclass.finance.TPaymentReminder,
1014
+ chargeArg: plugins.tsclass.finance.TPaymentReminderCharge
1015
+ ): TemplateResult | null {
1016
+ switch (chargeArg.chargeType) {
1017
+ case "default-interest": {
1018
+ const interest = chargeArg.interest;
1019
+ return html`<div class="chargeBasis">
1020
+ ${this.claimLabel(reminderArg, chargeArg.claimDocumentId)}:
1021
+ ${this.formatPrice(interest.principal)} ×
1022
+ ${this.formatPercent(interest.ratePercent)}
1023
+ ${this.translateKey("paymentReminder@@interest.perYear")}
1024
+ (${this.translateKey("paymentReminder@@interest.baseRate")}
1025
+ ${this.formatPercent(interest.baseRatePercent)}
1026
+ ${this.translateKey("paymentReminder@@interest.plus")}
1027
+ ${interest.surchargePoints}
1028
+ ${this.translateKey("paymentReminder@@interest.points")},
1029
+ <span class="citation"
1030
+ >${plugins.shared.defaultInterestCitations[interest.legalBasis]}</span
1031
+ >),
1032
+ ${this.formatDay(interest.from)} – ${this.formatDay(interest.to)}
1033
+ </div>`;
1034
+ }
1035
+ case "default-lump-sum":
1036
+ return html`<div class="chargeBasis">
1037
+ ${this.claimLabel(reminderArg, chargeArg.claimDocumentId)},
1038
+ <span class="citation"
1039
+ >${plugins.shared.DEFAULT_LUMP_SUM_CITATION}</span
1040
+ >
1041
+ </div>`;
1042
+ case "reminder-fee":
1043
+ case "other-cost": {
1044
+ const offsets = chargeArg.lumpSumOffsets ?? [];
1045
+ if (chargeArg.claimDocumentId === undefined && offsets.length === 0) {
1046
+ return null;
1047
+ }
1048
+ return html`${chargeArg.claimDocumentId !== undefined
1049
+ ? html`<div class="chargeBasis">
1050
+ ${this.claimLabel(reminderArg, chargeArg.claimDocumentId)}
1051
+ </div>`
1052
+ : null}
1053
+ ${offsets.map(
1054
+ (offsetArg) => html`<div class="chargeBasis">
1055
+ ${this.translateKey("paymentReminder@@charge.lumpSumOffset")}
1056
+ ${this.claimLabel(reminderArg, offsetArg.claimDocumentId)}:
1057
+ ${this.formatPrice(offsetArg.amount)}
1058
+ (<span class="citation"
1059
+ >${plugins.shared.LUMP_SUM_OFFSET_CITATION}</span
1060
+ >)
1061
+ </div>`
1062
+ )}`;
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ /** One row per charge: its description, what it rests on, and its amount. */
1068
+ private renderReminderCharges(
1069
+ reminderArg: plugins.tsclass.finance.TPaymentReminder
1070
+ ): TemplateResult | null {
1071
+ if (reminderArg.charges.length === 0) {
1072
+ return null;
1073
+ }
1074
+ return html`<div class="chargeGrid topLine dataHeader">
1075
+ <div class="lineItem">
1076
+ ${this.translateKey("paymentReminder@@charge.description")}
1077
+ </div>
1078
+ <div class="lineItem rightAlign amountCell">
1079
+ ${this.translateKey("paymentReminder@@charge.amount")}
1080
+ </div>
1081
+ </div>
1082
+ ${reminderArg.charges.map(
1083
+ (chargeArg) => html`<div class="chargeGrid needsDataHeader">
1084
+ <div class="lineItem chargeText">
1085
+ <div>${chargeArg.description}</div>
1086
+ ${this.renderChargeBasis(reminderArg, chargeArg)}
1087
+ </div>
1088
+ <div class="lineItem rightAlign amountCell">
1089
+ ${this.formatPrice(chargeArg.amount)}
1090
+ </div>
1091
+ </div>`
1092
+ )}`;
1093
+ }
1094
+
1095
+ /** The sums of a reminder: the open claims, the charges and the total payable, without VAT. */
1096
+ private renderReminderSums(
1097
+ totalsArg: plugins.shared.IPaymentReminderTotals,
1098
+ hasChargesArg: boolean
1099
+ ): TemplateResult {
1100
+ return html`<div class="sums">
1101
+ <div class="sumline">
1102
+ <div class="label">
1103
+ ${this.translateKey("paymentReminder@@paymentReminder.sum.claims")}
1104
+ </div>
1105
+ <div class="value rightAlign amountCell">
1106
+ ${this.formatPrice(totalsArg.claimsOutstanding)}
1107
+ </div>
1108
+ </div>
1109
+ ${hasChargesArg
1110
+ ? html`<div class="sumline">
1111
+ <div class="label">
1112
+ ${this.translateKey("paymentReminder@@paymentReminder.sum.charges")}
1113
+ </div>
1114
+ <div class="value rightAlign amountCell">
1115
+ ${this.formatPrice(totalsArg.charges)}
1116
+ </div>
1117
+ </div>`
1118
+ : null}
1119
+ <div class="sumline">
1120
+ <div class="label">
1121
+ ${this.translateKey("paymentReminder@@paymentReminder.sum.payable")}
1122
+ </div>
1123
+ <div class="value value--total rightAlign amountCell">
1124
+ ${this.formatPrice(totalsArg.payable)}
1125
+ </div>
1126
+ </div>
1127
+ </div>`;
1128
+ }
1129
+
1130
+ /**
1131
+ * A payment reminder: the issuer's intro or the standard one, the reminded
1132
+ * documents, the charges, the sums, the notes, the new payment deadline and
1133
+ * the QR pay box for the total payable with the reminder's number.
1134
+ */
1135
+ private renderPaymentReminder(
1136
+ reminderArg: plugins.tsclass.finance.TPaymentReminder
1137
+ ): TemplateResult {
1138
+ const totals = plugins.shared.getPaymentReminderTotals(reminderArg);
1139
+ return html`
1140
+ <div>
1141
+ ${reminderArg.topText ??
1142
+ this.translateKey("paymentReminder@@introStatement")}
1143
+ </div>
1144
+ ${this.renderReminderClaims(reminderArg)}
1145
+ ${this.renderReminderCharges(reminderArg)}
1146
+ ${this.renderReminderSums(totals, reminderArg.charges.length > 0)}
1147
+ <div class="divider"></div>
1148
+
1149
+ <!-- NOTES -->
1150
+ ${this.renderNotes(reminderArg.notes)}
1151
+
1152
+ <!-- PAYMENT DEADLINE -->
1153
+ ${this.renderPaymentTerms({
1154
+ date: reminderArg.date,
1155
+ dueInDays: reminderArg.dueInDays,
1156
+ labelKey: "paymentReminder@@paymentReminder.deadline",
1157
+ textKey: "paymentReminder@@paymentReminder.deadline.text",
1158
+ })}
1159
+
1160
+ <!-- PAYMENT INFO -->
1161
+ ${this.renderPaymentInfo({
1162
+ from: reminderArg.from,
1163
+ currency: reminderArg.currency,
1164
+ amount: totals.payable,
1165
+ reference: reminderArg.id,
1166
+ })}
1167
+ `;
1168
+ }
1169
+
591
1170
  private renderReferencedContract(): TemplateResult | null {
592
1171
  return null;
593
1172
  // return this.documentSettings.enableInvoiceContractRefSection &&
@@ -610,29 +1189,42 @@ export class DeContentInvoice extends DeesElement {
610
1189
 
611
1190
  public async attachInvoiceDom() {
612
1191
  const contentNodes = await this.getContentNodes();
1192
+ const paymentReminder = this.paymentReminder;
1193
+ if (paymentReminder) {
1194
+ render(this.renderPaymentReminder(paymentReminder), contentNodes.currentContent);
1195
+ this.fitColumns(
1196
+ contentNodes.currentContent,
1197
+ ".claimGrid.dataHeader",
1198
+ reminderColumns
1199
+ );
1200
+ return;
1201
+ }
613
1202
  const accountingDoc = this.accountingDoc;
614
1203
  render(
615
1204
  html`
1205
+ ${this.renderCorrection()}
616
1206
  <div>${this.translateKey(this.docContent.introStatement)}</div>
617
1207
  ${this.renderRelatedDocuments()}
618
1208
  <div class="grid topLine dataHeader">
619
1209
  <div class="lineItem rightAlign">
620
1210
  ${this.translateKey("invoice@@item.position")}
621
1211
  </div>
622
- <div class="lineItem">
1212
+ <div class="lineItem descriptionCell">
623
1213
  ${this.translateKey("invoice@@description")}
624
1214
  </div>
625
- <div class="lineItem rightAlign">
1215
+ <div class="lineItem rightAlign quantityCell">
626
1216
  ${this.translateKey("invoice@@quantity")}
627
1217
  </div>
628
- <div class="lineItem">${this.translateKey("invoice@@unit.type")}</div>
629
- <div class="lineItem rightAlign">
1218
+ <div class="lineItem unitCell">
1219
+ ${this.translateKey("invoice@@unit.type")}
1220
+ </div>
1221
+ <div class="lineItem rightAlign unitPriceCell">
630
1222
  ${this.translateKey("invoice@@price.unit.net")}
631
1223
  </div>
632
1224
  <div class="lineItem rightAlign">
633
1225
  ${this.translateKey("invoice@@vat.short")}
634
1226
  </div>
635
- <div class="lineItem rightAlign">
1227
+ <div class="lineItem rightAlign amountCell">
636
1228
  ${this.translateKey("invoice@@price.total.net")}
637
1229
  </div>
638
1230
  </div>
@@ -640,16 +1232,24 @@ export class DeContentInvoice extends DeesElement {
640
1232
  (invoiceItem, index) => html`
641
1233
  <div class="grid needsDataHeader">
642
1234
  <div class="lineItem rightAlign">${index + 1}</div>
643
- <div class="lineItem">${invoiceItem.name}</div>
644
- <div class="lineItem rightAlign">${invoiceItem.unitQuantity}</div>
645
- <div class="lineItem">${invoiceItem.unitType}</div>
646
- <div class="lineItem rightAlign">
1235
+ <div class="lineItem descriptionCell">${invoiceItem.name}</div>
1236
+ <div class="lineItem rightAlign quantityCell">
1237
+ ${this.formatQuantity(invoiceItem.unitQuantity)}
1238
+ </div>
1239
+ <div class="lineItem unitCell">
1240
+ ${plugins.shared.unitName(
1241
+ this.documentSettings.languageCode,
1242
+ invoiceItem.unitType,
1243
+ invoiceItem.unitQuantity
1244
+ )}
1245
+ </div>
1246
+ <div class="lineItem rightAlign unitPriceCell">
647
1247
  ${this.formatPrice(invoiceItem.unitNetPrice)}
648
1248
  </div>
649
1249
  <div class="lineItem rightAlign">
650
1250
  ${invoiceItem.vatPercentage}%
651
1251
  </div>
652
- <div class="lineItem rightAlign">
1252
+ <div class="lineItem rightAlign amountCell">
653
1253
  ${this.formatPrice(
654
1254
  invoiceItem.unitQuantity * invoiceItem.unitNetPrice
655
1255
  )}
@@ -662,7 +1262,7 @@ export class DeContentInvoice extends DeesElement {
662
1262
  <div class="label">
663
1263
  ${this.translateKey("invoice@@sum.total.net")}
664
1264
  </div>
665
- <div class="value value--total rightAlign">
1265
+ <div class="value value--total rightAlign amountCell">
666
1266
  ${this.formatPrice(this.getTotalNet())}
667
1267
  </div>
668
1268
  </div>
@@ -684,7 +1284,7 @@ export class DeContentInvoice extends DeesElement {
684
1284
  `
685
1285
  : html``}
686
1286
  </div>
687
- <div class="value rightAlign">
1287
+ <div class="value rightAlign amountCell">
688
1288
  ${this.formatPrice(vatGroupArg.vatAmountSum)}
689
1289
  </div>
690
1290
  </div>
@@ -692,36 +1292,100 @@ export class DeContentInvoice extends DeesElement {
692
1292
  })}
693
1293
  <div class="sumline">
694
1294
  <div class="label">${this.translateKey("invoice@@totalGross")}</div>
695
- <div class="value value--total rightAlign">
1295
+ <div class="value value--total rightAlign amountCell">
696
1296
  ${this.formatPrice(this.getTotalGross())}
697
1297
  </div>
698
1298
  </div>
699
1299
  </div>
700
1300
  <div class="divider"></div>
701
1301
 
702
- ${accountingDoc?.reverseCharge
703
- ? html`<div class="taxNote">
704
- ${this.translateKey("invoice@@vat.reverseCharge.note")}
705
- </div>`
706
- : ``}
1302
+ ${accountingDoc?.reverseCharge ? this.renderReverseChargeNote() : ``}
707
1303
 
708
1304
  <!-- REFERENCED CONTRACT -->
709
1305
  ${this.renderReferencedContract()}
710
1306
 
711
1307
  <!-- NOTES -->
712
- ${this.renderNotes()}
1308
+ ${this.renderNotes(accountingDoc?.notes)}
713
1309
 
714
1310
  <!-- PAYMENT TERMS -->
715
1311
  ${accountingDoc && this.docContent.requestsPayment
716
- ? this.renderPaymentTerms(accountingDoc)
1312
+ ? this.renderPaymentTerms({
1313
+ date: accountingDoc.date,
1314
+ dueInDays: accountingDoc.dueInDays,
1315
+ labelKey: "invoice@@payment.terms",
1316
+ textKey: "invoice@@payment.terms.direct",
1317
+ })
717
1318
  : null}
718
1319
 
719
1320
  <!-- PAYMENT INFO -->
720
1321
  ${accountingDoc && this.docContent.requestsPayment
721
- ? this.renderPaymentInfo(accountingDoc)
1322
+ ? this.renderPaymentInfo({
1323
+ from: accountingDoc.from,
1324
+ currency: accountingDoc.currency,
1325
+ amount: this.getTotalGross(),
1326
+ reference: accountingDoc.id,
1327
+ })
722
1328
  : null}
723
1329
  `,
724
1330
  contentNodes.currentContent
725
1331
  );
1332
+ this.fitColumns(
1333
+ contentNodes.currentContent,
1334
+ ".grid.dataHeader",
1335
+ positionColumns
1336
+ );
1337
+ }
1338
+
1339
+ /**
1340
+ * Sizes the columns of a table to their widest cell. Every row is a grid of
1341
+ * its own, so the rows only line up when a column has one width for all of
1342
+ * them; each page renders every row before it is trimmed, so every page
1343
+ * measures the same widths.
1344
+ *
1345
+ * A column is never narrower than its minimum. The widths the columns ask
1346
+ * for beyond their minimums are granted out of the row's width less its
1347
+ * fixed columns and the minimum of its flexible column (the description),
1348
+ * in equal shares, so the table always fits the page. A cell whose text is
1349
+ * still wider than its column, such as an amount beyond hundreds of
1350
+ * billions, is set in a smaller font, down to 8 px, and wraps inside its
1351
+ * column once that is not enough, so it never runs into its neighbour.
1352
+ */
1353
+ private fitColumns(
1354
+ containerArg: HTMLElement,
1355
+ headerRowSelectorArg: string,
1356
+ layoutArg: IColumnLayout
1357
+ ): void {
1358
+ const cellsOf = (columnArg: IFittedColumn): HTMLElement[] =>
1359
+ Array.from(containerArg.querySelectorAll<HTMLElement>(columnArg.cellSelector));
1360
+ for (const column of layoutArg.columns) {
1361
+ this.style.removeProperty(column.widthProperty);
1362
+ for (const cell of cellsOf(column)) {
1363
+ cell.style.removeProperty("font-size");
1364
+ cell.style.removeProperty("white-space");
1365
+ cell.style.removeProperty("overflow-wrap");
1366
+ }
1367
+ }
1368
+ const headerRow = containerArg.querySelector<HTMLElement>(headerRowSelectorArg);
1369
+ if (!headerRow || headerRow.clientWidth === 0) {
1370
+ // not laid out: the columns keep their minimums
1371
+ return;
1372
+ }
1373
+ const wanted = layoutArg.columns.map((columnArg) =>
1374
+ cellsOf(columnArg).reduce(
1375
+ (widestArg, cellArg) => Math.max(widestArg, neededWidth(cellArg)),
1376
+ columnArg.minWidth
1377
+ )
1378
+ );
1379
+ const widths = shareWidth(
1380
+ wanted,
1381
+ layoutArg.columns.map((columnArg) => columnArg.minWidth),
1382
+ headerRow.clientWidth - layoutArg.fixedWidth - layoutArg.flexibleMinWidth
1383
+ );
1384
+ layoutArg.columns.forEach((columnArg, index) => {
1385
+ this.style.setProperty(columnArg.widthProperty, `${widths[index]}px`);
1386
+ for (const cell of cellsOf(columnArg)) {
1387
+ fitCellText(cell, widths[index]);
1388
+ }
1389
+ });
726
1390
  }
727
1391
  }