diff --git a/lib/demo/__tests__/fixtures.test.ts b/lib/demo/__tests__/fixtures.test.ts
index 0a6b76390..5531a479b 100644
--- a/lib/demo/__tests__/fixtures.test.ts
+++ b/lib/demo/__tests__/fixtures.test.ts
@@ -35,6 +35,8 @@ import type { BusyPeriod } from '@/lib/jmap/types';
import { useRecoveryStore } from '@/stores/recovery-store';
import { SUPPORT_TAGS } from '../fixtures/shared-metadata';
import { SIGNED_EMAIL_ID, SIGNED_TEXT } from '../fixtures/signed';
+import { INVOICE_BLOB, INVOICE_FILE, INVOICE_TOTAL, createSpreehostInvoiceXml } from '../fixtures/invoice';
+import { INVOICE_EMAIL_ID } from '../fixtures/supplier';
import { loadSharedTags, sharedTagsKey, useSharedTagsStore, saveSharedTags } from '@/stores/shared-tags-store';
const PUBLIC = path.resolve(__dirname, '..', '..', '..', 'public');
@@ -168,7 +170,7 @@ describe('demo fixtures: files behind the data', () => {
it('every attachment is backed by an asset', () => {
for (const e of [...work.emails, ...personal.emails]) {
for (const att of e.attachments ?? []) {
- if (att.blobId === GONOGO_INVITE_BLOB) continue;
+ if (att.blobId === GONOGO_INVITE_BLOB || att.blobId === INVOICE_BLOB) continue;
expect(DEMO_ASSETS[att.blobId as keyof typeof DEMO_ASSETS], `${e.id} -> ${att.name}`).toBeDefined();
}
}
@@ -539,4 +541,33 @@ describe('demo fixtures: audience screenshots', () => {
const der = Buffer.from(signaturePart.split('\r\n\r\n')[1].replace(/\s+/g, ''), 'base64');
expect(der.equals(fs.readFileSync(path.join(PUBLIC, DEMO_ASSETS[sigPart!.blobId as keyof typeof DEMO_ASSETS].path)))).toBe(true);
});
+
+ it('Spreehost\'s invoice mail carries an XRechnung that adds up and is not overdue', async () => {
+ const client = new DemoJMAPClient('work');
+ const email = (await client.getEmail(INVOICE_EMAIL_ID))!;
+ expect(email.mailboxIds[MB.inbox]).toBe(true);
+ expect(email.from?.[0].email).toBe('billing@spreehost.example');
+ const [att] = email.attachments!;
+ // What the E-Invoices plugin reads: an .xml attachment (its detect.js kindOf).
+ expect(att.name).toBe(INVOICE_FILE);
+ expect(att.name).toMatch(/\.xml$/);
+ const xml = await (await client.fetchBlob(att.blobId)).text();
+ expect(xml).toBe(createSpreehostInvoiceXml());
+ expect(new TextEncoder().encode(xml).length).toBe(att.size);
+
+ const doc = new DOMParser().parseFromString(xml, 'application/xml');
+ const one = (name: string) => doc.getElementsByTagNameNS('*', name)[0]?.textContent ?? '';
+ expect(one('CustomizationID')).toContain('xrechnung_3.0');
+ expect(one('BuyerReference')).not.toBe('');
+ const lines = [...doc.getElementsByTagNameNS('*', 'InvoiceLine')]
+ .map((l) => Number(l.getElementsByTagNameNS('*', 'LineExtensionAmount')[0].textContent));
+ const net = lines.reduce((a, b) => a + b, 0);
+ expect(net.toFixed(2)).toBe(one('TaxExclusiveAmount'));
+ expect((net * 1.19).toFixed(2)).toBe(INVOICE_TOTAL);
+ expect(one('PayableAmount')).toBe(INVOICE_TOTAL);
+ // Dates follow the clock: issued yesterday, due in the future.
+ const today = new Date().toLocaleDateString('sv-SE');
+ expect(one('IssueDate') < today).toBe(true);
+ expect(one('DueDate') > today).toBe(true);
+ });
});
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index 07841951a..f1f236b91 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -21,6 +21,7 @@ import { generateDemoId } from './demo-utils';
import { DEMO_ASSETS } from './fixtures/assets';
import { GONOGO_INVITE_BLOB } from './fixtures/emails';
import { createGoNoGoInvitationIcs, GONOGO_EVENT_ID } from './fixtures/calendars';
+import { createSpreehostInvoiceXml, INVOICE_BLOB } from './fixtures/invoice';
import { ALICE, BOB, SARAH, PRIYA, LISA } from './fixtures/people';
import { parseAuthenticationResults, parseSpamLLM, parseSpamScore } from '@/lib/email-headers';
import { compareEmails, type SortLevel } from '@/lib/message-list-order';
@@ -1203,6 +1204,8 @@ export class DemoJMAPClient implements IJMAPClient {
if (blobId === GONOGO_INVITE_BLOB) {
return new Blob([createGoNoGoInvitationIcs()], { type: 'text/calendar; method=REQUEST' });
}
+ // Built on fetch so its dates follow the clock (fixtures/invoice.ts).
+ if (blobId === INVOICE_BLOB) return new Blob([createSpreehostInvoiceXml()], { type: 'application/xml' });
const emlOf = demoEmlEmailId(blobId);
const emlEmail = emlOf ? this.data.emails.find((e) => e.id === emlOf) : undefined;
if (emlEmail) return new Blob([buildDemoEml(emlEmail)], { type: 'message/rfc822' });
diff --git a/lib/demo/demo-data.ts b/lib/demo/demo-data.ts
index 41ada84ed..8aa3bf016 100644
--- a/lib/demo/demo-data.ts
+++ b/lib/demo/demo-data.ts
@@ -3,6 +3,7 @@ import { createDemoMailboxes, createDemoSupportMailboxes, createDemoPersonalMail
import { createDemoEmails } from './fixtures/emails';
import { createDemoSupportEmails } from './fixtures/support';
import { createDemoSignedEmails } from './fixtures/signed';
+import { createDemoSupplierEmails } from './fixtures/supplier';
import { createDemoPersonalEmails, createDemoPersonalIdentities } from './fixtures/personal';
import { createDemoContacts, createDemoAddressBooks, createDemoPersonalContacts, createDemoPersonalAddressBooks } from './fixtures/contacts';
import { createDemoCalendars, createDemoCalendarEvents } from './fixtures/calendars';
@@ -107,7 +108,7 @@ export function getDemoData(profile: DemoProfile = 'work'): DemoData {
self: { name: NORA.name, email: NORA.email },
mailboxes: withShares(createDemoMailboxes(), createDemoMailboxShares()),
sharedMailboxes: { [SUPPORT_ACCOUNT_ID]: { name: SUPPORT_ACCOUNT_NAME, mailboxes: createDemoSupportMailboxes() } },
- emails: [...createDemoEmails(), ...createDemoSignedEmails(), ...createDemoSupportEmails()],
+ emails: [...createDemoEmails(), ...createDemoSignedEmails(), ...createDemoSupplierEmails(), ...createDemoSupportEmails()],
contacts: createDemoContacts(),
addressBooks: createDemoAddressBooks(),
calendars: createDemoCalendars(),
diff --git a/lib/demo/fixtures/invoice.ts b/lib/demo/fixtures/invoice.ts
new file mode 100644
index 000000000..33b75c7ef
--- /dev/null
+++ b/lib/demo/fixtures/invoice.ts
@@ -0,0 +1,144 @@
+/**
+ * The monthly hosting invoice from Spreehost, Meridian Labs' (made-up)
+ * hosting company, as an XRechnung 3.0 in UBL: what the E-Invoices plugin
+ * (repos/plugins e-invoice) shows as a readable invoice. Adapted from that
+ * plugin's own test fixture (e-invoice/test/fixtures/xrechnung-ubl.xml,
+ * AGPL-3.0 like this repo) with the cast's names and amounts.
+ *
+ * The XML is built when it is fetched, like the go/no-go .ics: its dates
+ * follow the demo's clock, so the invoice is never shown as overdue. Every
+ * date is YYYY-MM-DD, so its length, and the attachment size, stay the same.
+ */
+export const INVOICE_BLOB = 'demo-blob-spreehost-invoice';
+export const INVOICE_NUMBER = 'SH-2026-10418';
+export const INVOICE_FILE = `${INVOICE_NUMBER}.xml`;
+
+/** A local date, `days` from today, as YYYY-MM-DD. */
+function localDate(days: number, base = new Date()): string {
+ const d = new Date(base);
+ d.setDate(d.getDate() + days);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+/** Issued yesterday for the month before, due two weeks later. */
+export function invoiceDates(now = new Date()) {
+ const issued = new Date(now);
+ issued.setDate(issued.getDate() - 1);
+ const periodEnd = new Date(issued.getFullYear(), issued.getMonth(), 0);
+ const periodStart = new Date(periodEnd.getFullYear(), periodEnd.getMonth(), 1);
+ return {
+ issue: localDate(0, issued),
+ due: localDate(14, issued),
+ periodStart: localDate(0, periodStart),
+ periodEnd: localDate(0, periodEnd),
+ };
+}
+
+/** Net 571.00, VAT 19% 108.49, total and due 679.49 EUR. */
+export const INVOICE_TOTAL = '679.49';
+
+const line = (id: number, name: string, description: string, quantity: number, price: string, amount: string) => `
+
+ ${id}
+ ${quantity}
+ ${amount}
+
+ ${description}
+ ${name}
+ S19VAT
+
+ ${price}
+ `;
+
+export function createSpreehostInvoiceXml(now = new Date()): string {
+ const d = invoiceDates(now);
+ return `
+
+ urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0
+ urn:fdc:peppol.eu:2017:poacc:billing:01:1.0
+ ${INVOICE_NUMBER}
+ ${d.issue}
+ ${d.due}
+ 380
+ Thank you for hosting with Spreehost. Please quote the invoice number with your payment.
+ EUR
+ PO-2026-0398
+
+ ${d.periodStart}
+ ${d.periodEnd}
+
+ PO-2026-0398
+
+
+ billing@spreehost.example
+ Spreehost
+
+ Köpenicker Straße 154
+ Berlin
+ 10997
+ DE
+
+
+ DE298765432
+ VAT
+
+
+ Spreehost GmbH
+ HRB 214477 B
+
+
+ Spreehost Billing
+ +49 30 555 0400
+ billing@spreehost.example
+
+
+
+
+
+ accounts@meridianlabs.example
+
+ Torstraße 140
+ Berlin
+ 10119
+ DE
+
+ Meridian Labs GmbH
+
+
+
+ 58
+ ${INVOICE_NUMBER}
+
+ DE02120300000000202051
+ Spreehost GmbH
+ BYLADEM1001
+
+
+ Payable within 14 days without deduction.
+
+ 108.49
+
+ 571.00
+ 108.49
+ S19VAT
+
+
+
+ 571.00
+ 571.00
+ ${INVOICE_TOTAL}
+ ${INVOICE_TOTAL}
+ ${[
+ line(1, 'Dedicated server DX-64', 'Beacon, eu-1 and eu-2', 2, '189.00', '378.00'),
+ line(2, 'Managed PostgreSQL M', 'Primary with one replica', 1, '145.00', '145.00'),
+ line(3, 'Backup storage 2 TB', 'Daily snapshots, 30 days kept', 1, '48.00', '48.00'),
+ ].join('')}
+
+`;
+}
+
+/** Bytes of the XML, for the attachment size (the same every day). */
+export const invoiceSize = (): number => new TextEncoder().encode(createSpreehostInvoiceXml()).length;
diff --git a/lib/demo/fixtures/people.ts b/lib/demo/fixtures/people.ts
index 5ed348a00..f383e7140 100644
--- a/lib/demo/fixtures/people.ts
+++ b/lib/demo/fixtures/people.ts
@@ -51,6 +51,8 @@ export const SARAH = p('Sarah', 'Kim', 'sarah@designco.example', 'sarah-kim');
export const OLIVIA = p('Olivia', 'Bennett', 'olivia@northwind.example', 'olivia-bennett');
export const JENNIFER = p('Jennifer', 'Hayes', 'jennifer@talent-partners.example', 'jennifer-hayes');
export const KENJI = p('Kenji', 'Watanabe', 'kenji@infrasummit.example', 'kenji-watanabe');
+/** Meridian Labs' hosting company; sends the monthly e-invoice (fixtures/invoice.ts). */
+export const SPREEHOST: Person = { name: 'Spreehost Billing', email: 'billing@spreehost.example', given: 'Spreehost', surname: 'Billing' };
// ── Family and friends ────────────────────────────────────────
export const SOFIA = p('Sofia', 'Russo', 'sofia.russo@example.com', 'sofia-russo');
diff --git a/lib/demo/fixtures/supplier.ts b/lib/demo/fixtures/supplier.ts
new file mode 100644
index 000000000..8bf537aca
--- /dev/null
+++ b/lib/demo/fixtures/supplier.ts
@@ -0,0 +1,26 @@
+/**
+ * Mail from suppliers: Spreehost's monthly hosting invoice, with the
+ * XRechnung the E-Invoices plugin shows as an invoice (fixtures/invoice.ts).
+ */
+import type { Email } from '@/lib/jmap/types';
+import { ago, authPass, mail } from './mail';
+import { MB } from './mailboxes';
+import { addr, SPREEHOST } from './people';
+import { INVOICE_BLOB, INVOICE_FILE, INVOICE_NUMBER, INVOICE_TOTAL, invoiceDates, invoiceSize } from './invoice';
+
+export const INVOICE_EMAIL_ID = 'demo-email-spreehost-invoice';
+
+export function createDemoSupplierEmails(): Email[] {
+ const due = new Date(`${invoiceDates().due}T12:00:00`).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
+ return [
+ mail({
+ id: INVOICE_EMAIL_ID, mailbox: MB.inbox, date: ago(2, 25),
+ from: addr(SPREEHOST),
+ subject: `Your invoice ${INVOICE_NUMBER}`,
+ text: `Hello Meridian Labs,\n\nYour invoice ${INVOICE_NUMBER} for last month is attached as an XRechnung (XML).\n\nAmount due: ${INVOICE_TOTAL} EUR, payable by ${due} to the account on the invoice. Please quote the invoice number with your payment.\n\nQuestions about the invoice: reply to this mail.\n\nSpreehost GmbH\nKöpenicker Straße 154, 10997 Berlin`,
+ attachments: [{ partId: 'att-invoice-xml', blobId: INVOICE_BLOB, size: invoiceSize(), name: INVOICE_FILE, type: 'application/xml' }],
+ keywords: ['$label:finance'],
+ headers: authPass('spreehost.example'),
+ }),
+ ];
+}