test: add an e-invoice mail to the demo

This commit is contained in:
Linus Rath
2026-10-31 14:17:11 +01:00
parent 523e5b8981
commit c7eb483202
6 changed files with 209 additions and 2 deletions
+32 -1
View File
@@ -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);
});
});
+3
View File
@@ -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' });
+2 -1
View File
@@ -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(),
+144
View File
@@ -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) => `
<cac:InvoiceLine>
<cbc:ID>${id}</cbc:ID>
<cbc:InvoicedQuantity unitCode="C62">${quantity}</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">${amount}</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Description>${description}</cbc:Description>
<cbc:Name>${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">${price}</cbc:PriceAmount></cac:Price>
</cac:InvoiceLine>`;
export function createSpreehostInvoiceXml(now = new Date()): string {
const d = invoiceDates(now);
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>${INVOICE_NUMBER}</cbc:ID>
<cbc:IssueDate>${d.issue}</cbc:IssueDate>
<cbc:DueDate>${d.due}</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Thank you for hosting with Spreehost. Please quote the invoice number with your payment.</cbc:Note>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cbc:BuyerReference>PO-2026-0398</cbc:BuyerReference>
<cac:InvoicePeriod>
<cbc:StartDate>${d.periodStart}</cbc:StartDate>
<cbc:EndDate>${d.periodEnd}</cbc:EndDate>
</cac:InvoicePeriod>
<cac:OrderReference><cbc:ID>PO-2026-0398</cbc:ID></cac:OrderReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="EM">billing@spreehost.example</cbc:EndpointID>
<cac:PartyName><cbc:Name>Spreehost</cbc:Name></cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Köpenicker Straße 154</cbc:StreetName>
<cbc:CityName>Berlin</cbc:CityName>
<cbc:PostalZone>10997</cbc:PostalZone>
<cac:Country><cbc:IdentificationCode>DE</cbc:IdentificationCode></cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>DE298765432</cbc:CompanyID>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Spreehost GmbH</cbc:RegistrationName>
<cbc:CompanyID>HRB 214477 B</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Spreehost Billing</cbc:Name>
<cbc:Telephone>+49 30 555 0400</cbc:Telephone>
<cbc:ElectronicMail>billing@spreehost.example</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="EM">accounts@meridianlabs.example</cbc:EndpointID>
<cac:PostalAddress>
<cbc:StreetName>Torstraße 140</cbc:StreetName>
<cbc:CityName>Berlin</cbc:CityName>
<cbc:PostalZone>10119</cbc:PostalZone>
<cac:Country><cbc:IdentificationCode>DE</cbc:IdentificationCode></cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity><cbc:RegistrationName>Meridian Labs GmbH</cbc:RegistrationName></cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>58</cbc:PaymentMeansCode>
<cbc:PaymentID>${INVOICE_NUMBER}</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>DE02120300000000202051</cbc:ID>
<cbc:Name>Spreehost GmbH</cbc:Name>
<cac:FinancialInstitutionBranch><cbc:ID>BYLADEM1001</cbc:ID></cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms><cbc:Note>Payable within 14 days without deduction.</cbc:Note></cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">108.49</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">571.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">108.49</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">571.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">571.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">${INVOICE_TOTAL}</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="EUR">${INVOICE_TOTAL}</cbc:PayableAmount>
</cac:LegalMonetaryTotal>${[
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('')}
</ubl:Invoice>
`;
}
/** Bytes of the XML, for the attachment size (the same every day). */
export const invoiceSize = (): number => new TextEncoder().encode(createSpreehostInvoiceXml()).length;
+2
View File
@@ -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');
+26
View File
@@ -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'),
}),
];
}