From c7eb4832023d4f83cb24ee5097f8ef146a903417 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:17:11 +0100 Subject: [PATCH] test: add an e-invoice mail to the demo --- lib/demo/__tests__/fixtures.test.ts | 33 ++++++- lib/demo/demo-client.ts | 3 + lib/demo/demo-data.ts | 3 +- lib/demo/fixtures/invoice.ts | 144 ++++++++++++++++++++++++++++ lib/demo/fixtures/people.ts | 2 + lib/demo/fixtures/supplier.ts | 26 +++++ 6 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 lib/demo/fixtures/invoice.ts create mode 100644 lib/demo/fixtures/supplier.ts 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'), + }), + ]; +}