From ef3374f9c4215c606078d8824382a048faede97f Mon Sep 17 00:00:00 2001
From: yorrd <kai@adornis.de>
Date: Thu, 13 Feb 2025 20:47:09 +0000
Subject: [PATCH 1/3] feat: add xrechnung capabilities in faktura

---
 lab/faktura/customer.ts         |   1 +
 lab/faktura/faktura-document.ts |  22 ++-
 lab/faktura/tenant.ts           |   1 +
 lab/faktura/xrechnung.ts        | 238 ++++++++++++++++++++++++++++++++
 4 files changed, 260 insertions(+), 2 deletions(-)
 create mode 100644 lab/faktura/xrechnung.ts

diff --git a/lab/faktura/customer.ts b/lab/faktura/customer.ts
index 71a0a6afbc..de0b7f45ca 100644
--- a/lab/faktura/customer.ts
+++ b/lab/faktura/customer.ts
@@ -15,5 +15,6 @@ export class Customer extends MongoEntity {
   /** may contain \n */
   @Field(type => String) namensZusatz: Maybe<string>;
   @Field(type => Address) address: Maybe<Address>;
+  @Field(type => String) email: Maybe<string>;
   @Field(type => String) ustID: Maybe<string>;
 }
diff --git a/lab/faktura/faktura-document.ts b/lab/faktura/faktura-document.ts
index 5b7b7e108c..99045667ba 100644
--- a/lab/faktura/faktura-document.ts
+++ b/lab/faktura/faktura-document.ts
@@ -9,7 +9,7 @@ import type { PartialEntityData } from '@adornis/baseql/entities/types.js';
 import { baseqlMetaData } from '@adornis/baseql/metadata/metaDataStore.js';
 import { registerMutation } from '@adornis/baseql/metadata/register.js';
 import { getByID } from '@adornis/baseql/operations/mongo.js';
-import { type BaseQLSelectionSet } from '@adornis/baseql/utils/queryGeneration.js';
+import { selectionSet, type BaseQLSelectionSet } from '@adornis/baseql/utils/queryGeneration.js';
 import { Counter } from '@adornis/counter/counter.js';
 import { AdornisFile } from '@adornis/file-utils/db/files.js';
 import { createPDFBufferFromComponent } from '@adornis/print/server/print-internals.js';
@@ -22,6 +22,7 @@ import { readFile } from 'fs/promises';
 import { PDFDocument } from 'pdf-lib';
 import { Customer } from './customer.js';
 import { Tenant } from './tenant.js';
+import { generateXInvoiceXML } from './xrechnung.js';
 
 export const STEUERSATZ = 19;
 
