@design.estate/dees-document 3.2.0 → 3.4.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 {
@@ -238,6 +452,21 @@ export class DeContentInvoice extends DeesElement {
238
452
  font-weight: bold;
239
453
  }
240
454
 
455
+ /* the tax an advance payment contains is stated, not added up */
456
+ .sums .sumline--contained .label,
457
+ .sums .sumline--contained .value {
458
+ font-weight: normal;
459
+ font-size: 11px;
460
+ }
461
+
462
+ .advancePayment {
463
+ margin-top: 2px;
464
+ padding-left: 20%;
465
+ font-size: 11px;
466
+ text-align: right;
467
+ color: #555;
468
+ }
469
+
241
470
  .divider {
242
471
  margin-top: 8px;
243
472
  border-top: 1px dotted #ccc;
@@ -264,6 +493,19 @@ export class DeContentInvoice extends DeesElement {
264
493
  margin-top: 4px;
265
494
  }
266
495
 
496
+ .correction {
497
+ margin-bottom: 12px;
498
+ }
499
+
500
+ .correctionTitle {
501
+ font-weight: bold;
502
+ }
503
+
504
+ .correctionDetails ul {
505
+ margin: 2px 0 0 0;
506
+ padding-left: 18px;
507
+ }
508
+
267
509
  .relatedDocuments .label {
268
510
  font-weight: bold;
269
511
  }
@@ -294,7 +536,7 @@ export class DeContentInvoice extends DeesElement {
294
536
  * an unknown or missing currency falls back to a plain decimal number.
295
537
  */
296
538
  protected formatPrice(value: number): string {
297
- const currency = this.accountingDoc?.currency;
539
+ const currency = this.currency;
298
540
  const locale = localeForLanguage(this.documentSettings?.languageCode);
299
541
  if (currency) {
300
542
  try {
@@ -306,6 +548,39 @@ export class DeContentInvoice extends DeesElement {
306
548
  return new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
307
549
  }
308
550
 
551
+ /**
552
+ * Formats a quantity in the document's language with every decimal it
553
+ * carries: 12.5 prints as "12,5" in German and "12.5" in English, and no
554
+ * stated quantity is rounded. The decimals are those of the shortest
555
+ * representation of the number, the one `String()` gives, as the unit name
556
+ * counts them for its singular or plural.
557
+ */
558
+ protected formatQuantity(quantityArg: number): string {
559
+ const fractionDigits = plugins.shared.quantityFractionDigits(quantityArg);
560
+ return new Intl.NumberFormat(
561
+ localeForLanguage(this.documentSettings?.languageCode),
562
+ {
563
+ minimumFractionDigits: fractionDigits,
564
+ maximumFractionDigits: fractionDigits,
565
+ }
566
+ ).format(quantityArg);
567
+ }
568
+
569
+ /** Formats a rate in percent, with as many decimals as it states, up to four. */
570
+ protected formatPercent(percentArg: number): string {
571
+ return new Intl.NumberFormat(
572
+ localeForLanguage(this.documentSettings?.languageCode),
573
+ { style: "percent", maximumFractionDigits: 4 }
574
+ ).format(percentArg / 100);
575
+ }
576
+
577
+ /** Formats a day in the document's language and date style. */
578
+ protected formatDay(timestampArg: number): string {
579
+ return new Intl.DateTimeFormat(this.documentSettings.languageCode, {
580
+ dateStyle: this.documentSettings.dateStyle,
581
+ }).format(new Date(timestampArg));
582
+ }
583
+
309
584
  public getTotalNet = (): number => {
310
585
  let totalNet = 0;
311
586
 
@@ -485,24 +760,33 @@ export class DeContentInvoice extends DeesElement {
485
760
  }
486
761
  }
487
762
 
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 {
763
+ /**
764
+ * The due date, which only an accounting document and a payment reminder
765
+ * have: it counts `dueInDays` from the letter's date. A document that
766
+ * states a paid amount names the amount due, since the total is not what
767
+ * remains to be paid.
768
+ */
769
+ private renderPaymentTerms(optionsArg: {
770
+ date: number;
771
+ dueInDays: number;
772
+ labelKey: plugins.shared.translation.TranslationKey;
773
+ textKey: plugins.shared.translation.TranslationKey;
774
+ /** the amount due, when the document states a paid amount */
775
+ amountDue?: number;
776
+ }): TemplateResult {
492
777
  return html`<div class="infoBox">
493
778
  <div>
494
779
  <div>
495
- <div class="label">
496
- ${this.translateKey("invoice@@payment.terms")}
497
- </div>
780
+ <div class="label">${this.translateKey(optionsArg.labelKey)}</div>
498
781
  <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
782
+ ${optionsArg.amountDue === undefined
783
+ ? this.translateKey(optionsArg.textKey)
784
+ : html`${this.translateKey("payment.amountDue")}:
785
+ ${this.formatPrice(optionsArg.amountDue)}
786
+ ${this.translateKey("payment.terms.amountDue")}`}
787
+ ${this.formatDay(
788
+ new Date(optionsArg.date).setDate(
789
+ new Date(optionsArg.date).getDate() + optionsArg.dueInDays
506
790
  )
507
791
  )}
508
792
  </span>
@@ -516,15 +800,21 @@ export class DeContentInvoice extends DeesElement {
516
800
  * code: a euro amount to the sender's IBAN, which a document in another
517
801
  * currency or without the sender's IBAN does not have.
518
802
  */
519
- private renderPaymentInfo(
520
- accountingDocArg: plugins.tsclass.finance.TAccountingDoc
521
- ): TemplateResult | null {
522
- const bic = accountingDocArg.from.sepaConnection?.bic;
523
- const name = accountingDocArg.from.name;
524
- const iban = accountingDocArg.from.sepaConnection?.iban;
525
- const currency = accountingDocArg.currency;
526
- const totalGross = this.getTotalGross();
527
- const reference = accountingDocArg.id;
803
+ private renderPaymentInfo(optionsArg: {
804
+ /** the sender, who is paid */
805
+ from: plugins.tsclass.business.TContact;
806
+ currency: string;
807
+ /** the amount to pay */
808
+ amount: number;
809
+ /** the number of the letter, the remittance information */
810
+ reference: string;
811
+ }): TemplateResult | null {
812
+ const bic = optionsArg.from.sepaConnection?.bic;
813
+ const name = optionsArg.from.name;
814
+ const iban = optionsArg.from.sepaConnection?.iban;
815
+ const currency = optionsArg.currency;
816
+ const totalGross = optionsArg.amount;
817
+ const reference = optionsArg.reference;
528
818
  if (
529
819
  typeof iban !== "string" ||
530
820
  plugins.shared.buildEpcQrPayload({
@@ -557,6 +847,115 @@ export class DeContentInvoice extends DeesElement {
557
847
  </div>`;
558
848
  }
559
849
 
850
+ /**
851
+ * The paid amount and the amount due, below the gross total, when the
852
+ * document states a paid amount (EN 16931 BT-113 and BT-115). The advance
853
+ * payments a final invoice deducts are deducted as the gross total received,
854
+ * with the tax it contains stated per rate, as Abschnitt 14.8 Abs. 7 Satz 3
855
+ * UStAE allows: "Statt der vorausgezahlten Entgelte oder Teilentgelte und
856
+ * der Steuerbeträge können auch die Gesamtbeträge der Voraus- oder
857
+ * Anzahlungen abgesetzt und die darin enthaltenen Steuerbeträge zusätzlich
858
+ * angegeben werden." More paid than the total prints as a credit balance.
859
+ */
860
+ private renderPaidAmountSums(
861
+ summaryArg: plugins.shared.IPaidAmountSummary
862
+ ): TemplateResult {
863
+ return html`<div class="sumline">
864
+ <div class="label">${this.translateKey("payment.paid")}</div>
865
+ <div class="value rightAlign amountCell">
866
+ ${this.formatPrice(summaryArg.paidAmount)}
867
+ </div>
868
+ </div>
869
+ ${summaryArg.advancePaymentTaxes.map(
870
+ (taxArg) => html`<div class="sumline sumline--contained">
871
+ <div class="label">
872
+ ${this.translateKey("advancePayment.vat")} ${taxArg.percentage}%
873
+ </div>
874
+ <div class="value rightAlign amountCell">
875
+ ${this.formatPrice(taxArg.vat)}
876
+ </div>
877
+ </div>`
878
+ )}
879
+ <div class="sumline">
880
+ <div class="label">
881
+ ${this.translateKey(
882
+ summaryArg.amountDue < 0 ? "payment.creditBalance" : "payment.amountDue"
883
+ )}
884
+ </div>
885
+ <div class="value value--total rightAlign amountCell">
886
+ ${this.formatPrice(Math.abs(summaryArg.amountDue))}
887
+ </div>
888
+ </div>`;
889
+ }
890
+
891
+ /**
892
+ * The advance payments the paid amount deducts, one line each, by the
893
+ * advance invoice's number and date and the day the payment was received,
894
+ * as far as the document states them. Each line is its own block, so a long
895
+ * list flows across pages.
896
+ */
897
+ private renderAdvancePayments(
898
+ summaryArg: plugins.shared.IPaidAmountSummary
899
+ ): TemplateResult[] {
900
+ return summaryArg.advancePayments.map(
901
+ (paymentArg) => html`<div class="advancePayment">
902
+ ${this.translateKey("advancePayment.title")}${paymentArg.invoice
903
+ ? html` ${this.translateKey("advancePayment.invoice")}
904
+ ${paymentArg.invoice.documentId}${paymentArg.invoice.issueDate !== undefined
905
+ ? html` ${this.translateKey("advancePayment.invoiceDate")}
906
+ ${this.formatDay(paymentArg.invoice.issueDate)}`
907
+ : null}`
908
+ : null}${paymentArg.receivedOn !== undefined
909
+ ? html`, ${this.translateKey("advancePayment.received")}
910
+ ${this.formatDay(paymentArg.receivedOn)}`
911
+ : null}
912
+ </div>`
913
+ );
914
+ }
915
+
916
+ /**
917
+ * What the document asks to be paid: the payment terms and the QR pay box
918
+ * for the gross total, or for the amount due when the document states a
919
+ * paid amount. When nothing is due, it says so, and names the credit
920
+ * balance when more was paid than the total; there are no terms and no QR
921
+ * pay box then.
922
+ */
923
+ private renderPaymentRequest(
924
+ accountingDocArg: plugins.tsclass.finance.TAccountingDoc,
925
+ summaryArg: plugins.shared.IPaidAmountSummary | null
926
+ ): TemplateResult {
927
+ if (summaryArg && summaryArg.amountDue <= 0) {
928
+ return html`<div class="infoBox">
929
+ <div>
930
+ <div>
931
+ <div class="label">${this.translateKey("invoice@@payment.terms")}</div>
932
+ <span>
933
+ ${summaryArg.amountDue === 0
934
+ ? this.translateKey("payment.nothingDue")
935
+ : html`${this.translateKey("payment.nothingDue.creditBalance")}
936
+ ${this.formatPrice(-summaryArg.amountDue)}.`}
937
+ </span>
938
+ </div>
939
+ </div>
940
+ </div>`;
941
+ }
942
+ return html`
943
+ ${this.renderPaymentTerms({
944
+ date: accountingDocArg.date,
945
+ dueInDays: accountingDocArg.dueInDays,
946
+ labelKey: "invoice@@payment.terms",
947
+ textKey: "invoice@@payment.terms.direct",
948
+ amountDue: summaryArg?.amountDue,
949
+ })}
950
+ ${this.renderPaymentInfo({
951
+ from: accountingDocArg.from,
952
+ currency: accountingDocArg.currency,
953
+ amount: summaryArg ? summaryArg.amountDue : this.getTotalGross(),
954
+ reference: accountingDocArg.id,
955
+ })}
956
+ `;
957
+ }
958
+
560
959
  /**
561
960
  * The reverse-charge statement. § 14a UStG requires the words
562
961
  * "Steuerschuldnerschaft des Leistungsempfängers": Abs. 5 for a domestic
@@ -577,6 +976,46 @@ export class DeContentInvoice extends DeesElement {
577
976
  </div>`;
578
977
  }
579
978
 
979
+ /**
980
+ * What makes a corrected invoice a correction (§ 31 Abs. 5 UStDV): its
981
+ * title, the invoice it corrects by number and issue date when the document
982
+ * states them ("spezifisch und eindeutig", Satz 2), and the details it
983
+ * supplies or corrects, in words. A received corrected invoice may name
984
+ * neither; then only the title is printed, and no reference is made up.
985
+ */
986
+ private renderCorrection(): TemplateResult | null {
987
+ const accountingDoc = this.accountingDoc;
988
+ if (accountingDoc?.accountingDocType !== "corrected-invoice") {
989
+ return null;
990
+ }
991
+ const reference = accountingDoc.correctedInvoice;
992
+ const corrections = accountingDoc.corrections ?? [];
993
+ return html`<div class="correction">
994
+ <div class="correctionTitle">
995
+ ${this.translateKey("correctedInvoice.title")}${reference
996
+ ? html` ${this.translateKey("correctedInvoice.reference")}
997
+ ${reference.documentId}${reference.issueDate !== undefined
998
+ ? html` ${this.translateKey("correctedInvoice.referenceDate")}
999
+ ${this.formatDay(reference.issueDate)}`
1000
+ : null}`
1001
+ : null}
1002
+ </div>
1003
+ ${corrections.length > 0
1004
+ ? html`<div class="correctionDetails">
1005
+ <div>${this.translateKey("correctedInvoice.corrections")}:</div>
1006
+ <ul>
1007
+ ${corrections.map(
1008
+ (correctionArg) => html`<li>
1009
+ ${this.translateKey(correctionDetailLabels[correctionArg.detail])}:
1010
+ ${this.translateKey(correctionDefectLabels[correctionArg.defect])}
1011
+ </li>`
1012
+ )}
1013
+ </ul>
1014
+ </div>`
1015
+ : null}
1016
+ </div>`;
1017
+ }
1018
+
580
1019
  /** The earlier documents a correction answers, by number and, when known, by date. */
581
1020
  private renderRelatedDocuments(): TemplateResult | null {
582
1021
  const relatedDocuments = this.accountingDoc?.relatedDocuments;
@@ -610,8 +1049,8 @@ export class DeContentInvoice extends DeesElement {
610
1049
  * pages and cannot split one, so a note is handed over token by token: a
611
1050
  * paragraph in one piece could be taller than a page and would never fit.
612
1051
  */
613
- private renderNotes(): TemplateResult | null {
614
- const notes = (this.accountingDoc?.notes ?? [])
1052
+ private renderNotes(notesArg: string[] | undefined): TemplateResult | null {
1053
+ const notes = (notesArg ?? [])
615
1054
  .filter((noteArg): noteArg is string => typeof noteArg === "string")
616
1055
  .map((noteArg) => noteArg.replace(/\r\n?/g, "\n").trim())
617
1056
  .filter((noteArg) => noteArg.length > 0);
@@ -626,6 +1065,240 @@ export class DeContentInvoice extends DeesElement {
626
1065
  )}`;
627
1066
  }
628
1067
 
1068
+ /**
1069
+ * The document a reminder claims, as the claims table and the charges name
1070
+ * it: its kind and its number.
1071
+ */
1072
+ private claimLabel(
1073
+ reminderArg: plugins.tsclass.finance.TPaymentReminder,
1074
+ documentIdArg: string
1075
+ ): string {
1076
+ const claim = reminderArg.claims.find(
1077
+ (claimArg) => claimArg.documentId === documentIdArg
1078
+ );
1079
+ if (!claim) {
1080
+ return documentIdArg;
1081
+ }
1082
+ // a kind this version does not know is named as an invoice, as the letterhead and the content do
1083
+ const label =
1084
+ claimDocumentLabels[claim.documentType] ?? claimDocumentLabels.invoice;
1085
+ return `${this.translateKey(label)} ${documentIdArg}`;
1086
+ }
1087
+
1088
+ /**
1089
+ * The ground of the claim: every reminded document with its number, its
1090
+ * date, the date it fell due when it stated one, its gross total and what is
1091
+ * still open on it.
1092
+ */
1093
+ private renderReminderClaims(
1094
+ reminderArg: plugins.tsclass.finance.TPaymentReminder
1095
+ ): TemplateResult {
1096
+ return html`<div class="claimGrid topLine dataHeader">
1097
+ <div class="lineItem descriptionCell">
1098
+ ${this.translateKey("paymentReminder@@claim.document")}
1099
+ </div>
1100
+ <div class="lineItem rightAlign issueDateCell">
1101
+ ${this.translateKey("paymentReminder@@claim.issueDate")}
1102
+ </div>
1103
+ <div class="lineItem rightAlign dueDateCell">
1104
+ ${this.translateKey("paymentReminder@@claim.dueDate")}
1105
+ </div>
1106
+ <div class="lineItem rightAlign grossCell">
1107
+ ${this.translateKey("paymentReminder@@claim.totalGross")}
1108
+ </div>
1109
+ <div class="lineItem rightAlign amountCell">
1110
+ ${this.translateKey("paymentReminder@@claim.outstanding")}
1111
+ </div>
1112
+ </div>
1113
+ ${reminderArg.claims.map(
1114
+ (claimArg) => html`<div class="claimGrid needsDataHeader">
1115
+ <div class="lineItem descriptionCell">
1116
+ ${this.claimLabel(reminderArg, claimArg.documentId)}
1117
+ </div>
1118
+ <div class="lineItem rightAlign issueDateCell">
1119
+ ${this.formatDay(claimArg.issueDate)}
1120
+ </div>
1121
+ <div class="lineItem rightAlign dueDateCell">
1122
+ ${claimArg.dueDate !== undefined
1123
+ ? this.formatDay(claimArg.dueDate)
1124
+ : "–"}
1125
+ </div>
1126
+ <div class="lineItem rightAlign grossCell">
1127
+ ${this.formatPrice(claimArg.totalGross)}
1128
+ </div>
1129
+ <div class="lineItem rightAlign amountCell">
1130
+ ${this.formatPrice(claimArg.outstandingAmount)}
1131
+ </div>
1132
+ </div>`
1133
+ )}`;
1134
+ }
1135
+
1136
+ /**
1137
+ * What a charge rests on, as the reminder states it: default interest its
1138
+ * computation (principal, rate a year with the base rate and the surcharge
1139
+ * points of § 288 Abs. 1 or 2 BGB, first and last day), the default lump sum
1140
+ * its provision, and every charge the claim it accrues on. A cost with lump
1141
+ * sums credited against it lists them (§ 288 Abs. 5 Satz 3 BGB); its amount
1142
+ * is already the cost less them.
1143
+ */
1144
+ private renderChargeBasis(
1145
+ reminderArg: plugins.tsclass.finance.TPaymentReminder,
1146
+ chargeArg: plugins.tsclass.finance.TPaymentReminderCharge
1147
+ ): TemplateResult | null {
1148
+ switch (chargeArg.chargeType) {
1149
+ case "default-interest": {
1150
+ const interest = chargeArg.interest;
1151
+ return html`<div class="chargeBasis">
1152
+ ${this.claimLabel(reminderArg, chargeArg.claimDocumentId)}:
1153
+ ${this.formatPrice(interest.principal)} ×
1154
+ ${this.formatPercent(interest.ratePercent)}
1155
+ ${this.translateKey("paymentReminder@@interest.perYear")}
1156
+ (${this.translateKey("paymentReminder@@interest.baseRate")}
1157
+ ${this.formatPercent(interest.baseRatePercent)}
1158
+ ${this.translateKey("paymentReminder@@interest.plus")}
1159
+ ${interest.surchargePoints}
1160
+ ${this.translateKey("paymentReminder@@interest.points")},
1161
+ <span class="citation"
1162
+ >${plugins.shared.defaultInterestCitations[interest.legalBasis]}</span
1163
+ >),
1164
+ ${this.formatDay(interest.from)} – ${this.formatDay(interest.to)}
1165
+ </div>`;
1166
+ }
1167
+ case "default-lump-sum":
1168
+ return html`<div class="chargeBasis">
1169
+ ${this.claimLabel(reminderArg, chargeArg.claimDocumentId)},
1170
+ <span class="citation"
1171
+ >${plugins.shared.DEFAULT_LUMP_SUM_CITATION}</span
1172
+ >
1173
+ </div>`;
1174
+ case "reminder-fee":
1175
+ case "other-cost": {
1176
+ const offsets = chargeArg.lumpSumOffsets ?? [];
1177
+ if (chargeArg.claimDocumentId === undefined && offsets.length === 0) {
1178
+ return null;
1179
+ }
1180
+ return html`${chargeArg.claimDocumentId !== undefined
1181
+ ? html`<div class="chargeBasis">
1182
+ ${this.claimLabel(reminderArg, chargeArg.claimDocumentId)}
1183
+ </div>`
1184
+ : null}
1185
+ ${offsets.map(
1186
+ (offsetArg) => html`<div class="chargeBasis">
1187
+ ${this.translateKey("paymentReminder@@charge.lumpSumOffset")}
1188
+ ${this.claimLabel(reminderArg, offsetArg.claimDocumentId)}:
1189
+ ${this.formatPrice(offsetArg.amount)}
1190
+ (<span class="citation"
1191
+ >${plugins.shared.LUMP_SUM_OFFSET_CITATION}</span
1192
+ >)
1193
+ </div>`
1194
+ )}`;
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ /** One row per charge: its description, what it rests on, and its amount. */
1200
+ private renderReminderCharges(
1201
+ reminderArg: plugins.tsclass.finance.TPaymentReminder
1202
+ ): TemplateResult | null {
1203
+ if (reminderArg.charges.length === 0) {
1204
+ return null;
1205
+ }
1206
+ return html`<div class="chargeGrid topLine dataHeader">
1207
+ <div class="lineItem">
1208
+ ${this.translateKey("paymentReminder@@charge.description")}
1209
+ </div>
1210
+ <div class="lineItem rightAlign amountCell">
1211
+ ${this.translateKey("paymentReminder@@charge.amount")}
1212
+ </div>
1213
+ </div>
1214
+ ${reminderArg.charges.map(
1215
+ (chargeArg) => html`<div class="chargeGrid needsDataHeader">
1216
+ <div class="lineItem chargeText">
1217
+ <div>${chargeArg.description}</div>
1218
+ ${this.renderChargeBasis(reminderArg, chargeArg)}
1219
+ </div>
1220
+ <div class="lineItem rightAlign amountCell">
1221
+ ${this.formatPrice(chargeArg.amount)}
1222
+ </div>
1223
+ </div>`
1224
+ )}`;
1225
+ }
1226
+
1227
+ /** The sums of a reminder: the open claims, the charges and the total payable, without VAT. */
1228
+ private renderReminderSums(
1229
+ totalsArg: plugins.shared.IPaymentReminderTotals,
1230
+ hasChargesArg: boolean
1231
+ ): TemplateResult {
1232
+ return html`<div class="sums">
1233
+ <div class="sumline">
1234
+ <div class="label">
1235
+ ${this.translateKey("paymentReminder@@paymentReminder.sum.claims")}
1236
+ </div>
1237
+ <div class="value rightAlign amountCell">
1238
+ ${this.formatPrice(totalsArg.claimsOutstanding)}
1239
+ </div>
1240
+ </div>
1241
+ ${hasChargesArg
1242
+ ? html`<div class="sumline">
1243
+ <div class="label">
1244
+ ${this.translateKey("paymentReminder@@paymentReminder.sum.charges")}
1245
+ </div>
1246
+ <div class="value rightAlign amountCell">
1247
+ ${this.formatPrice(totalsArg.charges)}
1248
+ </div>
1249
+ </div>`
1250
+ : null}
1251
+ <div class="sumline">
1252
+ <div class="label">
1253
+ ${this.translateKey("paymentReminder@@paymentReminder.sum.payable")}
1254
+ </div>
1255
+ <div class="value value--total rightAlign amountCell">
1256
+ ${this.formatPrice(totalsArg.payable)}
1257
+ </div>
1258
+ </div>
1259
+ </div>`;
1260
+ }
1261
+
1262
+ /**
1263
+ * A payment reminder: the issuer's intro or the standard one, the reminded
1264
+ * documents, the charges, the sums, the notes, the new payment deadline and
1265
+ * the QR pay box for the total payable with the reminder's number.
1266
+ */
1267
+ private renderPaymentReminder(
1268
+ reminderArg: plugins.tsclass.finance.TPaymentReminder
1269
+ ): TemplateResult {
1270
+ const totals = plugins.shared.getPaymentReminderTotals(reminderArg);
1271
+ return html`
1272
+ <div>
1273
+ ${reminderArg.topText ??
1274
+ this.translateKey("paymentReminder@@introStatement")}
1275
+ </div>
1276
+ ${this.renderReminderClaims(reminderArg)}
1277
+ ${this.renderReminderCharges(reminderArg)}
1278
+ ${this.renderReminderSums(totals, reminderArg.charges.length > 0)}
1279
+ <div class="divider"></div>
1280
+
1281
+ <!-- NOTES -->
1282
+ ${this.renderNotes(reminderArg.notes)}
1283
+
1284
+ <!-- PAYMENT DEADLINE -->
1285
+ ${this.renderPaymentTerms({
1286
+ date: reminderArg.date,
1287
+ dueInDays: reminderArg.dueInDays,
1288
+ labelKey: "paymentReminder@@paymentReminder.deadline",
1289
+ textKey: "paymentReminder@@paymentReminder.deadline.text",
1290
+ })}
1291
+
1292
+ <!-- PAYMENT INFO -->
1293
+ ${this.renderPaymentInfo({
1294
+ from: reminderArg.from,
1295
+ currency: reminderArg.currency,
1296
+ amount: totals.payable,
1297
+ reference: reminderArg.id,
1298
+ })}
1299
+ `;
1300
+ }
1301
+
629
1302
  private renderReferencedContract(): TemplateResult | null {
630
1303
  return null;
631
1304
  // return this.documentSettings.enableInvoiceContractRefSection &&
@@ -648,29 +1321,46 @@ export class DeContentInvoice extends DeesElement {
648
1321
 
649
1322
  public async attachInvoiceDom() {
650
1323
  const contentNodes = await this.getContentNodes();
1324
+ const paymentReminder = this.paymentReminder;
1325
+ if (paymentReminder) {
1326
+ render(this.renderPaymentReminder(paymentReminder), contentNodes.currentContent);
1327
+ this.fitColumns(
1328
+ contentNodes.currentContent,
1329
+ ".claimGrid.dataHeader",
1330
+ reminderColumns
1331
+ );
1332
+ return;
1333
+ }
651
1334
  const accountingDoc = this.accountingDoc;
1335
+ // throws when the advance payments do not add up to the paid amount
1336
+ const paidAmountSummary = accountingDoc
1337
+ ? plugins.shared.getPaidAmountSummary(accountingDoc, this.getTotalGross())
1338
+ : null;
652
1339
  render(
653
1340
  html`
1341
+ ${this.renderCorrection()}
654
1342
  <div>${this.translateKey(this.docContent.introStatement)}</div>
655
1343
  ${this.renderRelatedDocuments()}
656
1344
  <div class="grid topLine dataHeader">
657
1345
  <div class="lineItem rightAlign">
658
1346
  ${this.translateKey("invoice@@item.position")}
659
1347
  </div>
660
- <div class="lineItem">
1348
+ <div class="lineItem descriptionCell">
661
1349
  ${this.translateKey("invoice@@description")}
662
1350
  </div>
663
- <div class="lineItem rightAlign">
1351
+ <div class="lineItem rightAlign quantityCell">
664
1352
  ${this.translateKey("invoice@@quantity")}
665
1353
  </div>
666
- <div class="lineItem">${this.translateKey("invoice@@unit.type")}</div>
667
- <div class="lineItem rightAlign">
1354
+ <div class="lineItem unitCell">
1355
+ ${this.translateKey("invoice@@unit.type")}
1356
+ </div>
1357
+ <div class="lineItem rightAlign unitPriceCell">
668
1358
  ${this.translateKey("invoice@@price.unit.net")}
669
1359
  </div>
670
1360
  <div class="lineItem rightAlign">
671
1361
  ${this.translateKey("invoice@@vat.short")}
672
1362
  </div>
673
- <div class="lineItem rightAlign">
1363
+ <div class="lineItem rightAlign amountCell">
674
1364
  ${this.translateKey("invoice@@price.total.net")}
675
1365
  </div>
676
1366
  </div>
@@ -678,16 +1368,24 @@ export class DeContentInvoice extends DeesElement {
678
1368
  (invoiceItem, index) => html`
679
1369
  <div class="grid needsDataHeader">
680
1370
  <div class="lineItem rightAlign">${index + 1}</div>
681
- <div class="lineItem">${invoiceItem.name}</div>
682
- <div class="lineItem rightAlign">${invoiceItem.unitQuantity}</div>
683
- <div class="lineItem">${invoiceItem.unitType}</div>
684
- <div class="lineItem rightAlign">
1371
+ <div class="lineItem descriptionCell">${invoiceItem.name}</div>
1372
+ <div class="lineItem rightAlign quantityCell">
1373
+ ${this.formatQuantity(invoiceItem.unitQuantity)}
1374
+ </div>
1375
+ <div class="lineItem unitCell">
1376
+ ${plugins.shared.unitName(
1377
+ this.documentSettings.languageCode,
1378
+ invoiceItem.unitType,
1379
+ invoiceItem.unitQuantity
1380
+ )}
1381
+ </div>
1382
+ <div class="lineItem rightAlign unitPriceCell">
685
1383
  ${this.formatPrice(invoiceItem.unitNetPrice)}
686
1384
  </div>
687
1385
  <div class="lineItem rightAlign">
688
1386
  ${invoiceItem.vatPercentage}%
689
1387
  </div>
690
- <div class="lineItem rightAlign">
1388
+ <div class="lineItem rightAlign amountCell">
691
1389
  ${this.formatPrice(
692
1390
  invoiceItem.unitQuantity * invoiceItem.unitNetPrice
693
1391
  )}
@@ -700,7 +1398,7 @@ export class DeContentInvoice extends DeesElement {
700
1398
  <div class="label">
701
1399
  ${this.translateKey("invoice@@sum.total.net")}
702
1400
  </div>
703
- <div class="value value--total rightAlign">
1401
+ <div class="value value--total rightAlign amountCell">
704
1402
  ${this.formatPrice(this.getTotalNet())}
705
1403
  </div>
706
1404
  </div>
@@ -722,7 +1420,7 @@ export class DeContentInvoice extends DeesElement {
722
1420
  `
723
1421
  : html``}
724
1422
  </div>
725
- <div class="value rightAlign">
1423
+ <div class="value rightAlign amountCell">
726
1424
  ${this.formatPrice(vatGroupArg.vatAmountSum)}
727
1425
  </div>
728
1426
  </div>
@@ -730,11 +1428,13 @@ export class DeContentInvoice extends DeesElement {
730
1428
  })}
731
1429
  <div class="sumline">
732
1430
  <div class="label">${this.translateKey("invoice@@totalGross")}</div>
733
- <div class="value value--total rightAlign">
1431
+ <div class="value value--total rightAlign amountCell">
734
1432
  ${this.formatPrice(this.getTotalGross())}
735
1433
  </div>
736
1434
  </div>
1435
+ ${paidAmountSummary ? this.renderPaidAmountSums(paidAmountSummary) : null}
737
1436
  </div>
1437
+ ${paidAmountSummary ? this.renderAdvancePayments(paidAmountSummary) : null}
738
1438
  <div class="divider"></div>
739
1439
 
740
1440
  ${accountingDoc?.reverseCharge ? this.renderReverseChargeNote() : ``}
@@ -743,19 +1443,72 @@ export class DeContentInvoice extends DeesElement {
743
1443
  ${this.renderReferencedContract()}
744
1444
 
745
1445
  <!-- NOTES -->
746
- ${this.renderNotes()}
1446
+ ${this.renderNotes(accountingDoc?.notes)}
747
1447
 
748
- <!-- PAYMENT TERMS -->
1448
+ <!-- PAYMENT TERMS AND PAYMENT INFO -->
749
1449
  ${accountingDoc && this.docContent.requestsPayment
750
- ? this.renderPaymentTerms(accountingDoc)
751
- : null}
752
-
753
- <!-- PAYMENT INFO -->
754
- ${accountingDoc && this.docContent.requestsPayment
755
- ? this.renderPaymentInfo(accountingDoc)
1450
+ ? this.renderPaymentRequest(accountingDoc, paidAmountSummary)
756
1451
  : null}
757
1452
  `,
758
1453
  contentNodes.currentContent
759
1454
  );
1455
+ this.fitColumns(
1456
+ contentNodes.currentContent,
1457
+ ".grid.dataHeader",
1458
+ positionColumns
1459
+ );
1460
+ }
1461
+
1462
+ /**
1463
+ * Sizes the columns of a table to their widest cell. Every row is a grid of
1464
+ * its own, so the rows only line up when a column has one width for all of
1465
+ * them; each page renders every row before it is trimmed, so every page
1466
+ * measures the same widths.
1467
+ *
1468
+ * A column is never narrower than its minimum. The widths the columns ask
1469
+ * for beyond their minimums are granted out of the row's width less its
1470
+ * fixed columns and the minimum of its flexible column (the description),
1471
+ * in equal shares, so the table always fits the page. A cell whose text is
1472
+ * still wider than its column, such as an amount beyond hundreds of
1473
+ * billions, is set in a smaller font, down to 8 px, and wraps inside its
1474
+ * column once that is not enough, so it never runs into its neighbour.
1475
+ */
1476
+ private fitColumns(
1477
+ containerArg: HTMLElement,
1478
+ headerRowSelectorArg: string,
1479
+ layoutArg: IColumnLayout
1480
+ ): void {
1481
+ const cellsOf = (columnArg: IFittedColumn): HTMLElement[] =>
1482
+ Array.from(containerArg.querySelectorAll<HTMLElement>(columnArg.cellSelector));
1483
+ for (const column of layoutArg.columns) {
1484
+ this.style.removeProperty(column.widthProperty);
1485
+ for (const cell of cellsOf(column)) {
1486
+ cell.style.removeProperty("font-size");
1487
+ cell.style.removeProperty("white-space");
1488
+ cell.style.removeProperty("overflow-wrap");
1489
+ }
1490
+ }
1491
+ const headerRow = containerArg.querySelector<HTMLElement>(headerRowSelectorArg);
1492
+ if (!headerRow || headerRow.clientWidth === 0) {
1493
+ // not laid out: the columns keep their minimums
1494
+ return;
1495
+ }
1496
+ const wanted = layoutArg.columns.map((columnArg) =>
1497
+ cellsOf(columnArg).reduce(
1498
+ (widestArg, cellArg) => Math.max(widestArg, neededWidth(cellArg)),
1499
+ columnArg.minWidth
1500
+ )
1501
+ );
1502
+ const widths = shareWidth(
1503
+ wanted,
1504
+ layoutArg.columns.map((columnArg) => columnArg.minWidth),
1505
+ headerRow.clientWidth - layoutArg.fixedWidth - layoutArg.flexibleMinWidth
1506
+ );
1507
+ layoutArg.columns.forEach((columnArg, index) => {
1508
+ this.style.setProperty(columnArg.widthProperty, `${widths[index]}px`);
1509
+ for (const cell of cellsOf(columnArg)) {
1510
+ fitCellText(cell, widths[index]);
1511
+ }
1512
+ });
760
1513
  }
761
1514
  }