mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
test: add an e-invoice mail to the demo
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user