@@ -264,7 +265,13 @@ export const printDocumentWithAttachments = registerMutation({
     const fakDoc = await getByID(
       FakturaDocument,
       fakDocID,
-    )({ readableID: 1, documentDate: 1, identifierNumber: 1, attachments: { file: AdornisFile.allFields } });
+    )({
+      ...selectionSet(() => FakturaDocument, 4),
+      readableID: 1,
+      documentDate: 1,
+      identifierNumber: 1,
+      attachments: { file: AdornisFile.allFields },
+    });
     if (!fakDoc) throw new Error('no faktura document found with id ' + fakDocID);
     const name = `${fakDoc.readableID}.pdf`;
     const pdfBuffer = await createPDFBufferFromComponent({
@@ -280,6 +287,17 @@ export const printDocumentWithAttachments = registerMutation({
       for (const page of await pdflib.copyPages(pdflibAttachment, pdflibAttachment.getPageIndices()))
         pdflib.addPage(page);
     }
+
+    // Embed the ZUGFeRD XML as a file attachment
+    const xmlAttachmentName = 'xrechnung.xml';
+    const xmlBytes = Buffer.from(generateXInvoiceXML(fakDoc), 'utf8');
+
+    await pdflib.attach(xmlBytes, xmlAttachmentName, {
+      mimeType: 'application/xml',
+      creationDate: new Date(),
+      modificationDate: new Date(),
+    });
+
     // TODO we create an orphaned adornisfile here, we'll need to remove this at some point. Best solution would be for baseQL to allow buffer serving through some integrated means
     const afile = await AdornisFile.createFromBuffer(Buffer.from(await pdflib.save()), name);
     return afile;
diff --git a/lab/faktura/tenant.ts b/lab/faktura/tenant.ts
index 6c9bcb06d4..a6116b5737 100644
--- a/lab/faktura/tenant.ts
+++ b/lab/faktura/tenant.ts
@@ -18,6 +18,7 @@ export class Tenant extends MongoEntity {
   @DataLoadedField(() => AdornisFile, ({ logoID }) => logoID) logo: Maybe<AdornisFile>;
   @Field(() => Address) address: Maybe<Address>;
   @Field(() => String) email: Maybe<string>;
+  @Field(() => String) phone: Maybe<string>;
   @Field(() => String) website: Maybe<string>;
   @Field(() => String) steuerNr: Maybe<string>;
   @Field(() => String) umsatzsteuerNr: Maybe<string>;
diff --git a/lab/faktura/xrechnung.ts b/lab/faktura/xrechnung.ts
new file mode 100644
index 0000000000..ef0f283853
--- /dev/null
+++ b/lab/faktura/xrechnung.ts
@@ -0,0 +1,238 @@
+/*!
+ * xrechnung-generator
+ * Copyright(c) 2024 Benedikt Cleff (info@pixelpal.io)
+ * MIT Licensed
+ *
+ * Changes made by Adornis
+ */
+
+import { assertNotEmpty } from '@adornis/assert/not-empty.js';
+import type { NarrowedEntity } from '@adornis/baseql/utils/queryGeneration.js';
+import { add, format } from 'date-fns';
+import type { FakturaDocument } from './faktura-document.js';
+
+function escapeXML(value: string) {
+  if (!value) return '';
+  return value
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&apos;');
+}
+
+export function generateXInvoiceXML(
+  invoice: NarrowedEntity<
+    FakturaDocument,
+    {
+      documentDate: 1;
+      readableID: 1;
+      identifierNumber: 1;
+      tenant: {
+        name: 1;
+        email: 1;
+        phone: 1;
+        address: { streetAndHousenumber: 1; zipCity: { zip: 1; city: 1 } };
+        bankName: 1;
+        handelsregisterNr: 1;
+        steuerNr: 1;
+        umsatzsteuerNr: 1;
+        iban: 1;
+      };
+      customer: {
+        displayName: 1;
+        firmenName: 1;
+        namensZusatz: 1;
+        ustID: 1;
+        email: 1;
+        address: { name: 1; streetAndHousenumber: 1; zipCity: { zip: 1; city: 1 } };
+      };
+      totalTax: 1;
+      totalBrutto: 1;
+      totalNetto: 1;
+      products: {
+        name: 1;
+        description: 1;
+        nettoTotal: 1;
+        discountPercent: 1;
+        amount: 1;
+        durationInMonths: 1;
+      };
+    }
+  >,
+) {
+  return `<?xml version="1.0" encoding="UTF-8"?>
+<ubl:Invoice xmlns:ubl="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
+             xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
+             xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
+  <cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0</cbc:CustomizationID>
+  <cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
+  <cbc:ID>${escapeXML(invoice.readableID)}</cbc:ID>
+  <cbc:IssueDate>${format(invoice.documentDate, 'yyyy-MM-dd')}</cbc:IssueDate>
+  <cbc:DueDate>${format(add(invoice.documentDate, { days: 14 }), 'yyyy-MM-dd')}</cbc:DueDate>
+  <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
+  <cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
+  <cbc:BuyerReference>${escapeXML(invoice.readableID)}</cbc:BuyerReference>
+
+  <cac:AccountingSupplierParty>
+    <cac:Party>
+      <cbc:EndpointID schemeID="EM">${escapeXML(assertNotEmpty(invoice.tenant?.email))}</cbc:EndpointID>
+      <cac:PartyName>
+        <cbc:Name>${escapeXML(assertNotEmpty(invoice.tenant?.name))}</cbc:Name>
+      </cac:PartyName>
+      <cac:PostalAddress>
+        ${
+          invoice.tenant?.address?.streetAndHousenumber
+            ? `<cbc:StreetName>${escapeXML(invoice.tenant.address.streetAndHousenumber)}</cbc:StreetName>`
+            : ''
+        }
+        ${
+          invoice.tenant?.address?.zipCity?.city
+            ? `<cbc:CityName>${escapeXML(invoice.tenant.address.zipCity.city)}</cbc:CityName>`
+            : ''
+        }
+        ${
+          invoice.tenant?.address?.zipCity?.zip
+            ? `<cbc:PostalZone>${escapeXML(invoice.tenant.address.zipCity.zip)}</cbc:PostalZone>`
+            : ''
+        }
+        <cac:Country>
+          <cbc:IdentificationCode>DE</cbc:IdentificationCode>
+        </cac:Country>
+      </cac:PostalAddress>
+      ${
+        invoice.tenant?.umsatzsteuerNr
+          ? `<cac:PartyTaxScheme>
+        <cbc:CompanyID>${escapeXML(invoice.tenant.umsatzsteuerNr)}</cbc:CompanyID>
+        <cac:TaxScheme>
+          <cbc:ID>VAT</cbc:ID>
+        </cac:TaxScheme>
+      </cac:PartyTaxScheme>`
+          : ''
+      }
+      ${
+        invoice.tenant?.steuerNr
+          ? `<cac:PartyTaxScheme>
+        <cbc:CompanyID>${escapeXML(invoice.tenant.steuerNr)}</cbc:CompanyID>
+        <cac:TaxScheme>
+          <cbc:ID>FC</cbc:ID>
+        </cac:TaxScheme>
+      </cac:PartyTaxScheme>`
+          : ''
+      }
+      <cac:PartyLegalEntity>
+        <cbc:RegistrationName>${escapeXML(assertNotEmpty(invoice.tenant?.name))}</cbc:RegistrationName>
+        ${
+          invoice.tenant?.handelsregisterNr
+            ? `<cbc:CompanyID>${escapeXML(invoice.tenant.handelsregisterNr)}</cbc:CompanyID>`
+            : ''
+        }
+      </cac:PartyLegalEntity>
+      <cac:Contact>
+        <cbc:Name>${escapeXML(assertNotEmpty(invoice.tenant?.name))}</cbc:Name>
+        ${invoice.tenant?.phone ? `<cbc:Telephone>${escapeXML(invoice.tenant.phone)}</cbc:Telephone>` : ''}
+        ${invoice.tenant?.email ? `<cbc:ElectronicMail>${escapeXML(invoice.tenant.email)}</cbc:ElectronicMail>` : ''}
+      </cac:Contact>
+    </cac:Party>
+  </cac:AccountingSupplierParty>
+
+  <cac:AccountingCustomerParty>
+    <cac:Party>
+      <cbc:EndpointID schemeID="EM">${escapeXML(assertNotEmpty(invoice.customer?.email))}</cbc:EndpointID>
+      <cac:PartyName>
+        <cbc:Name>${escapeXML(assertNotEmpty(invoice.customer?.displayName))}</cbc:Name>
+      </cac:PartyName>
+      <cac:PostalAddress>
+        ${
+          invoice.customer?.address?.streetAndHousenumber
+            ? `<cbc:StreetName>${escapeXML(invoice.customer.address.streetAndHousenumber)}</cbc:StreetName>`
+            : ''
+        }
+        ${
+          invoice.customer?.address?.zipCity?.city
+            ? `<cbc:CityName>${escapeXML(invoice.customer.address.zipCity.city)}</cbc:CityName>`
+            : ''
+        }
+        ${
+          invoice.customer?.address?.zipCity?.zip
+            ? `<cbc:PostalZone>${escapeXML(invoice.customer.address.zipCity.zip)}</cbc:PostalZone>`
+            : ''
+        }
+        <cac:Country>
+          <cbc:IdentificationCode>DE</cbc:IdentificationCode>
+        </cac:Country>
+      </cac:PostalAddress>
+      <cac:PartyLegalEntity>
+        <cbc:RegistrationName>${escapeXML(assertNotEmpty(invoice.customer?.firmenName))}</cbc:RegistrationName>
+      </cac:PartyLegalEntity>
+    </cac:Party>
+  </cac:AccountingCustomerParty>
+
+  <cac:PaymentMeans>
+    <cbc:PaymentMeansCode>31</cbc:PaymentMeansCode>
+    <cbc:PaymentID>${escapeXML(invoice.readableID)}</cbc:PaymentID>
+    ${
+      invoice.tenant?.iban || invoice.tenant?.bankName
+        ? `
+    <cac:PayeeFinancialAccount>
+      ${invoice.tenant.iban ? `<cbc:ID>${escapeXML(invoice.tenant.iban)}</cbc:ID>` : ''}
+      ${invoice.tenant.bankName ? `<cbc:Name>${escapeXML(invoice.tenant.bankName)}</cbc:Name>` : ''}
+    </cac:PayeeFinancialAccount>`
+        : ''
+    }
+  </cac:PaymentMeans>
+
+  <cac:TaxTotal>
+    <cbc:TaxAmount currencyID="EUR">${invoice.totalTax.toFixed(2)}</cbc:TaxAmount>
+    <cac:TaxSubtotal>
+      <cbc:TaxableAmount currencyID="EUR">${invoice.totalNetto.toFixed(2)}</cbc:TaxableAmount>
+      <cbc:TaxAmount currencyID="EUR">${invoice.totalTax.toFixed(2)}</cbc:TaxAmount>
+      <cac:TaxCategory>
+        <cbc:ID>S</cbc:ID>
+        <cbc:Percent>19</cbc:Percent>
+        <cac:TaxScheme>
+          <cbc:ID>VAT</cbc:ID>
+        </cac:TaxScheme>
+      </cac:TaxCategory>
+    </cac:TaxSubtotal>
+  </cac:TaxTotal>
+
+  <cac:LegalMonetaryTotal>
+    <cbc:LineExtensionAmount currencyID="EUR">${invoice.totalNetto.toFixed(2)}</cbc:LineExtensionAmount>
+    <cbc:TaxExclusiveAmount currencyID="EUR">${invoice.totalNetto.toFixed(2)}</cbc:TaxExclusiveAmount>
+    <cbc:TaxInclusiveAmount currencyID="EUR">${invoice.totalBrutto.toFixed(2)}</cbc:TaxInclusiveAmount>
+    <cbc:PayableAmount currencyID="EUR">${invoice.totalBrutto.toFixed(2)}</cbc:PayableAmount>
+  </cac:LegalMonetaryTotal>
+
+  ${invoice.products
+    .map(
+      (item, index) => `
+  <cac:InvoiceLine>
+    <cbc:ID>${index + 1}</cbc:ID>
+    <cbc:InvoicedQuantity unitCode="H87">1</cbc:InvoicedQuantity>
+    <cbc:LineExtensionAmount currencyID="EUR">${item.nettoTotal.toFixed(2)}</cbc:LineExtensionAmount>
+    <cac:Item>
+      <cbc:Description>${escapeXML(item.description)}</cbc:Description>
+      <cbc:Name>${escapeXML(item.name)}</cbc:Name>
+      <cac:ClassifiedTaxCategory>
+        <cbc:ID>S</cbc:ID>
+        <cbc:Percent>19</cbc:Percent>
+        <cac:TaxScheme>
+          <cbc:ID>VAT</cbc:ID>
+        </cac:TaxScheme>
+      </cac:ClassifiedTaxCategory>
+    </cac:Item>
+    <cac:Price>
+      <cbc:PriceAmount currencyID="EUR">${item.nettoTotal.toFixed(2)}</cbc:PriceAmount>
+      <cbc:BaseQuantity unitCode="H87">1</cbc:BaseQuantity>
+      <cac:AllowanceCharge>
+        <cbc:ChargeIndicator>false</cbc:ChargeIndicator>
+        <cbc:Amount currencyID="EUR">0.00</cbc:Amount>
+        <cbc:BaseAmount currencyID="EUR">${item.nettoTotal.toFixed(2)}</cbc:BaseAmount>
+      </cac:AllowanceCharge>
+    </cac:Price>
+  </cac:InvoiceLine>`,
+    )
+    .join('\n')}
+</ubl:Invoice>`;
+}
-- 
GitLab


From 0037b7298e37d1cd5ffc8242de79cc4aed7acd51 Mon Sep 17 00:00:00 2001
From: Kai Brobeil <kai@adornis.de>
Date: Thu, 13 Feb 2025 20:49:12 +0000
Subject: [PATCH 2/3] chore: add validation tool

---
 lab/faktura/xrechnung.ts | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/lab/faktura/xrechnung.ts b/lab/faktura/xrechnung.ts
index ef0f283853..b9bfa78dc6 100644
--- a/lab/faktura/xrechnung.ts
+++ b/lab/faktura/xrechnung.ts
@@ -4,6 +4,8 @@
  * MIT Licensed
  *
  * Changes made by Adornis
+ * 
+ * hiermit validieren wir: https://erechnungsvalidator.service-bw.de/
  */
 
 import { assertNotEmpty } from '@adornis/assert/not-empty.js';
-- 
GitLab


From f9d8d9bd0784b974389e8efb2a8f61e4196e4f9d Mon Sep 17 00:00:00 2001
From: yorrd <kai@adornis.de>
Date: Thu, 13 Feb 2025 20:51:48 +0000
Subject: [PATCH 3/3] chore: add missing dependency

---
 lab/faktura/package.json | 1 +
 pnpm-lock.yaml           | 3 +++
 2 files changed, 4 insertions(+)

diff --git a/lab/faktura/package.json b/lab/faktura/package.json
index b631ae3230..c09878e648 100644
--- a/lab/faktura/package.json
+++ b/lab/faktura/package.json
@@ -11,6 +11,7 @@
   "dependencies": {
     "@adornis/accounting": "workspace:^",
     "@adornis/address": "workspace:^",
+    "@adornis/assert": "workspace:^",
     "@adornis/base": "workspace:^",
     "@adornis/baseql": "workspace:^",
     "@adornis/chemistry": "workspace:^",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d1849bef9f..ec3349450f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -470,6 +470,9 @@ importers:
       '@adornis/address':
         specifier: workspace:^
         version: link:../../modules/address
+      '@adornis/assert':
+        specifier: workspace:^
+        version: link:../assert
       '@adornis/base':
         specifier: workspace:^
         version: link:../../modules/base
-- 
GitLab