From 89224c05b6930f16ab3274fa529f0a9072b0a2ed Mon Sep 17 00:00:00 2001 From: jop Date: Mon, 11 Nov 2024 16:25:51 +0100 Subject: [PATCH] Implement xrechnung invoice generation - add ability to print xrechnung invoice for business customers - xrechnung is generated via own generator class - generator class utilizes xml templates that are replaced with data - improved test cases for pdf and xml invoice gen - minor improvements and fixes to pdf gen Co-authored-by: kibibytium <104761667+kibibytium@users.noreply.github.com> close #3357 --- resources/pdf/metadata.xml | 10 - .../api/worker/facades/lazy/CustomerFacade.ts | 17 + .../api/worker/invoicegen/InvoiceUtils.ts | 123 ++++++ .../worker/invoicegen/PdfInvoiceGenerator.ts | 150 ++----- .../invoicegen/XRechnungInvoiceGenerator.ts | 387 ++++++++++++++++++ .../worker/invoicegen/XRechnungUBLTemplate.ts | 207 ++++++++++ src/common/api/worker/pdf/PdfConstants.ts | 36 +- src/common/api/worker/pdf/PdfDocument.ts | 20 +- src/common/api/worker/pdf/PdfWriter.ts | 28 +- src/common/misc/TranslationKey.ts | 2 + src/common/subscription/PaymentViewer.ts | 32 +- src/mail-app/translations/en.ts | 4 +- src/mail-app/translations/uk.ts | 3 +- test/tests/Suite.ts | 1 + .../invoicegen/PdfInvoiceGeneratorTest.ts | 93 +++-- .../XRechnungInvoiceGeneratorTest.ts | 287 +++++++++++++ .../api/worker/invoicegen/invoiceTestUtils.ts | 23 ++ 17 files changed, 1214 insertions(+), 209 deletions(-) delete mode 100644 resources/pdf/metadata.xml create mode 100644 src/common/api/worker/invoicegen/InvoiceUtils.ts create mode 100644 src/common/api/worker/invoicegen/XRechnungInvoiceGenerator.ts create mode 100644 src/common/api/worker/invoicegen/XRechnungUBLTemplate.ts create mode 100644 test/tests/api/worker/invoicegen/XRechnungInvoiceGeneratorTest.ts create mode 100644 test/tests/api/worker/invoicegen/invoiceTestUtils.ts 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 2f9aed37f47b..5de14062b5d9 100644 --- a/src/common/api/worker/facades/lazy/CustomerFacade.ts +++ b/src/common/api/worker/facades/lazy/CustomerFacade.ts @@ -23,6 +23,7 @@ import { createMembershipRemoveData, createPaymentDataServicePutData, CustomDomainReturn, + Customer, CustomerInfoTypeRef, CustomerServerProperties, CustomerServerPropertiesTypeRef, @@ -70,6 +71,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" assertWorkerOrNode() @@ -463,6 +465,21 @@ export class CustomerFacade { } } + async generateXRechnungInvoice(invoiceNumber: string, customer: Customer, accountingInfo: AccountingInfo): Promise { + const customerInfo = await this.entityClient.load(CustomerInfoTypeRef, customer.customerInfo) + const invoiceData = await this.serviceExecutor.get(InvoiceDataService, createInvoiceDataGetIn({ invoiceNumber })) + 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/misc/TranslationKey.ts b/src/common/misc/TranslationKey.ts index 4792146aca3b..ac59ec3cdf00 100644 --- a/src/common/misc/TranslationKey.ts +++ b/src/common/misc/TranslationKey.ts @@ -492,6 +492,8 @@ export type TranslationKeyType = | "done_action" | "doNotAskAgain_label" | "downloadCompleted_msg" + | "downloadInvoicePdf_action" + | "downloadInvoiceXml_action" | "download_action" | "draftNotSavedConnectionLost_msg" | "draftNotSaved_msg" diff --git a/src/common/subscription/PaymentViewer.ts b/src/common/subscription/PaymentViewer.ts index 7cf1aa6a4a12..a52d0e5256fa 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() @@ -320,13 +321,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), @@ -342,6 +361,15 @@ export class PaymentViewer implements UpdatableSettingsViewer { } } + private async doXrechnungInvoiceDownload(posting: CustomerAccountPosting) { + return showProgressDialog( + "pleaseWait_msg", + locator.customerFacade + .generateXRechnungInvoice(neverNull(posting.invoiceNumber), neverNull(this.customer), neverNull(this.accountingInfo)) + .then((xInvoice) => locator.fileController.saveDataFile(xInvoice)), + ) + } + private updateAccountingInfoData(accountingInfo: AccountingInfo) { this.accountingInfo = accountingInfo diff --git a/src/mail-app/translations/en.ts b/src/mail-app/translations/en.ts index 7c3814b646a6..3c824d79dfb8 100644 --- a/src/mail-app/translations/en.ts +++ b/src/mail-app/translations/en.ts @@ -13,7 +13,7 @@ export default { "other" ], "created_at": "2015-01-13T20:10:13Z", - "updated_at": "2024-11-18T14:39:11Z", + "updated_at": "2024-11-20T15:29:03Z", "source_locale": null, "fallback_locale": null, "keys": { @@ -509,6 +509,8 @@ export default { "done_action": "Done", "doNotAskAgain_label": "Don't ask again for this file", "downloadCompleted_msg": "Download completed", + "downloadInvoicePdf_action": "PDF (PDF/A)", + "downloadInvoiceXml_action": "XML (XRechnung)", "download_action": "Download", "draftNotSavedConnectionLost_msg": "Draft not saved (offline).", "draftNotSaved_msg": "Draft not saved.", diff --git a/src/mail-app/translations/uk.ts b/src/mail-app/translations/uk.ts index 0864fd9288ce..ffa30c8a647e 100644 --- a/src/mail-app/translations/uk.ts +++ b/src/mail-app/translations/uk.ts @@ -15,7 +15,7 @@ export default { "other" ], "created_at": "2015-11-02T11:29:23Z", - "updated_at": "2024-11-13T18:36:11Z", + "updated_at": "2024-11-20T12:10:27Z", "source_locale": null, "fallback_locale": null, "keys": { @@ -809,6 +809,7 @@ export default { "keywords_label": "Ключові слова", "knowledgebase_label": "База знань", "knownCredentials_label": "Збережені облікові записи", + "labelLimitExceeded_msg": "До безплатного тарифного плану включено лише 3 позначки. Для пониження тарифу видаліть позначки.", "labels_label": "Позначки", "languageAfrikaans_label": "Африкаанс", "languageAlbanianref_label": "Албанська (реформована)", diff --git a/test/tests/Suite.ts b/test/tests/Suite.ts index 90f4838283c6..56d25cd915d8 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..1e1ef815ce1b 100644 --- a/test/tests/api/worker/invoicegen/PdfInvoiceGeneratorTest.ts +++ b/test/tests/api/worker/invoicegen/PdfInvoiceGeneratorTest.ts @@ -1,21 +1,12 @@ 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 fs from "fs" +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 +14,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..68f5ce7691b1 --- /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 fs from "fs" +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 +}