diff --git a/resources/pdf/metadata.xml b/resources/pdf/metadata.xml
deleted file mode 100644
index c302bafa82cc..000000000000
--- a/resources/pdf/metadata.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
- A
- 1
-
-
-
-
-
diff --git a/src/common/api/worker/facades/lazy/CustomerFacade.ts b/src/common/api/worker/facades/lazy/CustomerFacade.ts
index 2ecff187db52..338c7129670d 100644
--- a/src/common/api/worker/facades/lazy/CustomerFacade.ts
+++ b/src/common/api/worker/facades/lazy/CustomerFacade.ts
@@ -70,6 +70,7 @@ import { KeyLoaderFacade } from "../KeyLoaderFacade.js"
import { RecoverCodeFacade } from "./RecoverCodeFacade.js"
import { encryptKeyWithVersionedKey, VersionedEncryptedKey, VersionedKey } from "../../crypto/CryptoWrapper.js"
import { AsymmetricCryptoFacade } from "../../crypto/AsymmetricCryptoFacade.js"
+import { XRechnungInvoiceGenerator } from "../../invoicegen/XRechnungInvoiceGenerator.js"
import type { SubscriptionApp } from "../../../../subscription/SubscriptionViewer.js"
assertWorkerOrNode()
@@ -466,6 +467,23 @@ export class CustomerFacade {
}
}
+ async generateXRechnungInvoice(invoiceNumber: string): Promise {
+ const customer = await this.entityClient.load(CustomerTypeRef, assertNotNull(this.userFacade.getUser()?.customer))
+ const customerInfo = await this.entityClient.load(CustomerInfoTypeRef, customer.customerInfo)
+ const invoiceData = await this.serviceExecutor.get(InvoiceDataService, createInvoiceDataGetIn({ invoiceNumber }))
+ const { XRechnungInvoiceGenerator } = await import("../../invoicegen/XRechnungInvoiceGenerator.js")
+ const xRechnungGenerator = new XRechnungInvoiceGenerator(invoiceData, invoiceNumber, this.getCustomerId(), customerInfo.registrationMailAddress)
+ const xRechnungFile = xRechnungGenerator.generate()
+ return {
+ _type: "DataFile",
+ name: String(invoiceNumber) + ".xml",
+ mimeType: "application/xml",
+ data: xRechnungFile,
+ size: xRechnungFile.byteLength,
+ id: undefined,
+ }
+ }
+
async loadAccountingInfo(): Promise {
const customer = await this.entityClient.load(CustomerTypeRef, assertNotNull(this.userFacade.getUser()?.customer))
const customerInfo = await this.entityClient.load(CustomerInfoTypeRef, customer.customerInfo)
diff --git a/src/common/api/worker/invoicegen/InvoiceUtils.ts b/src/common/api/worker/invoicegen/InvoiceUtils.ts
new file mode 100644
index 000000000000..a16a564d4294
--- /dev/null
+++ b/src/common/api/worker/invoicegen/InvoiceUtils.ts
@@ -0,0 +1,123 @@
+import InvoiceTexts from "./InvoiceTexts.js"
+
+export const enum VatType {
+ NO_VAT = "0",
+ ADD_VAT = "1",
+ VAT_INCLUDED_SHOWN = "2",
+ VAT_INCLUDED_HIDDEN = "3",
+ NO_VAT_REVERSE_CHARGE = "4",
+}
+
+export const enum InvoiceType {
+ INVOICE = "0",
+ CREDIT = "1",
+ REFERRAL_CREDIT = "2",
+}
+
+export const enum PaymentMethod {
+ INVOICE = "0",
+ CREDIT_CARD = "1",
+ SEPA_UNUSED = "2",
+ PAYPAL = "3",
+ ACCOUNT_BALANCE = "4",
+}
+
+export const enum InvoiceItemType {
+ PREMIUM_USER = "0",
+ StarterUser = "1",
+ StarterUserPackage = "2",
+ StarterUserPackageUpgrade = "3",
+ StoragePackage = "4",
+ StoragePackageUpgrade = "5",
+ EmailAliasPackage = "6",
+ EmailAliasPackageUpgrade = "7",
+ SharedMailGroup = "8",
+ WhitelabelFeature = "9",
+ ContactForm_UNUSED = "10",
+ WhitelabelChild = "11",
+ LocalAdminGroup = "12",
+ Discount = "13",
+ SharingFeature = "14",
+ Credit = "15",
+ GiftCard = "16",
+ BusinessFeature = "17",
+ GiftCardMigration = "18",
+ ReferralCredit = "19",
+ CancelledReferralCredit = "20",
+ RevolutionaryAccount = "21",
+ LegendAccount = "22",
+ EssentialAccount = "23",
+ AdvancedAccount = "24",
+ UnlimitedAccount = "25",
+}
+
+/**
+ * Returns the language code of country as either "en" or "de"
+ * "de" is only returned if the country is Germany or Austria
+ * @param country
+ */
+export function countryUsesGerman(country: string): "de" | "en" {
+ return country === "DE" || country === "AT" ? "de" : "en"
+}
+
+/**
+ * Get the name of a given InvoiceItemType
+ */
+export function getInvoiceItemTypeName(type: NumberString, languageCode: "en" | "de"): string {
+ switch (type) {
+ case InvoiceItemType.PREMIUM_USER:
+ return InvoiceTexts[languageCode].premiumUser
+ case InvoiceItemType.StarterUser:
+ return InvoiceTexts[languageCode].starterUser
+ case InvoiceItemType.StarterUserPackage:
+ return InvoiceTexts[languageCode].starterUserPackage
+ case InvoiceItemType.StarterUserPackageUpgrade:
+ return InvoiceTexts[languageCode].starterUserPackageUpgrade
+ case InvoiceItemType.StoragePackage:
+ return InvoiceTexts[languageCode].storagePackage
+ case InvoiceItemType.StoragePackageUpgrade:
+ return InvoiceTexts[languageCode].storagePackageUpgrade
+ case InvoiceItemType.EmailAliasPackage:
+ return InvoiceTexts[languageCode].emailAliasPackage
+ case InvoiceItemType.EmailAliasPackageUpgrade:
+ return InvoiceTexts[languageCode].emailAliasPackageUpgrade
+ case InvoiceItemType.SharedMailGroup:
+ return InvoiceTexts[languageCode].sharedMailGroup
+ case InvoiceItemType.WhitelabelFeature:
+ return InvoiceTexts[languageCode].whitelabelFeature
+ case InvoiceItemType.ContactForm_UNUSED:
+ return InvoiceTexts[languageCode].contactFormUnused
+ case InvoiceItemType.WhitelabelChild:
+ return InvoiceTexts[languageCode].whitelabelChild
+ case InvoiceItemType.LocalAdminGroup:
+ return InvoiceTexts[languageCode].localAdminGroup
+ case InvoiceItemType.Discount:
+ return InvoiceTexts[languageCode].discount
+ case InvoiceItemType.SharingFeature:
+ return InvoiceTexts[languageCode].sharingFeature
+ case InvoiceItemType.Credit:
+ return InvoiceTexts[languageCode].creditType
+ case InvoiceItemType.GiftCard:
+ return InvoiceTexts[languageCode].giftCard
+ case InvoiceItemType.BusinessFeature:
+ return InvoiceTexts[languageCode].businessFeature
+ case InvoiceItemType.GiftCardMigration:
+ return InvoiceTexts[languageCode].giftCardMigration
+ case InvoiceItemType.ReferralCredit:
+ return InvoiceTexts[languageCode].referralCredit
+ case InvoiceItemType.CancelledReferralCredit:
+ return InvoiceTexts[languageCode].cancelledReferralCredit
+ case InvoiceItemType.RevolutionaryAccount:
+ return InvoiceTexts[languageCode].revolutionaryAccount
+ case InvoiceItemType.LegendAccount:
+ return InvoiceTexts[languageCode].legendAccount
+ case InvoiceItemType.EssentialAccount:
+ return InvoiceTexts[languageCode].essentialAccount
+ case InvoiceItemType.AdvancedAccount:
+ return InvoiceTexts[languageCode].advancedAccount
+ case InvoiceItemType.UnlimitedAccount:
+ return InvoiceTexts[languageCode].unlimitedAccount
+ default:
+ throw new Error("Unknown InvoiceItemType " + type)
+ }
+}
diff --git a/src/common/api/worker/invoicegen/PdfInvoiceGenerator.ts b/src/common/api/worker/invoicegen/PdfInvoiceGenerator.ts
index b229ff11bd1a..2e5eafce0f00 100644
--- a/src/common/api/worker/invoicegen/PdfInvoiceGenerator.ts
+++ b/src/common/api/worker/invoicegen/PdfInvoiceGenerator.ts
@@ -2,69 +2,19 @@ import { MARGIN_LEFT, MARGIN_TOP, PDF_FONTS, PDF_IMAGES, PdfDocument, TABLE_VERT
import InvoiceTexts from "./InvoiceTexts.js"
import { PdfWriter } from "../pdf/PdfWriter.js"
import { InvoiceDataGetOut } from "../../entities/sys/TypeRefs.js"
-
-const enum VatType {
- NO_VAT = "0",
- ADD_VAT = "1",
- VAT_INCLUDED_SHOWN = "2",
- VAT_INCLUDED_HIDDEN = "3",
- NO_VAT_REVERSE_CHARGE = "4",
-}
-
-const enum InvoiceType {
- INVOICE = "0",
- CREDIT = "1",
- REFERRAL_CREDIT = "2",
-}
-
-const enum PaymentMethod {
- INVOICE = "0",
- CREDIT_CARD = "1",
- SEPA_UNUSED = "2",
- PAYPAL = "3",
- ACCOUNT_BALANCE = "4",
-}
-
-const enum InvoiceItemType {
- PREMIUM_USER = "0",
- StarterUser = "1",
- StarterUserPackage = "2",
- StarterUserPackageUpgrade = "3",
- StoragePackage = "4",
- StoragePackageUpgrade = "5",
- EmailAliasPackage = "6",
- EmailAliasPackageUpgrade = "7",
- SharedMailGroup = "8",
- WhitelabelFeature = "9",
- ContactForm_UNUSED = "10",
- WhitelabelChild = "11",
- LocalAdminGroup = "12",
- Discount = "13",
- SharingFeature = "14",
- Credit = "15",
- GiftCard = "16",
- BusinessFeature = "17",
- GiftCardMigration = "18",
- ReferralCredit = "19",
- CancelledReferralCredit = "20",
- RevolutionaryAccount = "21",
- LegendAccount = "22",
- EssentialAccount = "23",
- AdvancedAccount = "24",
- UnlimitedAccount = "25",
-}
+import { countryUsesGerman, getInvoiceItemTypeName, InvoiceItemType, InvoiceType, PaymentMethod, VatType } from "./InvoiceUtils.js"
/**
* Object generating a PDF invoice document.
- * This document is ONLY responsible for rendering the data it gets and formatting it in a way that does not change anything about it.
+ * This generator is ONLY responsible for rendering the data it gets and formatting it in a way that does not change anything about it.
* If adjustments to the data must be made prior to rendering, then these should take place within the RenderInvoice service.
*/
export class PdfInvoiceGenerator {
private readonly doc: PdfDocument
private readonly languageCode: "de" | "en" = "en"
+ private readonly invoiceNumber: string
+ private readonly customerId: string
private invoice: InvoiceDataGetOut
- private invoiceNumber: string
- private customerId: string
constructor(pdfWriter: PdfWriter, invoice: InvoiceDataGetOut, invoiceNumber: string, customerId: string) {
this.invoice = invoice
@@ -170,14 +120,14 @@ export class PdfInvoiceGenerator {
// Entry with all invoice info
tableData.push([
this.formatAmount(invoiceItem.itemType, invoiceItem.amount),
- this.getInvoiceItemTypeName(invoiceItem.itemType),
+ getInvoiceItemTypeName(invoiceItem.itemType, this.languageCode),
invoiceItem.singlePrice == null ? "" : this.formatInvoiceCurrency(invoiceItem.singlePrice),
this.formatInvoiceCurrency(invoiceItem.totalPrice),
])
// Entry with date range
tableData.push(["", `${this.formatInvoiceDate(invoiceItem.startDate)} - ${this.formatInvoiceDate(invoiceItem.endDate)}`, "", ""])
}
- const tableEndPoint = await this.doc.addTable([MARGIN_LEFT, MARGIN_TOP + 120], 165, columns, tableData)
+ const tableEndPoint = await this.doc.addTable([MARGIN_LEFT, MARGIN_TOP + 120], 165, columns, tableData, this.getTableRowsForFirstPage())
this.renderTableSummary(tableEndPoint, columns)
this.doc.changeTextCursorPosition([MARGIN_LEFT, tableEndPoint + 4 * TABLE_VERTICAL_SPACING])
@@ -190,6 +140,8 @@ export class PdfInvoiceGenerator {
// Line break that's to be removed if no VAT appears in the summary
let additionalVerticalSpace = 1
+ this.doc.changeFont(PDF_FONTS.REGULAR, 11)
+
// Sub total
this.doc.addTableRow([MARGIN_LEFT, tableEndPoint], columns, [
"",
@@ -231,7 +183,7 @@ export class PdfInvoiceGenerator {
* Additional blocks displayed below the table depending on invoice type, vat type and payment method
*/
renderAdditional() {
- this.doc.changeFont(PDF_FONTS.REGULAR, 12)
+ this.doc.changeFont(PDF_FONTS.REGULAR, 11)
// No VAT / VAT not shown in table
switch (this.invoice.vatType) {
@@ -249,6 +201,7 @@ export class PdfInvoiceGenerator {
.addText(`${InvoiceTexts[this.languageCode].yourVatId} `)
.changeFont(PDF_FONTS.BOLD, 11)
.addText(`${this.invoice.vatIdNumber}`)
+ .changeFont(PDF_FONTS.REGULAR, 11)
} else {
this.doc.addText(InvoiceTexts[this.languageCode].netPricesNoVatInGermany)
}
@@ -320,6 +273,23 @@ export class PdfInvoiceGenerator {
.addText(InvoiceTexts[this.languageCode].legalBankAccount)
}
+ /**
+ * Determines how many table rows (invoice items) can be rendered on the first page depending on the texts that follow after the table
+ */
+ getTableRowsForFirstPage(): number {
+ if (
+ this.invoice.paymentMethod === PaymentMethod.INVOICE &&
+ this.invoice.vatIdNumber != null &&
+ (this.invoice.vatType === VatType.NO_VAT || this.invoice.vatType === VatType.NO_VAT_REVERSE_CHARGE)
+ ) {
+ // In these scenarios, there will be a lot of text after the table summary, so few rows can render
+ return 4
+ } else {
+ // In all other scenarios, there will be little text after the table summary, so more rows can render
+ return 8
+ }
+ }
+
/**
* Get the name of a given InvoiceType
*/
@@ -340,68 +310,6 @@ export class PdfInvoiceGenerator {
}
}
- /**
- * Get the name of a given InvoiceItemType
- */
- getInvoiceItemTypeName(type: NumberString): string {
- switch (type) {
- case InvoiceItemType.PREMIUM_USER:
- return InvoiceTexts[this.languageCode].premiumUser
- case InvoiceItemType.StarterUser:
- return InvoiceTexts[this.languageCode].starterUser
- case InvoiceItemType.StarterUserPackage:
- return InvoiceTexts[this.languageCode].starterUserPackage
- case InvoiceItemType.StarterUserPackageUpgrade:
- return InvoiceTexts[this.languageCode].starterUserPackageUpgrade
- case InvoiceItemType.StoragePackage:
- return InvoiceTexts[this.languageCode].storagePackage
- case InvoiceItemType.StoragePackageUpgrade:
- return InvoiceTexts[this.languageCode].storagePackageUpgrade
- case InvoiceItemType.EmailAliasPackage:
- return InvoiceTexts[this.languageCode].emailAliasPackage
- case InvoiceItemType.EmailAliasPackageUpgrade:
- return InvoiceTexts[this.languageCode].emailAliasPackageUpgrade
- case InvoiceItemType.SharedMailGroup:
- return InvoiceTexts[this.languageCode].sharedMailGroup
- case InvoiceItemType.WhitelabelFeature:
- return InvoiceTexts[this.languageCode].whitelabelFeature
- case InvoiceItemType.ContactForm_UNUSED:
- return InvoiceTexts[this.languageCode].contactFormUnused
- case InvoiceItemType.WhitelabelChild:
- return InvoiceTexts[this.languageCode].whitelabelChild
- case InvoiceItemType.LocalAdminGroup:
- return InvoiceTexts[this.languageCode].localAdminGroup
- case InvoiceItemType.Discount:
- return InvoiceTexts[this.languageCode].discount
- case InvoiceItemType.SharingFeature:
- return InvoiceTexts[this.languageCode].sharingFeature
- case InvoiceItemType.Credit:
- return InvoiceTexts[this.languageCode].creditType
- case InvoiceItemType.GiftCard:
- return InvoiceTexts[this.languageCode].giftCard
- case InvoiceItemType.BusinessFeature:
- return InvoiceTexts[this.languageCode].businessFeature
- case InvoiceItemType.GiftCardMigration:
- return InvoiceTexts[this.languageCode].giftCardMigration
- case InvoiceItemType.ReferralCredit:
- return InvoiceTexts[this.languageCode].referralCredit
- case InvoiceItemType.CancelledReferralCredit:
- return InvoiceTexts[this.languageCode].cancelledReferralCredit
- case InvoiceItemType.RevolutionaryAccount:
- return InvoiceTexts[this.languageCode].revolutionaryAccount
- case InvoiceItemType.LegendAccount:
- return InvoiceTexts[this.languageCode].legendAccount
- case InvoiceItemType.EssentialAccount:
- return InvoiceTexts[this.languageCode].essentialAccount
- case InvoiceItemType.AdvancedAccount:
- return InvoiceTexts[this.languageCode].advancedAccount
- case InvoiceItemType.UnlimitedAccount:
- return InvoiceTexts[this.languageCode].unlimitedAccount
- default:
- throw new Error("Unknown InvoiceItemType " + type)
- }
- }
-
/**
* Format the date depending on document language (dd.mm.yyyy) / (dd. Mon yyyy)
*/
@@ -442,7 +350,3 @@ export class PdfInvoiceGenerator {
}
}
}
-
-function countryUsesGerman(country: string): "de" | "en" {
- return country === "DE" || country === "AT" ? "de" : "en"
-}
diff --git a/src/common/api/worker/invoicegen/XRechnungInvoiceGenerator.ts b/src/common/api/worker/invoicegen/XRechnungInvoiceGenerator.ts
new file mode 100644
index 000000000000..5aa085cc5874
--- /dev/null
+++ b/src/common/api/worker/invoicegen/XRechnungInvoiceGenerator.ts
@@ -0,0 +1,387 @@
+import { InvoiceDataGetOut, InvoiceDataItem } from "../../entities/sys/TypeRefs.js"
+import XRechnungUBLTemplate from "./XRechnungUBLTemplate.js"
+import InvoiceTexts from "./InvoiceTexts.js"
+import { countryUsesGerman, getInvoiceItemTypeName, InvoiceType, PaymentMethod, VatType } from "./InvoiceUtils.js"
+
+const DE_POSTAL_CODE_REGEX = new RegExp(/\d{5}/)
+const CITY_NAME_REGEX = new RegExp(/\d{5}/)
+
+const PaymentMethodTypeCodes: Record = Object.freeze({
+ [PaymentMethod.INVOICE]: "31",
+ [PaymentMethod.CREDIT_CARD]: "97",
+ [PaymentMethod.SEPA_UNUSED]: "59",
+ [PaymentMethod.PAYPAL]: "68",
+ [PaymentMethod.ACCOUNT_BALANCE]: "97",
+})
+
+const VatTypeCategoryCodes: Record = Object.freeze({
+ [VatType.NO_VAT]: "E",
+ [VatType.ADD_VAT]: "S",
+ [VatType.VAT_INCLUDED_SHOWN]: "S",
+ [VatType.VAT_INCLUDED_HIDDEN]: "S",
+ [VatType.NO_VAT_REVERSE_CHARGE]: "AE",
+})
+
+/**
+ * Object for generating XRechnung invoices.
+ * These are electronic invoices conforming to the European standard EN16931 and the German CIUS+Extension XRechnung standard.
+ * They are a legal requirement and also improve the billing process for business users.
+ * The resulting invoice is an XML file in UBL syntax.
+ *
+ * This generator is ONLY responsible for processing the data it gets and formatting it in a way that does not change anything about it.
+ * If adjustments to the data must be made prior to generation, then these should take place within the RenderInvoice service.
+ */
+export class XRechnungInvoiceGenerator {
+ private readonly languageCode: "de" | "en" = "en"
+ private readonly invoiceNumber: string
+ private readonly customerId: string
+ private readonly buyerMailAddress: string
+ private invoice: InvoiceDataGetOut
+ private itemIndex: number = 0
+ private discountItems: InvoiceDataItem[] = []
+ private totalDiscountSum: number = -1
+
+ constructor(invoice: InvoiceDataGetOut, invoiceNumber: string, customerId: string, buyerMailAddress: string) {
+ this.invoice = invoice
+ this.invoiceNumber = invoiceNumber
+ this.customerId = customerId
+ this.languageCode = countryUsesGerman(this.invoice.country)
+ this.buyerMailAddress = buyerMailAddress
+ }
+
+ /**
+ * Generate the XRechnung xml file
+ */
+ generate(): Uint8Array {
+ let stringTemplate =
+ `\n` +
+ (this.invoice.invoiceType === InvoiceType.INVOICE ? XRechnungUBLTemplate.RootInvoice : XRechnungUBLTemplate.RootCreditNote)
+ stringTemplate = stringTemplate
+ .replace("{slotMain}", XRechnungUBLTemplate.Main)
+ .replace("{slotInvoiceLines}", this.resolveInvoiceLines()) // Must run first to calculate potential discounts
+ .replace("{invoiceNumber}", this.invoiceNumber)
+ .replace("{issueDate}", formatDate(this.invoice.date))
+ .replace("{slotInvoiceType}", this.resolveInvoiceType())
+ .replace("{buyerId}", this.customerId)
+ .replace("{slotSeller}", XRechnungUBLTemplate.Seller)
+ .replace("{slotBuyer}", this.resolveBuyer())
+ .replace("{paymentMeansCode}", PaymentMethodTypeCodes[this.invoice.paymentMethod as PaymentMethod])
+ .replace("{slotPaymentTerms}", this.resolvePaymentTerms())
+ .replace("{slotAllowanceCharge}", this.resolveAllowanceCharge())
+ .replace("{slotTotalTax}", this.resolveTotalTax())
+ .replace("{slotDocumentTotals}", this.resolveDocumentsTotal())
+ .replaceAll(/^\t\t/gm, "")
+ return new TextEncoder().encode(stringTemplate)
+ }
+
+ /**
+ * Resolves the root of the xml depending on invoice type (billing invoice or credit)
+ * @private
+ */
+ private resolveInvoiceType(): string {
+ if (this.invoice.invoiceType === InvoiceType.INVOICE) {
+ return `380`
+ }
+ return `381`
+ }
+
+ /**
+ * Resolves placeholders concerning the buyer (customer)
+ * buyerMail - Electronic address of the customer
+ * buyerStreetName - despite its name, also includes the street number
+ * buyerCityName - self-explanatory
+ * buyerPostalZone - despite its name, only refers to the postal code, not any associated city
+ * buyerCountryCode - self-explanatory
+ * buyerName - Legal name / company name of the customer -> The first line of the address field
+ * @private
+ */
+ private resolveBuyer(): string {
+ const addressParts = this.invoice.address.split("\n")
+ return XRechnungUBLTemplate.Buyer.replace("{buyerMail}", this.buyerMailAddress)
+ .replace("{buyerStreetName}", addressParts[1] ?? "STREET NAME UNKNOWN")
+ .replace("{buyerCityName}", extractCityName(addressParts[2] ?? ""))
+ .replace("{buyerPostalZone}", extractPostalCode(addressParts[2] ?? ""))
+ .replace("{buyerCountryCode}", this.invoice.country)
+ .replace("{buyerAddressLine}", this.invoice.address.replaceAll("\n", " "))
+ .replace("{slotBuyerVatInfo}", this.resolveBuyerVatInfo())
+ .replace("{buyerName}", addressParts[0] ?? "BUYER NAME UNKNOWN")
+ }
+
+ /**
+ * Resolves tax info about the buyer (customer). Only resolved if the buyer has a vatIdNumber.
+ * buyerVatId - Customer's vatIdNumber
+ * @private
+ */
+ private resolveBuyerVatInfo(): string {
+ if (this.invoice.vatIdNumber != null) {
+ return XRechnungUBLTemplate.BuyerVatInfo.replace("{buyerVatId}", this.invoice.vatIdNumber)
+ }
+ return ""
+ }
+
+ /**
+ * Resolves the payment note, i.e. the instructions for the buyer
+ * These are the same texts below the summary table of a PDF invoice
+ * @private
+ */
+ private resolvePaymentNote(): string {
+ let paymentNote = ""
+ if (this.invoice.invoiceType === InvoiceType.INVOICE) {
+ switch (this.invoice.paymentMethod) {
+ case PaymentMethod.INVOICE:
+ paymentNote += `${InvoiceTexts[this.languageCode].paymentInvoiceDue1} ${InvoiceTexts[this.languageCode].paymentInvoiceDue2} ${
+ InvoiceTexts[this.languageCode].paymentInvoiceHolder
+ } ${InvoiceTexts[this.languageCode].paymentInvoiceBank} ${InvoiceTexts[this.languageCode].paymentInvoiceIBAN} ${
+ InvoiceTexts[this.languageCode].paymentInvoiceBIC
+ } ${InvoiceTexts[this.languageCode].paymentInvoiceProvideNumber1} ${this.invoiceNumber} ${
+ InvoiceTexts[this.languageCode].paymentInvoiceProvideNumber2
+ }`
+ break
+ case PaymentMethod.CREDIT_CARD:
+ paymentNote += `${InvoiceTexts[this.languageCode].paymentCreditCard}`
+ break
+ case PaymentMethod.PAYPAL:
+ paymentNote += `${InvoiceTexts[this.languageCode].paymentPaypal}`
+ break
+ case PaymentMethod.ACCOUNT_BALANCE:
+ paymentNote += `${InvoiceTexts[this.languageCode].paymentAccountBalance}`
+ break
+ }
+ paymentNote += " " + InvoiceTexts[this.languageCode].thankYou
+ }
+ return paymentNote
+ }
+
+ /**
+ * Resolves the payment terms (supplementary note to customer) if the invoice is a billing invoice
+ * @private
+ */
+ private resolvePaymentTerms(): string {
+ if (this.invoice.invoiceType === InvoiceType.INVOICE) {
+ // language=HTML
+ return `
+
+ ${this.resolvePaymentNote()}
+
+ `
+ }
+ return ""
+ }
+
+ /**
+ * Resolves all information about potential discounts
+ * totalDiscount - Inverted sum of all discount invoiceitems
+ * vatType - Standardized VAT category code
+ * vatPercent - Percentage of the vat applied. I.e. 19% -> vatPercent == 19
+ * taxableAmount - Amount that is subject to the tax. Usually this is the entire amount, so the subTotal
+ * vatAmount - The amount of the tax. This is equal to "taxableAmount * vatPercent"
+ * @private
+ */
+ private resolveAllowanceCharge(): string {
+ return XRechnungUBLTemplate.AllowanceCharge.replace("{totalDiscount}", this.calculateTotalDiscount().toFixed(2))
+ .replace("{vatType}", VatTypeCategoryCodes[this.invoice.vatType as VatType])
+ .replace("{vatPercent}", this.invoice.vatRate)
+ .replace("{slotTaxExemptionReason}", this.resolveTaxExemptionReason())
+ .replace("{taxableAmount}", this.getVatExcludedPrice(this.invoice.subTotal))
+ .replaceAll("{vatAmount}", this.invoice.vat)
+ }
+
+ /**
+ * Resolves the total tax slot: summarized information of all applied taxes (vat)
+ * vatType - Standardized VAT category code
+ * vatPercent - Percentage of the vat applied. I.e. 19% -> vatPercent == 19
+ * taxableAmount - Amount that is subject to the tax. Usually this is the entire amount, so the subTotal
+ * vatAmount - The amount of the tax. This is equal to "taxableAmount * vatPercent"
+ * @private
+ */
+ private resolveTotalTax(): string {
+ return XRechnungUBLTemplate.TaxTotal.replace("{vatType}", VatTypeCategoryCodes[this.invoice.vatType as VatType])
+ .replace("{vatPercent}", this.invoice.vatRate)
+ .replace("{slotTaxExemptionReason}", this.resolveTaxExemptionReason())
+ .replace("{taxableAmount}", this.getVatExcludedPrice(this.invoice.subTotal))
+ .replaceAll("{vatAmount}", this.invoice.vat)
+ }
+
+ /**
+ * Resolves the textual reason why taxes are exempt. Only resolved if the vat type is reverse-charge
+ * @private
+ */
+ private resolveTaxExemptionReason(): string {
+ if (this.invoice.vatType === VatType.NO_VAT || this.invoice.vatType === VatType.NO_VAT_REVERSE_CHARGE) {
+ return `Umkehrung der Steuerschuldnerschaft`
+ }
+ return ""
+ }
+
+ /**
+ * Resolves the document total slot: summarized information of the pricing
+ * sumOfInvoiceLines - The total amount of all invoice items summed up alongside their quantity (amount): subTotal
+ * invoiceExclusiveVat - The total amount of the entire invoice without vat: subTotal
+ * invoiceInclusiveVat - The total amount of the entire invoice with vat: grandTotal
+ * amountDueForPayment - The final amount the buyer is billed with: grandTotal
+ * @private
+ */
+ private resolveDocumentsTotal(): string {
+ return XRechnungUBLTemplate.DocumentTotals.replace(
+ "{sumOfInvoiceLines}",
+ this.getVatExcludedPrice((parseFloat(this.invoice.subTotal) + this.calculateTotalDiscount()).toFixed(2)),
+ )
+ .replace("{invoiceExclusiveVat}", this.getVatExcludedPrice(this.invoice.subTotal))
+ .replace("{invoiceInclusiveVat}", this.invoice.grandTotal)
+ .replace("{amountDueForPayment}", this.invoice.grandTotal)
+ .replace("{totalDiscount}", this.calculateTotalDiscount().toFixed(2))
+ }
+
+ /**
+ * Resolves all invoice items (invoiceLines) by iterating over every invoice item and resolving a list for it
+ * @private
+ */
+ private resolveInvoiceLines(): string {
+ let invoiceLines = ""
+ if (this.invoice.invoiceType === InvoiceType.INVOICE) {
+ for (const invoiceItem of this.invoice.items) {
+ invoiceLines += this.resolveInvoiceLine(invoiceItem)
+ }
+ } else {
+ for (const invoiceItem of this.invoice.items) {
+ invoiceLines += this.resolveCreditNoteLine(invoiceItem)
+ }
+ }
+ return invoiceLines
+ }
+
+ /**
+ * Resolves a singular invoice item (invoiceLine): information about one row in an invoice table
+ * invoiceLineQuantity - The amount (quantity) of the item in the invoice line, so the invoiceItem's amount
+ * invoiceLineTotal - The total price of the invoice line. This is equal to "itemPrice * quantity" == totalPrice
+ * invoiceLineStartDate - self-explanatory
+ * invoiceLineEndDate - self-explanatory
+ * invoiceLineItemName - self-explanatory
+ * invoiceLineItemVatType - Standardized Vat category code for this item. Equal to the vat type of the entire invoice
+ * invoiceLineItemVatPercent - Percentage of vat applied to this item. Equal to the vat percentage of the entire invoice
+ * invoiceLineItemPrice - Price of the singular item: singlePrice
+ * @param invoiceItem
+ * @private
+ */
+ private resolveInvoiceLine(invoiceItem: InvoiceDataItem): string {
+ this.itemIndex++
+ // If the invoice has a negative price it is some form of credit or discount.
+ // This is not the definition of an "invoice item" in the traditional sense, and therefore we treat it as a discount later applied to the whole invoice.
+ if (parseFloat(invoiceItem.totalPrice) < 0) {
+ this.discountItems.push(invoiceItem)
+ return ""
+ }
+ return XRechnungUBLTemplate.InvoiceLine.replace("{invoiceLineId}", this.itemIndex.toString())
+ .replace("{invoiceLineQuantity}", invoiceItem.amount)
+ .replace("{invoiceLineTotal}", this.getVatExcludedPrice(invoiceItem.totalPrice))
+ .replace("{invoiceLineStartDate}", formatDate(invoiceItem.startDate))
+ .replace("{invoiceLineEndDate}", formatDate(invoiceItem.endDate))
+ .replace("{invoiceLineItemName}", getInvoiceItemTypeName(invoiceItem.itemType, this.languageCode))
+ .replace("{invoiceLineItemVatType}", VatTypeCategoryCodes[this.invoice.vatType as VatType])
+ .replace("{invoiceLineItemVatPercent}", this.invoice.vatRate)
+ .replace("{invoiceLineItemPrice}", this.getVatExcludedPrice(getInvoiceItemPrice(invoiceItem)))
+ }
+
+ /**
+ * Same as resolveInvoiceLine but for CreditNotes
+ * @param invoiceItem
+ * @private
+ */
+ private resolveCreditNoteLine(invoiceItem: InvoiceDataItem): string {
+ this.itemIndex++
+ return XRechnungUBLTemplate.CreditNoteLine.replace("{invoiceLineId}", this.itemIndex.toString())
+ .replace("{invoiceLineQuantity}", invoiceItem.amount)
+ .replace("{invoiceLineTotal}", this.getVatExcludedPrice(invoiceItem.totalPrice))
+ .replace("{invoiceLineStartDate}", formatDate(invoiceItem.startDate))
+ .replace("{invoiceLineEndDate}", formatDate(invoiceItem.endDate))
+ .replace("{invoiceLineItemName}", getInvoiceItemTypeName(invoiceItem.itemType, this.languageCode))
+ .replace("{invoiceLineItemVatType}", VatTypeCategoryCodes[this.invoice.vatType as VatType])
+ .replace("{invoiceLineItemVatPercent}", this.invoice.vatRate)
+ .replace("{invoiceLineItemPrice}", this.getVatExcludedPrice(getInvoiceItemPrice(invoiceItem)))
+ }
+
+ /**
+ * Calculates the total discount applied to the entire invoice. The discount is a positive number that is to be subtracted from the invoice total
+ * The discount is calculated by iterating over every invoiceItem that is of type "discount" and adding it up.
+ * @private
+ */
+ private calculateTotalDiscount(): number {
+ if (this.totalDiscountSum !== -1) {
+ return this.totalDiscountSum
+ }
+ this.totalDiscountSum = 0
+ for (const discountItem of this.discountItems) {
+ this.totalDiscountSum += parseFloat(discountItem.totalPrice)
+ }
+ this.totalDiscountSum *= -1
+ return this.totalDiscountSum
+ }
+
+ /**
+ * Recalculates a price if the vat is already included. I.e. subtracts the applied vat
+ * Returns the price with vat excluded
+ * @param priceValue
+ * @private
+ */
+ private getVatExcludedPrice(priceValue: NumberString): NumberString {
+ switch (this.invoice.vatType) {
+ case VatType.VAT_INCLUDED_SHOWN:
+ case VatType.VAT_INCLUDED_HIDDEN:
+ const nPriceValue = parseFloat(priceValue)
+ const nVat = parseFloat(this.invoice.vat)
+ return (nPriceValue - nVat).toFixed(2)
+ default:
+ break
+ }
+ return priceValue
+ }
+}
+
+/**
+ * Formats a date to be of the pattern "yyyy-mm-dd"
+ * @param date
+ */
+function formatDate(date: Date | null): string {
+ if (date != null) {
+ return date.toISOString().split("T")[0]
+ }
+ return "No date given."
+}
+
+/**
+ * Returns the price of an invoice item.
+ * This is singlePrice if the amount of item is 1 or totalPrice if not.
+ * @param invoiceItem
+ */
+function getInvoiceItemPrice(invoiceItem: InvoiceDataItem): string {
+ if (invoiceItem.singlePrice != null) {
+ return invoiceItem.singlePrice
+ }
+ return invoiceItem.totalPrice
+}
+
+/**
+ * Naively tries to extract a German postal code.
+ * If this extraction fails, returns a string notifying the user to consult their full address line
+ * @param addressLine
+ */
+export function extractPostalCode(addressLine: string): string {
+ const match = addressLine.match(DE_POSTAL_CODE_REGEX)
+ if (match && match[0]) {
+ return match[0].trim()
+ }
+ return "Could not extract postal code. Please refer to full address line."
+}
+
+/**
+ * Naively tries to extract the city name from the third line.
+ * If this extraction fails, then we accept that the city field of the customer is filled incorrectly and must be manually changed by them
+ * @param addressLine
+ */
+export function extractCityName(addressLine: string): string {
+ const cityName = addressLine.replace(CITY_NAME_REGEX, "").replace(",", "").trim()
+ if (cityName === "") {
+ return "Could not extract city name. Please refer to full address line."
+ }
+ return cityName
+}
diff --git a/src/common/api/worker/invoicegen/XRechnungUBLTemplate.ts b/src/common/api/worker/invoicegen/XRechnungUBLTemplate.ts
new file mode 100644
index 000000000000..dc76a4f58e8c
--- /dev/null
+++ b/src/common/api/worker/invoicegen/XRechnungUBLTemplate.ts
@@ -0,0 +1,207 @@
+// WARNING: If you work with this, note that the ORDER in which elements appear can sometimes matter to the validator. (I.e. PostalAddress must come before PartyLegalEntity)
+// NOTE: Do not auto-format this file as it will delete XML root attribute (cac, cbc etc.)
+export default {
+ RootInvoice: `
+
+ urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0#conformant#urn:xeinkauf.de:kosit:extension:xrechnung_3.0
+ {slotMain}
+ `,
+
+ RootCreditNote: `
+
+ urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0
+ {slotMain}
+ `,
+
+ // language=HTML
+ Main: `
+ urn:fdc:peppol.eu:2017:poacc:billing:01:1.0
+ {invoiceNumber}
+ {issueDate}
+ {slotInvoiceType}
+ EUR
+ {buyerId}
+ {slotSeller}
+ {slotBuyer}
+
+ {paymentMeansCode}
+
+ DE67250800200138040001
+ Tutao GmbH
+
+ DRESDEFF250
+
+
+
+ {slotPaymentTerms}
+ {slotAllowanceCharge}
+ {slotTotalTax}
+ {slotDocumentTotals}
+ {slotInvoiceLines}`,
+
+ // language=XML
+ Seller: `
+
+
+ sales@tutao.de
+
+ Tutao GmbH
+
+
+ Deisterstraße 17a
+ Hannover
+ 30449
+
+ DE
+
+
+
+ DE280903265
+
+ VAT
+
+
+
+ Tutao GmbH
+ 208014
+
+
+ Tutao GmbH
+ +49 511202801-0
+ sales@tutao.de
+
+
+ `,
+
+ // language=XML
+ Buyer: `
+
+
+ {buyerMail}
+
+ {buyerStreetName}
+ {buyerCityName}
+ {buyerPostalZone}
+
+ {buyerAddressLine}
+
+
+ {buyerCountryCode}
+
+
+ {slotBuyerVatInfo}
+
+ {buyerName}
+
+
+ `,
+
+ // language=XML
+ BuyerVatInfo: `
+
+ {buyerVatId}
+
+ VAT
+
+ `,
+
+ // language=XML
+ AllowanceCharge: `
+
+ false
+ 95
+ {totalDiscount}
+
+ {vatType}
+ {vatPercent}
+ {slotTaxExemptionReason}
+
+ VAT
+
+
+
+ `,
+
+ // language=XML
+ TaxTotal: `
+
+ {vatAmount}
+
+ {taxableAmount}
+ {vatAmount}
+
+ {vatType}
+ {vatPercent}
+ {slotTaxExemptionReason}
+
+ VAT
+
+
+
+ `,
+
+ // language=XML
+ DocumentTotals: `
+
+ {sumOfInvoiceLines}
+ {invoiceExclusiveVat}
+ {invoiceInclusiveVat}
+ {totalDiscount}
+ {amountDueForPayment}
+ `,
+
+ // language=XML
+ InvoiceLine: `
+
+ {invoiceLineId}
+ {invoiceLineQuantity}
+ {invoiceLineTotal}
+
+ {invoiceLineStartDate}
+ {invoiceLineEndDate}
+
+
+ {invoiceLineItemName}
+
+ {invoiceLineItemVatType}
+ {invoiceLineItemVatPercent}
+
+ VAT
+
+
+
+
+ {invoiceLineItemPrice}
+
+ `,
+
+ // language=XML
+ CreditNoteLine: `
+
+ {invoiceLineId}
+ {invoiceLineQuantity}
+ {invoiceLineTotal}
+
+ {invoiceLineStartDate}
+ {invoiceLineEndDate}
+
+
+ {invoiceLineItemName}
+
+ {invoiceLineItemVatType}
+ {invoiceLineItemVatPercent}
+
+ VAT
+
+
+
+
+ {invoiceLineItemPrice}
+
+
+ `,
+}
diff --git a/src/common/api/worker/pdf/PdfConstants.ts b/src/common/api/worker/pdf/PdfConstants.ts
index 34c89d73e7ff..fb4afc66d10c 100644
--- a/src/common/api/worker/pdf/PdfConstants.ts
+++ b/src/common/api/worker/pdf/PdfConstants.ts
@@ -12,23 +12,27 @@ export type PdfDictValue = string | PdfObjectRef | PdfDictValue[] | Map width.toString())],
]),
}),
@@ -125,7 +129,7 @@ export const PDF_DEFAULT_OBJECTS = Object.freeze([
["BaseFont", "/SourceSans3-Bold"],
["Encoding", "/WinAnsiEncoding"],
["FirstChar", "32"],
- ["LastChar", "125"],
+ ["LastChar", "255"],
["Widths", boldFontWidths.map((width) => width.toString())],
]),
}),
@@ -174,3 +178,17 @@ export const PDF_DEFAULT_OBJECTS = Object.freeze([
]),
}),
])
+
+// language=XML
+export const PDF_METADATA = `
+
+
+ {slotCreateDate}
+ {slotModifyDate}
+ A
+ 1
+ Tuta PDF Generator
+
+`
diff --git a/src/common/api/worker/pdf/PdfDocument.ts b/src/common/api/worker/pdf/PdfDocument.ts
index ac5bca85127c..cca98d9c04d1 100644
--- a/src/common/api/worker/pdf/PdfDocument.ts
+++ b/src/common/api/worker/pdf/PdfDocument.ts
@@ -31,8 +31,7 @@ const ORIGIN_POSITION: [x: number, y: number] = [0, 0]
// Transform matrix to set origin point top-left
const TRANSFORM_MATRIX = `1 0 0 -1 0 ${mmToPSPoint(PAPER_HEIGHT)}`
// 1 InvoiceItem = 2 Table rows (first row item info, second row dates)
-// Amount of table rows that can fit on the first page
-const ROWS_FIRST_PAGE_SINGLE = 4 // 2 InvoiceItems
+// The amount of rows rendered on the first page is dynamically determined in the addTable() method
// Amount of table rows that can fit on the first page if a second is rendered too
const ROWS_FIRST_PAGE_MULTIPLE = 24 // 12 InvoiceItems
// Amount of table rows that can fit on any n-th page that isn't the first
@@ -51,13 +50,13 @@ const ADDRESS_FIELD_HEIGHT = 320
*/
export class PdfDocument {
private readonly pdfWriter: PdfWriter
+ private readonly deflater: Deflater
private pageCount: number = 0
private textStream: string = ""
private graphicsStream: string = ""
private currentFont: PDF_FONTS = PDF_FONTS.REGULAR
private currentFontSize: number = 12
private pageList: PdfObjectRef[] = []
- private deflater: Deflater
constructor(pdfWriter: PdfWriter) {
this.pdfWriter = pdfWriter
@@ -263,15 +262,25 @@ export class PdfDocument {
* @param tableWidth The width of the table
* @param columns Array of ColumnObjects, specifying the header name and width of each column in percent of the total tableWidth { headerName: string, columnWidth: number }
* @param data Two-dimensional array of strings, specifying the data for every row : [ //row1 [a,b,c] //row2 [x,y,z]... ]. The inner arrays (rows) must have the same length as the columns array!
+ * @param rowsOnFirstPage How many rows can fit on the first page. This is dynamically decided by the amount of text that should follow after the table
*/
- async addTable(position: [x: number, y: number], tableWidth: number, columns: TableColumn[], data: ReadonlyArray>): Promise {
+ async addTable(
+ position: [x: number, y: number],
+ tableWidth: number,
+ columns: TableColumn[],
+ data: ReadonlyArray>,
+ rowsOnFirstPage: number = 4,
+ ): Promise {
this.addTableHeader(position, tableWidth, columns)
// If all entries fit on the first page, then have "ITEMS_FIRST_PAGE_SINGLE" amount of entries, else "ROWS_FIRST_PAGE_MULTIPLE"
- const entriesOnFirstPage = data.length > ROWS_FIRST_PAGE_SINGLE ? ROWS_FIRST_PAGE_MULTIPLE : ROWS_FIRST_PAGE_SINGLE
+ const entriesOnFirstPage = data.length > rowsOnFirstPage ? ROWS_FIRST_PAGE_MULTIPLE : rowsOnFirstPage
// Render the first page, save the height of the table
let tableHeight = this.addTablePage(position, tableWidth, columns, data.slice(0, entriesOnFirstPage))
let entryCounter = entriesOnFirstPage
+ // only two fit on first page to then have enough space to render the BIGGEST, we have three so we new page
+ // BIGGEST is German or Enligsh (its close) invoice + not vat + vatid
+
// Keep writing pages of entries until all data is exhausted
while (entryCounter < data.length) {
await this.addPage()
@@ -282,6 +291,7 @@ export class PdfDocument {
const lastPageCannotFitRemainingRows = (entryCounter - entriesOnFirstPage) % ROWS_N_PAGE <= ROWS_FIRST_PAGE_MULTIPLE
const insufficientSpaceBelowTable = entryCounter == ROWS_FIRST_PAGE_MULTIPLE
+
if (!lastPageCannotFitRemainingRows || insufficientSpaceBelowTable) {
await this.addPage()
tableHeight = MARGIN_TOP
diff --git a/src/common/api/worker/pdf/PdfWriter.ts b/src/common/api/worker/pdf/PdfWriter.ts
index a7de93a2bb61..713766a9f4ef 100644
--- a/src/common/api/worker/pdf/PdfWriter.ts
+++ b/src/common/api/worker/pdf/PdfWriter.ts
@@ -1,5 +1,5 @@
import { PdfObject } from "./PdfObject.js"
-import { GENERATION_NUMBER, NEW_LINE, PDF_DEFAULT_OBJECTS, PdfDictValue, PdfObjectRef, PdfStreamEncoding } from "./PdfConstants.js"
+import { GENERATION_NUMBER, NEW_LINE, PDF_DEFAULT_OBJECTS, PDF_METADATA, PdfDictValue, PdfObjectRef, PdfStreamEncoding } from "./PdfConstants.js"
import { PdfStreamObject } from "./PdfStreamObject.js"
import { concat, hexToUint8Array } from "@tutao/tutanota-utils"
import { Deflater } from "./Deflater.js"
@@ -18,11 +18,11 @@ type GlobalFetch = typeof global.fetch
*/
export class PdfWriter {
private readonly textEncoder: TextEncoder
+ private readonly customFetch: GlobalFetch | undefined
+ private readonly deflater: Deflater
private byteLengthPosition = PDF_HEADER.byteLength
private pdfObjectList: PdfObject[] = []
private referenceTable: Map = new Map()
- private customFetch: GlobalFetch | undefined
- private deflater: Deflater
private cachedResources: ArrayBuffer[] | undefined
constructor(textEncoder: TextEncoder, customFetch: GlobalFetch | undefined) {
@@ -191,21 +191,15 @@ export class PdfWriter {
const baseUrl = typeof location === "undefined" ? "" : location.protocol + "//" + location.hostname + (location.port ? ":" + location.port : "")
if (!this.cachedResources) {
this.cachedResources = await Promise.all(
- [
- "/pdf/SourceSans3-Regular.ttf",
- "/pdf/SourceSans3-Bold.ttf",
- "/pdf/sRGB2014.icc",
- "/pdf/identity_h.cmap",
- "/pdf/tutanota_logo_en.jpg",
- "/pdf/metadata.xml",
- ].map((url) =>
- typeof this.customFetch !== "undefined"
- ? this.customFetch(baseUrl + url).then((r) => r.arrayBuffer())
- : fetch(baseUrl + url).then((r) => r.arrayBuffer()),
+ ["/pdf/SourceSans3-Regular.ttf", "/pdf/SourceSans3-Bold.ttf", "/pdf/sRGB2014.icc", "/pdf/identity_h.cmap", "/pdf/tutanota_logo_en.jpg"].map(
+ (url) =>
+ typeof this.customFetch !== "undefined"
+ ? this.customFetch(baseUrl + url).then((r) => r.arrayBuffer())
+ : fetch(baseUrl + url).then((r) => r.arrayBuffer()),
),
)
}
- const [fontRegular, fontBold, colorProfile, cmap, tutaImage, metaData] = this.cachedResources
+ const [fontRegular, fontBold, colorProfile, cmap, tutaImage] = this.cachedResources
// Regular font file
this.createStreamObject(
@@ -258,12 +252,14 @@ export class PdfWriter {
"IMG_TUTA_LOGO",
)
// Metadata
+ const todayDate = new Date()
+ const metaData = PDF_METADATA.replace("{slotCreateDate}", todayDate.toISOString()).replace("{slotModifyDate}", todayDate.toISOString())
this.createStreamObject(
new Map([
["Type", "/Metadata"],
["Subtype", "/XML"],
]),
- new Uint8Array(metaData),
+ new Uint8Array(this.textEncoder.encode(metaData)),
PdfStreamEncoding.NONE,
"METADATA",
)
diff --git a/src/common/subscription/PaymentViewer.ts b/src/common/subscription/PaymentViewer.ts
index 913d61be410c..0482000936bb 100644
--- a/src/common/subscription/PaymentViewer.ts
+++ b/src/common/subscription/PaymentViewer.ts
@@ -55,6 +55,7 @@ import type { UpdatableSettingsViewer } from "../settings/Interfaces.js"
import { ProgrammingError } from "../api/common/error/ProgrammingError.js"
import { showSwitchDialog } from "./SwitchSubscriptionDialog.js"
import { GENERATED_MAX_ID } from "../api/common/utils/EntityUtils.js"
+import { createDropdown } from "../gui/base/Dropdown.js"
assertMainOrNode()
@@ -315,13 +316,31 @@ export class PaymentViewer implements UpdatableSettingsViewer {
title: "download_action",
icon: Icons.Download,
size: ButtonSize.Compact,
- click: () => this.doInvoiceDownload(posting),
+ click: (e, dom) => {
+ if (this.customer?.businessUse) {
+ createDropdown({
+ width: 300,
+ lazyButtons: () => [
+ {
+ label: "downloadInvoicePdf_action",
+ click: () => this.doPdfInvoiceDownload(posting),
+ },
+ {
+ label: "downloadInvoiceXml_action",
+ click: () => this.doXrechnungInvoiceDownload(posting),
+ },
+ ],
+ })(e, dom)
+ } else {
+ this.doPdfInvoiceDownload(posting)
+ }
+ },
}
: null,
}
}
- private async doInvoiceDownload(posting: CustomerAccountPosting): Promise {
+ private async doPdfInvoiceDownload(posting: CustomerAccountPosting): Promise {
if (client.compressionStreamSupported()) {
return showProgressDialog("pleaseWait_msg", locator.customerFacade.generatePdfInvoice(neverNull(posting.invoiceNumber))).then((pdfInvoice) =>
locator.fileController.saveDataFile(pdfInvoice),
@@ -337,6 +356,13 @@ export class PaymentViewer implements UpdatableSettingsViewer {
}
}
+ private async doXrechnungInvoiceDownload(posting: CustomerAccountPosting) {
+ return showProgressDialog(
+ "pleaseWait_msg",
+ locator.customerFacade.generateXRechnungInvoice(neverNull(posting.invoiceNumber)).then((xInvoice) => locator.fileController.saveDataFile(xInvoice)),
+ )
+ }
+
private updateAccountingInfoData(accountingInfo: AccountingInfo) {
this.accountingInfo = accountingInfo
diff --git a/test/tests/Suite.ts b/test/tests/Suite.ts
index d3a7a1cf84cd..a8960563957f 100644
--- a/test/tests/Suite.ts
+++ b/test/tests/Suite.ts
@@ -125,6 +125,7 @@ import "./api/worker/pdf/PdfWriterTest.js"
import "./api/worker/pdf/PdfObjectTest.js"
import "./api/worker/pdf/PdfDocumentTest.js"
import "./api/worker/invoicegen/PdfInvoiceGeneratorTest.js"
+import "./api/worker/invoicegen/XRechnungInvoiceGeneratorTest.js"
import "./subscription/SignupFormTest.js"
import "./api/worker/facades/ContactFacadeTest.js"
import "./api/worker/facades/KeyRotationFacadeTest.js"
diff --git a/test/tests/api/worker/invoicegen/PdfInvoiceGeneratorTest.ts b/test/tests/api/worker/invoicegen/PdfInvoiceGeneratorTest.ts
index 63e0c197fed1..1f3a6e89ef65 100644
--- a/test/tests/api/worker/invoicegen/PdfInvoiceGeneratorTest.ts
+++ b/test/tests/api/worker/invoicegen/PdfInvoiceGeneratorTest.ts
@@ -1,21 +1,11 @@
import o from "@tutao/otest"
import { PdfWriter } from "../../../../../src/common/api/worker/pdf/PdfWriter.js"
import { createTestEntity } from "../../../TestUtils.js"
-import { InvoiceDataGetOutTypeRef, InvoiceDataItemTypeRef } from "../../../../../src/common/api/entities/sys/TypeRefs.js"
+import { InvoiceDataGetOutTypeRef } from "../../../../../src/common/api/entities/sys/TypeRefs.js"
import { PdfInvoiceGenerator } from "../../../../../src/common/api/worker/invoicegen/PdfInvoiceGenerator.js"
import { object, when } from "testdouble"
-
-async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise {
- if (globalThis.isBrowser) {
- return fetch("./resources/pdf/" + input.toString())
- } else {
- const [fs, path] = await Promise.all([import("node:fs"), import("node:path")])
- const resourceFile = path.normalize(process.cwd() + "/../resources" + input.toString())
- const response: Response = object()
- when(response.arrayBuffer()).thenResolve(fs.readFileSync(resourceFile))
- return response
- }
-}
+import { invoiceItemListMock } from "./invoiceTestUtils.js"
+import { PaymentMethod, VatType } from "../../../../../src/common/api/worker/invoicegen/InvoiceUtils.js"
o.spec("PdfInvoiceGenerator", function () {
let pdfWriter: PdfWriter
@@ -23,55 +13,73 @@ o.spec("PdfInvoiceGenerator", function () {
pdfWriter = new PdfWriter(new TextEncoder(), fetchStub)
})
- o("Gen", async function () {
+ o("pdf generation for japanese invoice addVat 3_items", async function () {
const invoiceData = createTestEntity(InvoiceDataGetOutTypeRef, {
- address: "竜宮レナ\n白川村\n日本国",
+ address: "竜宮 礼奈\n荻町, 411,\n〒501-5627 Shirakawa, Ono-Gun, Gifu, Japan",
country: "JP",
- items: dataMock(2),
+ subTotal: "28.00",
+ grandTotal: "28.00",
+ vatType: VatType.ADD_VAT,
+ vatIdNumber: "JP999999999",
+ paymentMethod: PaymentMethod.INVOICE,
+ items: invoiceItemListMock(3),
})
const gen = new PdfInvoiceGenerator(pdfWriter, invoiceData, "1978197819801981931", "NiiNii")
const pdf = await gen.generate()
- // fs.writeFileSync("/tmp/full_test.pdf", pdf, {flag:"w"})
+ //fs.writeFileSync("/tmp/tuta_jp_invoice_noVat_3.pdf", pdf, { flag: "w" })
})
- o("Entries fit all on a single page but generate a new empty page", async function () {
+ o("pdf generation for russian invoice vatReverseCharge 4_items", async function () {
const renderInvoice = createTestEntity(InvoiceDataGetOutTypeRef, {
- address: "Altschauerberg 8\n91448 Emskirchen\nDeutschland",
- country: "DE",
- items: dataMock(15),
+ address: "CompanyRU\n194352, Санкт-Петербург\nСиреневый бульвар, д. 8, корп. 2, лит. А.",
+ country: "RU",
+ items: invoiceItemListMock(2),
+ vatType: "4",
+ vat: "0",
+ vatRate: "0",
+ vatIdNumber: "1111_2222_3333_4444",
})
const gen = new PdfInvoiceGenerator(pdfWriter, renderInvoice, "1978197819801981931", "NiiNii")
const pdf = await gen.generate()
- // fs.writeFileSync("/tmp/normal_test.pdf", pdf, {flag:"w"})
+ //fs.writeFileSync("/tmp/tuta_ru_invoice_vatReverse_4.pdf", pdf, { flag: "w" })
})
- o("VatId number is generated", async function () {
+ o("pdf rendering with 100 entries", async function () {
+ const invoiceData = createTestEntity(InvoiceDataGetOutTypeRef, {
+ address: "Marcel Davis",
+ country: "DE",
+ vatRate: "19",
+ vatType: "1",
+ vat: "1",
+ items: invoiceItemListMock(100),
+ })
+
+ const gen = new PdfInvoiceGenerator(pdfWriter, invoiceData, "1978197819801981931", "NiiNii")
+ const pdf = await gen.generate()
+ //fs.writeFileSync("/tmp/tuta_100_entries.pdf", pdf, { flag: "w" })
+ })
+
+ o("pdf rendering with max entries to be put on first page", async function () {
const renderInvoice = createTestEntity(InvoiceDataGetOutTypeRef, {
- address: "BelgianStreet 5\n12345 Zellig\nBelgium",
- country: "BE",
- items: dataMock(15),
- vatType: "4",
- vatIdNumber: "1111_2222_3333_4444",
+ address: "Peter Lustig",
+ country: "DE",
+ items: invoiceItemListMock(13),
})
const gen = new PdfInvoiceGenerator(pdfWriter, renderInvoice, "1978197819801981931", "NiiNii")
const pdf = await gen.generate()
+ //fs.writeFileSync("/tmp/tuta_max_single_page_test.pdf", pdf, { flag: "w" })
})
})
-function dataMock(amount: number) {
- const data: any = []
- for (let i = 0; i < amount; i++) {
- data.push(
- createTestEntity(InvoiceDataItemTypeRef, {
- amount: "1",
- endDate: new Date("09.09.1984"),
- singlePrice: "14.40",
- startDate: new Date("09.09.1984"),
- totalPrice: "14.40",
- itemType: "25",
- }),
- )
+async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise {
+ if (globalThis.isBrowser) {
+ return fetch("./resources/pdf/" + input.toString())
+ } else {
+ const [fs, path] = await Promise.all([import("node:fs"), import("node:path")])
+ const resourceFile = path.normalize(process.cwd() + "/../resources" + input.toString())
+ const response: Response = object()
+ when(response.arrayBuffer()).thenResolve(fs.readFileSync(resourceFile))
+ return response
}
- return data
}
diff --git a/test/tests/api/worker/invoicegen/XRechnungInvoiceGeneratorTest.ts b/test/tests/api/worker/invoicegen/XRechnungInvoiceGeneratorTest.ts
new file mode 100644
index 000000000000..e44e6352e69d
--- /dev/null
+++ b/test/tests/api/worker/invoicegen/XRechnungInvoiceGeneratorTest.ts
@@ -0,0 +1,287 @@
+import o from "@tutao/otest"
+import { createTestEntity } from "../../../TestUtils.js"
+import { InvoiceDataGetOutTypeRef, InvoiceDataItemTypeRef } from "../../../../../src/common/api/entities/sys/TypeRefs.js"
+import { extractCityName, extractPostalCode, XRechnungInvoiceGenerator } from "../../../../../src/common/api/worker/invoicegen/XRechnungInvoiceGenerator.js"
+
+import { InvoiceItemType, InvoiceType, PaymentMethod, VatType } from "../../../../../src/common/api/worker/invoicegen/InvoiceUtils.js"
+
+o.spec("XRechnungInvoiceGenerator", function () {
+ o("xrechnung generation for japanese invoice noVat 2_items", async function () {
+ const invoiceData = createTestEntity(InvoiceDataGetOutTypeRef, {
+ address: "竜宮 礼奈\n荻町, 411,\n〒501-5627 Shirakawa, Ono-Gun, Gifu, Japan",
+ country: "JP",
+ subTotal: "20.00",
+ grandTotal: "20.00",
+ vatType: VatType.NO_VAT,
+ paymentMethod: PaymentMethod.INVOICE,
+ items: [
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: `1`,
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "10.00",
+ totalPrice: "10.00",
+ itemType: "25",
+ }),
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: `1`,
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "10.00",
+ totalPrice: "10.00",
+ itemType: "25",
+ }),
+ ],
+ })
+ const gen = new XRechnungInvoiceGenerator(invoiceData, "1978197819801981931", "MyCustomerId", "test@tutao.de")
+ const xml = gen.generate()
+ //fs.writeFileSync("/tmp/xtuta_jp_invoice_noVat_2.xml", xml, { flag: "w" })
+ })
+
+ o("xrechnung generation for german paypal addVat 3_items", async function () {
+ const invoiceData = createTestEntity(InvoiceDataGetOutTypeRef, {
+ address: "Bernd Brot\nNeuschauerberg 56\n91488 Emskirchen",
+ country: "DE",
+ subTotal: "60.00",
+ grandTotal: "71.40",
+ vatRate: "19",
+ vat: "11.40",
+ vatIdNumber: "DE12345678912345678912",
+ vatType: VatType.ADD_VAT,
+ paymentMethod: PaymentMethod.PAYPAL,
+ items: [
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "4",
+ startDate: new Date("11.11.1999"),
+ endDate: new Date("12.31.2000"),
+ singlePrice: "10.00",
+ totalPrice: "40.00",
+ itemType: "21",
+ }),
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "2",
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "5.00",
+ totalPrice: "10.00",
+ itemType: "9",
+ }),
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "1",
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "10.00",
+ totalPrice: "10.00",
+ itemType: "12",
+ }),
+ ],
+ })
+ const gen = new XRechnungInvoiceGenerator(invoiceData, "1978197819801981931", "MyCustomerId", "test@tutao.de")
+ const xml = gen.generate()
+ //fs.writeFileSync("/tmp/xtuta_de_paypal_addVat_3.xml", xml, { flag: "w" })
+ })
+
+ o("xrechnung generation for german accountBalance vatIncludedHidden 1_items", async function () {
+ const invoiceData = createTestEntity(InvoiceDataGetOutTypeRef, {
+ address: "Klappriger Klabautermann\nMariendorfer Hof 971\n12107 Berlin",
+ country: "DE",
+ subTotal: "36.00",
+ grandTotal: "36.00",
+ vatRate: "19",
+ vat: "5.75",
+ vatIdNumber: "DE12345678912345678912",
+ vatType: VatType.VAT_INCLUDED_HIDDEN,
+ paymentMethod: PaymentMethod.ACCOUNT_BALANCE,
+ items: [
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "1",
+ startDate: new Date("11.11.1999"),
+ endDate: new Date("12.31.2000"),
+ singlePrice: "36.00",
+ totalPrice: "36.00",
+ itemType: "21",
+ }),
+ ],
+ })
+ const gen = new XRechnungInvoiceGenerator(invoiceData, "1978197819801981931", "MyCustomerId", "test@tutao.de")
+ const xml = gen.generate()
+ //fs.writeFileSync("/tmp/xtuta_de_accountBalance_includedVat_2.xml", xml, { flag: "w" })
+ })
+
+ o("xrechnung generation for russia noVatReverse creditCard addVat 1_items", async function () {
+ const invoiceData = createTestEntity(InvoiceDataGetOutTypeRef, {
+ address: "CompanyRU\n194352, Санкт-Петербург\nСиреневый бульвар, д. 8, корп. 2, лит. А.",
+ country: "RU",
+ subTotal: "30.00",
+ grandTotal: "30.00",
+ vatType: VatType.NO_VAT_REVERSE_CHARGE,
+ vatRate: "0",
+ vat: "0",
+ vatIdNumber: "RU1234567891",
+ paymentMethod: PaymentMethod.CREDIT_CARD,
+ items: [
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "3",
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "10.00",
+ totalPrice: "30.00",
+ itemType: "12",
+ }),
+ ],
+ })
+ const gen = new XRechnungInvoiceGenerator(invoiceData, "1978197819801981931", "MyCustomerId", "test@tutao.de")
+ const xml = gen.generate()
+ //fs.writeFileSync("/tmp/xtuta_ru_creditCard_noVatReverseCharge_3.xml", xml, { flag: "w" })
+ })
+
+ o("xrechnung generation for credit note", async function () {
+ const invoiceData = createTestEntity(InvoiceDataGetOutTypeRef, {
+ address: "Malte Kieselstein\nLudwigstraße 6\nHanau-Steinheim",
+ invoiceType: InvoiceType.CREDIT,
+ country: "DE",
+ subTotal: "14.40",
+ grandTotal: "17.14",
+ vatType: VatType.ADD_VAT,
+ vatRate: "19",
+ vat: "2.74",
+ vatIdNumber: "DE12345678912345678912",
+ paymentMethod: PaymentMethod.ACCOUNT_BALANCE,
+ items: [
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "1",
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "14.40",
+ totalPrice: "14.40",
+ itemType: InvoiceItemType.Credit,
+ }),
+ ],
+ })
+ const gen = new XRechnungInvoiceGenerator(invoiceData, "1978197819801981931", "MyCustomerId", "test@tutao.de")
+ const xml = gen.generate()
+ //fs.writeFileSync("/tmp/xtuta_credit.xml", xml, { flag: "w" })
+ })
+
+ o("xrechnung generation for discount", async function () {
+ const invoiceData = createTestEntity(InvoiceDataGetOutTypeRef, {
+ address: "يورك هاوس، شييت ستريت،\n" + "وندسور SL4 1DD، المملكة المتحدة،",
+ invoiceType: InvoiceType.INVOICE,
+ country: "AE",
+ subTotal: "30.00",
+ grandTotal: "33.00",
+ vatType: VatType.ADD_VAT,
+ vatRate: "10",
+ vat: "3.00",
+ vatIdNumber: "AE12345678912345678912",
+ paymentMethod: PaymentMethod.ACCOUNT_BALANCE,
+ items: [
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "1",
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "30.00",
+ totalPrice: "30.00",
+ itemType: InvoiceItemType.LegendAccount,
+ }),
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "3",
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "10.00",
+ totalPrice: "30.00",
+ itemType: InvoiceItemType.WhitelabelChild,
+ }),
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "1",
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "-30.00",
+ totalPrice: "-30.00",
+ itemType: InvoiceItemType.Discount,
+ }),
+ ],
+ })
+ const gen = new XRechnungInvoiceGenerator(invoiceData, "1978197819801981931", "MyCustomerId", "test@tutao.de")
+ const xml = gen.generate()
+ //fs.writeFileSync("/tmp/xtuta_discount.xml", xml, { flag: "w" })
+ })
+
+ o("xrechnung generation for multi discount", async function () {
+ const invoiceData = createTestEntity(InvoiceDataGetOutTypeRef, {
+ address: "Jarl Balgruuf\nHolywood BT18 0AA",
+ invoiceType: InvoiceType.INVOICE,
+ country: "DE",
+ subTotal: "8.30",
+ grandTotal: "8.30",
+ vatType: VatType.VAT_INCLUDED_SHOWN,
+ vatRate: "19",
+ vat: "1.27",
+ vatIdNumber: "DE12345678912345678912",
+ paymentMethod: PaymentMethod.ACCOUNT_BALANCE,
+ items: [
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "1",
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "20.30",
+ totalPrice: "20.30",
+ itemType: InvoiceItemType.Credit,
+ }),
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "1",
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "-10.50",
+ totalPrice: "-10.50",
+ itemType: InvoiceItemType.Discount,
+ }),
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: "1",
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "-1.50",
+ totalPrice: "-1.50",
+ itemType: InvoiceItemType.Discount,
+ }),
+ ],
+ })
+ const gen = new XRechnungInvoiceGenerator(invoiceData, "1978197819801981931", "MyCustomerId", "test@tutao.de")
+ const xml = gen.generate()
+ //fs.writeFileSync("/tmp/xtuta_multi_discount.xml", xml, { flag: "w" })
+ })
+
+ o("extractPostalCode", function () {
+ const line1 = "Berlin, 12107"
+ o(extractPostalCode(line1)).equals("12107")
+
+ const line2 = "Neustadt a.d. Aisch 91413"
+ o(extractPostalCode(line2)).equals("91413")
+
+ const line3 = "94188 Emskirchen"
+ o(extractPostalCode(line3)).equals("94188")
+
+ const line4 = "Holywood BT18 0AA"
+ o(extractPostalCode(line4)).equals("Could not extract postal code. Please refer to full address line.")
+
+ const line5 = "〒501-5627 Shirakawa, Ono-Gun, Gifu, Japan"
+ o(extractPostalCode(line5)).equals("Could not extract postal code. Please refer to full address line.")
+ })
+
+ o("extractCityName", function () {
+ const line1 = "Berlin, 12107"
+ o(extractCityName(line1).includes("Berlin")).equals(true)
+
+ const line2 = "Neustadt a.d. Aisch 91413"
+ o(extractCityName(line2).includes("Neustadt a.d. Aisch")).equals(true)
+
+ const line3 = "94188 Emskirchen"
+ o(extractCityName(line3).includes("Emskirchen")).equals(true)
+
+ const line4 = "Holywood BT18 0AA"
+ o(extractCityName(line4).includes("Holywood")).equals(true)
+
+ const line5 = "〒501-5627 Shirakawa, Ono-Gun, Gifu, Japan"
+ o(extractCityName(line5).includes("Shirakawa")).equals(true)
+ })
+})
diff --git a/test/tests/api/worker/invoicegen/invoiceTestUtils.ts b/test/tests/api/worker/invoicegen/invoiceTestUtils.ts
new file mode 100644
index 000000000000..8d46bc5d333b
--- /dev/null
+++ b/test/tests/api/worker/invoicegen/invoiceTestUtils.ts
@@ -0,0 +1,23 @@
+import { createTestEntity } from "../../../TestUtils.js"
+import { InvoiceDataItemTypeRef } from "../../../../../src/common/api/entities/sys/TypeRefs.js"
+
+/**
+ * Produces a bulk list of mocked InvoiceItems
+ * @param amountOfEntries
+ */
+export function invoiceItemListMock(amountOfEntries: number) {
+ const data: any = []
+ for (let i = 0; i < amountOfEntries; i++) {
+ data.push(
+ createTestEntity(InvoiceDataItemTypeRef, {
+ amount: `${i}`,
+ startDate: new Date("09.09.1984"),
+ endDate: new Date("09.09.1984"),
+ singlePrice: "10.00",
+ totalPrice: "10.00",
+ itemType: "25",
+ }),
+ )
+ }
+ return data
+}