diff --git a/dashcaddy-api/__tests__/billing/invoice.test.js b/dashcaddy-api/__tests__/billing/invoice.test.js
new file mode 100644
index 0000000..c7ad865
--- /dev/null
+++ b/dashcaddy-api/__tests__/billing/invoice.test.js
@@ -0,0 +1,454 @@
+/**
+ * Invoice rendering tests โ DC-058.
+ *
+ * Pure functions. No live network, no SMTP, no Stripe SDK. Covers:
+ * - HTML escaping for every user-controlled field
+ * - CRLF/control-char neutralization (SMTP header injection defense)
+ * - Plain-text fallback has the same content
+ * - PDF is a valid PDF (magic bytes + loadable by pdf-parse)
+ * - Invoice number derived from event id (deterministic)
+ * - Catalog integration: missing productId still produces valid output
+ *
+ * Pairs with stripe-license-bridge.test.js (which covers the SMTP wiring
+ * on top of these primitives).
+ */
+
+const path = require('path');
+const fs = require('fs');
+
+const invoice = require('../../src/billing/invoice');
+const catalog = require('../../src/billing/catalog');
+
+// pdf-parse is the canonical tool to extract text from a PDF buffer for
+// verification. We keep it as a soft dependency โ if it's not available,
+// the text-content tests skip rather than fail.
+let pdfParse = null;
+try {
+ pdfParse = require('pdf-parse');
+} catch (_) {
+ pdfParse = null;
+}
+
+const BASE = {
+ email: 'alice@example.com',
+ customerName: 'Alice Johnson',
+ code: 'DC-PRO-30D-AB12CD34',
+ durationDays: 30,
+ productLabel: '1 month',
+ productId: 'pro-30d',
+ amountCents: 2000,
+ currency: 'USD',
+ eventId: 'evt_4f2c9b3a8b1d',
+ sessionId: 'cs_test_a1b2c3d4e5',
+ supportUrl: 'https://dashcaddy.net',
+};
+
+describe('billing/invoice', () => {
+ describe('generateInvoiceNumber', () => {
+ test('strips evt_ prefix and produces INV-{8 hex chars}', () => {
+ expect(invoice.generateInvoiceNumber('evt_4f2c9b3a8b1d')).toBe('INV-4F2C9B3A');
+ });
+
+ test('uppercases mixed-case event ids', () => {
+ expect(invoice.generateInvoiceNumber('evt_AbCdEf1234')).toBe('INV-ABCDEF12');
+ });
+
+ test('falls back to NOEVENT for empty/missing input', () => {
+ expect(invoice.generateInvoiceNumber('')).toBe('INV-NOEVENT');
+ expect(invoice.generateInvoiceNumber(null)).toBe('INV-NOEVENT');
+ expect(invoice.generateInvoiceNumber(undefined)).toBe('INV-NOEVENT');
+ });
+
+ test('handles event id without prefix', () => {
+ expect(invoice.generateInvoiceNumber('4f2c9b3a8b1d')).toBe('INV-4F2C9B3A');
+ });
+ });
+
+ describe('stripControlChars', () => {
+ test('replaces CRLF with single space (prevents SMTP header injection)', () => {
+ const input = 'alice@example.com\r\nBcc: attacker@evil.com';
+ const output = invoice.stripControlChars(input);
+ expect(output).toBe('alice@example.com Bcc: attacker@evil.com');
+ expect(output).not.toContain('\r');
+ expect(output).not.toContain('\n');
+ });
+
+ test('collapses whitespace runs', () => {
+ expect(invoice.stripControlChars(' alice example ')).toBe('alice example');
+ });
+
+ test('handles null/undefined gracefully', () => {
+ expect(invoice.stripControlChars(null)).toBe('');
+ expect(invoice.stripControlChars(undefined)).toBe('');
+ });
+
+ test('preserves printable unicode (accents, emoji)', () => {
+ expect(invoice.stripControlChars('Sami Ahmed ๐')).toBe('Sami Ahmed ๐');
+ });
+ });
+
+ describe('escapeHtml', () => {
+ test('escapes all HTML metacharacters', () => {
+ expect(invoice.escapeHtml(''))
+ .toBe('<script>alert(1)</script>');
+ expect(invoice.escapeHtml(`"O'Brien & Sons"`))
+ .toBe('"O'Brien & Sons"');
+ });
+
+ test('handles null/undefined', () => {
+ expect(invoice.escapeHtml(null)).toBe('');
+ expect(invoice.escapeHtml(undefined)).toBe('');
+ });
+ });
+
+ describe('renderLicenseEmailHtml', () => {
+ test('renders branded HTML with license code, invoice number, and price', () => {
+ const { subject, html } = invoice.renderLicenseEmailHtml(BASE);
+ expect(subject).toContain('DashCaddy Pro');
+ expect(subject).toContain('30 days');
+ expect(html).toContain('DC-PRO-30D-AB12CD34');
+ expect(html).toContain('INV-4F2C9B3A');
+ expect(html).toContain('$20.00');
+ expect(html).toContain('Alice'); // first name from customerName
+ expect(html).toContain('alice@example.com');
+ // Brand colors must match the rest of DashCaddy
+ expect(html).toContain('#09111f'); // bg
+ expect(html).toContain('#7cf2c0'); // pro accent
+ expect(html).toContain('#68a4ff'); // accent
+ });
+
+ test('uses a friendly greeting when customerName is missing', () => {
+ const { html } = invoice.renderLicenseEmailHtml({ ...BASE, customerName: '' });
+ expect(html).toContain('Hi there,');
+ expect(html).not.toContain('Hi ,');
+ });
+
+ test('does NOT include any CR or LF in user-controlled regions (CRLF injection defense)', () => {
+ // Use NO-SPACE-after-colon payloads so that if `stripControlChars`
+ // were deleted, the rendered output would contain "Bcc:attacker"
+ // (header-injection survivors, no spaces between the colon and value).
+ // The earlier version used "Bcc: attacker" (with space) which the
+ // rendered output also had โ the regex /Bcc:[^\s<]/ could not match
+ // either way, so the test passed vacuously regardless of whether
+ // sanitization actually ran.
+ const malicious = {
+ ...BASE,
+ email: 'alice@example.com\r\nBcc:attacker@evil.com',
+ customerName: 'Eve\r\nBcc:eve@evil.com',
+ code: 'X\r\nY',
+ eventId: 'evt_\r\nfakeHeader:1',
+ };
+ const { html } = invoice.renderLicenseEmailHtml(malicious);
+ // CRITICAL: no \r anywhere (template source has no \r).
+ expect(html).not.toMatch(/\r/);
+ // Extract each user-controlled region and assert no \n AND no
+ // unbroken "Bcc:" header-injection survivors. Each region
+ // comes from the email/customerName/code/eventId values; if any
+ // contains a \n OR a "Bcc:" without a space-after-colon, the test
+ // fails. This is the strongest possible assertion: deleting
+ // stripControlChars would break it immediately.
+ const patterns = [
+ { name: 'email', re: /Email[^<]*]+>([^<]+)<\/a>/ },
+ { name: 'name', re: /(?:Thanks for your purchase, |Hi )([^]*word-break[^>]*>([^<]+)<\/div>/ },
+ { name: 'eventId', re: /Stripe event[^<]*]+>([^<]+)<\/a>/ },
+ ];
+ for (const { name, re } of patterns) {
+ const m = html.match(re);
+ if (m) {
+ expect(m[1]).not.toMatch(/\n/);
+ expect(m[1]).not.toMatch(/Bcc:[^\s<]/); // header-injection survivor
+ expect(m[1]).not.toMatch(/fakeHeader:[^\s<]/);
+ }
+ }
+ });
+
+ test('escapes HTML in customer name (XSS defense)', () => {
+ const { html } = invoice.renderLicenseEmailHtml({
+ ...BASE,
+ customerName: '',
+ });
+ expect(html).not.toContain('',
+ 'file:///etc/passwd',
+ 'vbscript:msgbox(1)',
+ 'ftp://example.com',
+ ]) {
+ const { html } = invoice.renderLicenseEmailHtml({ ...BASE, supportUrl: badUrl });
+ expect(html).not.toContain('javascript:');
+ expect(html).not.toContain('data:text/html');
+ expect(html).not.toContain('file:///');
+ expect(html).not.toContain('vbscript:');
+ // Falls back to the canonical https URL.
+ expect(html).toContain('https://dashcaddy.net');
+ }
+ });
+
+ test('long license code (>24 chars) wraps instead of overflowing PDF', async () => {
+ // 50-char code would overflow the 484px Courier-Bold box at 13pt.
+ const longCode = 'DC-PRO-30D-' + 'X'.repeat(40);
+ const buf = await invoice.renderInvoicePdf({ ...BASE, code: longCode });
+ expect(buf.length).toBeGreaterThan(1000);
+ // PDFKit handles lineBreak:true by wrapping inside the box; we just
+ // need to verify the PDF is structurally valid (parsed by pdf-parse).
+ const pdfParse = require('pdf-parse');
+ const { text } = await pdfParse(buf);
+ // The key body should be in there somewhere โ even if wrapped across
+ // lines, at least part of the code is extractable.
+ expect(text).toMatch(/DC-PRO-30D/);
+ });
+
+ test('PDF Info Subject is constant (does NOT echo customer name or email)', async () => {
+ // A customer-influenceable string in PDF metadata (visible in every
+ // PDF reader's Properties panel) is a phishing-recon signal even
+ // though it's not XSS-executable. The Subject field MUST be a
+ // constant; the customer-identifying info lives in the visible body.
+ const buf = await invoice.renderInvoicePdf({
+ ...BASE,
+ customerName: '',
+ email: 'evil@attacker.com',
+ });
+ const pdfParse = require('pdf-parse');
+ // Pass version option to extract metadata (some pdf-parse versions
+ // require explicit hint to parse Info dictionary).
+ const { metadata, text } = await pdfParse(buf, { version: 'default' });
+ // If pdf-parse still doesn't extract metadata, fall back to scanning
+ // the binary for the Subject string. Either way, the assertion holds.
+ if (metadata) {
+ expect(metadata.Subject).toBe('DashCaddy Pro invoice');
+ } else {
+ // The Subject is stored as an indirect object reference in the PDF;
+ // it might not parse cleanly. Look for the constant in the binary
+ // string form (PDFKit may encode it as UTF-16BE or octal escapes).
+ const bin = buf.toString('binary');
+ // The escaped form of "DashCaddy Pro invoice" in PDF literal strings
+ // is the literal text wrapped in parentheses, possibly octal-escaped.
+ // We just verify the email/HTML-payload is NOT in the metadata object
+ // references โ search for the literal Subject string body.
+ const subjectObj = bin.match(/\/Subject\s*\(([^)]+)\)/);
+ if (subjectObj) {
+ expect(subjectObj[1]).not.toContain('evil@attacker.com');
+ expect(subjectObj[1]).not.toContain('');
+ });
+
+ test('rejects non-numeric amountCents (string "2000" would silently render $0.00)', () => {
+ // STRING amount used to silently fall through to $0.00 because
+ // Number.isFinite('2000') is false. Now we throw, surfacing the bug
+ // at the bridge instead of shipping a $0 invoice to a paying customer.
+ // We strip productId so the catalog fallback doesn't rescue the bad input.
+ const { productId, ...baseNoProduct } = BASE;
+ expect(() => invoice.renderLicenseEmailHtml({ ...baseNoProduct, amountCents: '2000' }))
+ .toThrow(/amountCents must be a positive integer/);
+ });
+
+ test('rejects NaN, Infinity, negative, and zero amountCents', () => {
+ const { productId, ...baseNoProduct } = BASE;
+ for (const bad of [NaN, Infinity, -Infinity, -100, 0]) {
+ expect(() => invoice.renderLicenseEmailHtml({ ...baseNoProduct, amountCents: bad }))
+ .toThrow(/amountCents must be a positive integer/);
+ }
+ });
+
+ test('falls back to catalog amount when amountCents is null AND productId resolves', () => {
+ // Bridge contract: if amountCents is missing from the Stripe session
+ // (older sessions, expand failure), we use the catalog's canonical
+ // price rather than throwing. This is the recovery path.
+ const html = invoice.renderLicenseEmailHtml({
+ ...BASE,
+ productId: 'pro-30d',
+ amountCents: null,
+ }).html;
+ // catalog says pro-30d = $20.00 (2000 cents)
+ expect(html).toContain('$20.00');
+ });
+
+ test('fractional cents are floored (no silent $0.01 from $0.005 rounding)', () => {
+ // 2000.7 cents should render as $20.00 (floored). The bridge should
+ // never send fractional cents in practice, but defense-in-depth.
+ const html = invoice.renderLicenseEmailHtml({ ...BASE, amountCents: 2000.7 }).html;
+ expect(html).toContain('$20.00');
+ expect(html).not.toContain('$20.01');
+ });
+
+ test('uses embedded SVG logo (works offline, no remote fetch)', () => {
+ const { html } = invoice.renderLicenseEmailHtml(BASE);
+ expect(html).toMatch(/src="data:image\/svg\+xml/);
+ expect(html).not.toMatch(/src="https?:\/\//);
+ });
+ });
+
+ describe('renderLicenseEmailText', () => {
+ test('includes license code, invoice #, and amount', () => {
+ const text = invoice.renderLicenseEmailText(BASE);
+ expect(text).toContain('DC-PRO-30D-AB12CD34');
+ expect(text).toContain('INV-4F2C9B3A');
+ expect(text).toContain('$20.00');
+ expect(text).toContain('Stripe event');
+ expect(text).toContain('evt_4f2c9b3a8b1d');
+ });
+
+ test('uses first name from customerName when present', () => {
+ const text = invoice.renderLicenseEmailText({
+ ...BASE,
+ customerName: 'Alice Johnson',
+ });
+ expect(text.split('\n')[0]).toBe('Hi Alice,');
+ });
+
+ test('falls back to "Hi there," when customerName missing', () => {
+ const text = invoice.renderLicenseEmailText({ ...BASE, customerName: '' });
+ expect(text.split('\n')[0]).toBe('Hi there,');
+ });
+ });
+
+ describe('renderInvoicePdf', () => {
+ test('produces a valid PDF (magic bytes + non-trivial size)', async () => {
+ const buf = await invoice.renderInvoicePdf(BASE);
+ expect(buf.length).toBeGreaterThan(1000);
+ expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
+ // PDF must end with %%EOF (or trailing newline + %%EOF)
+ const tail = buf.slice(-32).toString('ascii');
+ expect(tail).toContain('%%EOF');
+ });
+
+ test('PDF contains the license code (visible text)', async () => {
+ if (typeof pdfParse !== 'function') return; // soft skip if pdf-parse unavailable
+ const buf = await invoice.renderInvoicePdf(BASE);
+ const { text } = await pdfParse(buf);
+ expect(text).toContain('DC-PRO-30D-AB12CD34');
+ });
+
+ test('PDF contains the invoice number and amount', async () => {
+ if (typeof pdfParse !== 'function') return;
+ const buf = await invoice.renderInvoicePdf(BASE);
+ const { text } = await pdfParse(buf);
+ expect(text).toContain('INV-4F2C9B3A');
+ expect(text).toContain('20.00');
+ });
+
+ test('PDF includes customer name and email in bill-to', async () => {
+ if (typeof pdfParse !== 'function') return;
+ const buf = await invoice.renderInvoicePdf(BASE);
+ const { text } = await pdfParse(buf);
+ expect(text).toContain('Alice Johnson');
+ expect(text).toContain('alice@example.com');
+ });
+
+ test('rejects when code is missing', () => {
+ // The invoice builder now returns a rejected promise for invalid input
+ // (validated synchronously, surfaced via Promise.reject before any PDFKit
+ // allocation). Use .rejects for the async side and the sync-style
+ // expect().toThrow for the inline check.
+ return expect(invoice.renderInvoicePdf({ ...BASE, code: '' }))
+ .rejects.toThrow('code is required');
+ });
+ });
+
+ describe('catalog integration', () => {
+ test('all 4 catalog products render without throwing', async () => {
+ const products = catalog.listProducts();
+ for (const product of products) {
+ const input = {
+ ...BASE,
+ productId: product.id,
+ productLabel: product.label,
+ durationDays: product.durationDays,
+ amountCents: product.amountCents,
+ };
+ const { subject, html } = invoice.renderLicenseEmailHtml(input);
+ expect(subject).toContain(`${product.durationDays} days`);
+ expect(html).toContain(`$${(product.amountCents / 100).toFixed(2)}`);
+
+ const pdf = await invoice.renderInvoicePdf(input);
+ expect(pdf.slice(0, 4).toString('ascii')).toBe('%PDF');
+
+ if (typeof pdfParse === 'function') {
+ const { text } = await pdfParse(pdf);
+ expect(text).toContain(product.label);
+ }
+ }
+ });
+ });
+
+ describe('security: XSS via customer-controlled fields', () => {
+ // These should all escape, not execute. We don't render the email
+ // anywhere โ this is just defense-in-depth at the template layer.
+ test.each([
+ ['customerName', '
'],
+ ['email', '">'],
+ ['code', '">'],
+ ['eventId', '">'],
+ ['sessionId', '">'],
+ ])('field %s XSS payload is escaped', async (field, payload) => {
+ const { html } = invoice.renderLicenseEmailHtml({ ...BASE, [field]: payload });
+ // The exact attack strings must not appear unescaped.
+ expect(html).not.toContain(payload);
+ // Escaped versions should be present (defense-in-depth visible).
+ expect(html).toContain('<');
+ });
+
+ test('img tag with onerror handler is fully escaped', () => {
+ const { html } = invoice.renderLicenseEmailHtml({
+ ...BASE,
+ customerName: '
',
+ });
+ // The payload is HTML-escaped: < and > become < / >
+ expect(html).toContain('<img src=x onerror=alert(1)>');
+ // The dangerous literal pattern must not appear.
+ expect(html).not.toMatch(/
]+onerror/i);
+ });
+ });
+});
\ No newline at end of file
diff --git a/dashcaddy-api/__tests__/billing/stripe-license-bridge.test.js b/dashcaddy-api/__tests__/billing/stripe-license-bridge.test.js
index 1289eae..8c3de96 100644
--- a/dashcaddy-api/__tests__/billing/stripe-license-bridge.test.js
+++ b/dashcaddy-api/__tests__/billing/stripe-license-bridge.test.js
@@ -520,3 +520,221 @@ describe('stripe-license-bridge constants', () => {
expect(bridge.DELIVERY_LEASE_MS).toBeGreaterThan(0);
});
});
+
+describe('stripe-license-bridge invoice + email rendering (DC-058)', () => {
+ // These tests verify the bridge actually invokes the invoice renderer
+ // with the right inputs and that the SMTP send receives a multipart
+ // body + a PDF attachment. Pairs with invoice.test.js (which tests the
+ // rendering primitives in isolation).
+
+ test('passes customerName, sessionId, and amount through to the renderer', async () => {
+ const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
+ injectSmtp(sendMailMock);
+ process.env.SMTP_HOST = 'smtp.test';
+ process.env.SMTP_FROM = 'billing@dashcaddy.test';
+
+ const event = buildSessionEvent({
+ productId: 'pro-90d',
+ customerEmail: 'alice@example.com',
+ });
+ // Add customer_details.name + amount_total in line_items[0] (like real Stripe).
+ event.data.object.customer_details.name = 'Alice Johnson';
+ event.data.object.line_items = {
+ data: [{ amount_total: 5000, price: { id: 'price_90d_test', unit_amount: 5000 }, currency: 'usd' }],
+ };
+
+ const { rawBody, signatureHeader } = buildSignedPayload(event);
+ const result = await bridge.handleWebhook({ rawBody, signatureHeader });
+ expect(result.status).toBe(200);
+ expect(result.body.delivered).toBe(true);
+ expect(result.body.deliveredVia).toBe('smtp');
+
+ // Verify the SMTP send was called with branded email + PDF attachment.
+ expect(sendMailMock).toHaveBeenCalledTimes(1);
+ const mailArgs = sendMailMock.mock.calls[0][0];
+ expect(mailArgs.from).toBe('billing@dashcaddy.test');
+ expect(mailArgs.to).toBe('alice@example.com');
+ // Subject contains duration and "invoice".
+ expect(mailArgs.subject).toContain('DashCaddy Pro');
+ expect(mailArgs.subject).toContain('invoice');
+ // HTML + text both present (multipart/alternative).
+ expect(mailArgs.text).toBeDefined();
+ expect(mailArgs.html).toBeDefined();
+ expect(mailArgs.html).toContain('Hi Alice'); // first name from customer_details.name
+ expect(mailArgs.html).toContain('INV-'); // invoice number
+ expect(mailArgs.html).toContain('$50.00'); // 90d tier price
+ // PDF attachment present.
+ expect(Array.isArray(mailArgs.attachments)).toBe(true);
+ expect(mailArgs.attachments).toHaveLength(1);
+ expect(mailArgs.attachments[0].filename).toMatch(/^DashCaddy-Pro-Invoice-INV-.+\.pdf$/);
+ expect(mailArgs.attachments[0].contentType).toBe('application/pdf');
+ expect(mailArgs.attachments[0].encoding).toBe('base64');
+ expect(mailArgs.attachments[0].content.length).toBeGreaterThan(1000); // real PDF
+ // PDF magic bytes.
+ expect(mailArgs.attachments[0].content.slice(0, 4).toString('ascii')).toBe('%PDF');
+ });
+
+ test('falls back to catalog amount when line_items are missing', async () => {
+ const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
+ injectSmtp(sendMailMock);
+ process.env.SMTP_HOST = 'smtp.test';
+ process.env.SMTP_FROM = 'billing@dashcaddy.test';
+
+ const event = buildSessionEvent({ productId: 'pro-365d' });
+ // Strip line_items entirely (simulates a webhook without expansion).
+ delete event.data.object.line_items;
+ delete event.data.object.amount_total;
+ // Strip customer_details.name to verify "Hi there," fallback.
+ delete event.data.object.customer_details.name;
+
+ const { rawBody, signatureHeader } = buildSignedPayload(event);
+ const result = await bridge.handleWebhook({ rawBody, signatureHeader });
+ expect(result.status).toBe(200);
+
+ const mailArgs = sendMailMock.mock.calls[0][0];
+ // Falls back to catalog: pro-365d is $99.00.
+ expect(mailArgs.html).toContain('$99.00');
+ expect(mailArgs.html).toContain('Hi there,');
+ });
+
+ test('dev-console fallback logs invoice number + PDF size', async () => {
+ const event = buildSessionEvent({ productId: 'pro-30d' });
+ event.data.object.customer_details.name = 'Bob';
+ const { rawBody, signatureHeader } = buildSignedPayload(event);
+ const result = await bridge.handleWebhook({ rawBody, signatureHeader });
+ expect(result.status).toBe(200);
+ expect(result.body.deliveredVia).toBe('dev-console');
+ // We can't easily assert on log output from here, but the status proves
+ // the dev-console path was taken. The log line includes pdfBytes โ
+ // covered indirectly by invoice.test.js verifying the PDF size.
+ });
+
+ test('uses claim createdAt as issuedAt (not now) for stable retry semantics', async () => {
+ const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
+ injectSmtp(sendMailMock);
+ process.env.SMTP_HOST = 'smtp.test';
+ process.env.SMTP_FROM = 'billing@dashcaddy.test';
+
+ const event = buildSessionEvent({ productId: 'pro-30d' });
+ const { rawBody, signatureHeader } = buildSignedPayload(event);
+ const result = await bridge.handleWebhook({ rawBody, signatureHeader });
+ expect(result.status).toBe(200);
+
+ const mailArgs = sendMailMock.mock.calls[0][0];
+ // The "Issued" line must reflect the claim's createdAt (which is when
+ // the customer paid), not the moment we sent the email.
+ expect(mailArgs.html).toMatch(/Issued[\s\S]*?\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC/);
+ });
+
+ test('gracefully degrades to text-only email when PDF render fails', async () => {
+ const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
+ injectSmtp(sendMailMock);
+ process.env.SMTP_HOST = 'smtp.test';
+ process.env.SMTP_FROM = 'billing@dashcaddy.test';
+
+ // Force PDF render to throw by passing an invalid issuedAt โ this
+ // exercises the try/catch around renderInvoicePdf and verifies the
+ // bridge still sends a text+HTML email without the attachment.
+ // (PDFKit auto-escapes most non-ASCII; lone surrogates no longer
+ // throw on this PDFKit version. Bad dates remain a real crash path.)
+ const event = buildSessionEvent({ productId: 'pro-30d' });
+ // Override issuedAt to an invalid date via the bridge's deliverCode arg.
+ // The bridge forwards this from the invoice module, which we can stub
+ // at module level for this test.
+ const invoiceMod = require('../../src/billing/invoice');
+ const originalRender = invoiceMod.renderInvoicePdf;
+ invoiceMod.renderInvoicePdf = jest.fn().mockRejectedValue(new Error('simulated PDF render failure'));
+ try {
+ const { rawBody, signatureHeader } = buildSignedPayload(event);
+ const result = await bridge.handleWebhook({ rawBody, signatureHeader });
+ expect(result.status).toBe(200);
+ expect(result.body.delivered).toBe(true);
+ expect(sendMailMock).toHaveBeenCalledTimes(1);
+ const mailArgs = sendMailMock.mock.calls[0][0];
+ // No PDF attachment when render failed.
+ expect(mailArgs.attachments).toBeUndefined();
+ // Text + HTML still sent (keygen mock uses DC-TEST-... in this suite).
+ expect(mailArgs.text).toMatch(/DC-(PRO|TEST)-/);
+ expect(mailArgs.html).toContain('DashCaddy');
+ } finally {
+ invoiceMod.renderInvoicePdf = originalRender;
+ }
+ });
+
+ test('replay (layer-1 event-id idempotency) does NOT re-render the invoice', async () => {
+ const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
+ injectSmtp(sendMailMock);
+ process.env.SMTP_HOST = 'smtp.test';
+ process.env.SMTP_FROM = 'billing@dashcaddy.test';
+
+ const event = buildSessionEvent({ productId: 'pro-30d' });
+ const { rawBody, signatureHeader } = buildSignedPayload(event);
+ const sessionId = event.data.object.id;
+
+ // First delivery โ generates a new license + invoice.
+ const first = await bridge.handleWebhook({ rawBody, signatureHeader });
+ expect(first.body.delivered).toBe(true);
+ expect(first.body.codeId).toBeDefined();
+ const firstCodeId = first.body.codeId;
+ expect(sendMailMock).toHaveBeenCalledTimes(1);
+
+ // Second delivery of the SAME event โ should be deduplicated by event id
+ // at the layer-1 check (bridge.checkEventIdempotency). SMTP must NOT be
+ // called again because Stripe retrying the same event ID should never
+ // re-send the invoice.
+ const second = await bridge.handleWebhook({ rawBody, signatureHeader });
+ expect(second.body.delivered).toBe(true);
+ expect(second.body.deduplicated).toBe(true);
+ expect(sendMailMock).toHaveBeenCalledTimes(1);
+ });
+
+ test('layer-2 (different event, same session) does NOT re-send the invoice', async () => {
+ // Stripe can send BOTH `checkout.session.completed` AND
+ // `checkout.session.async_payment_succeeded` for the same Checkout Session
+ // (delayed-payment methods). Layer-1 dedup doesn't catch this because
+ // the event IDs differ โ only the session ID is the same. The bridge
+ // MUST recognize that delivery already happened via the OTHER event and
+ // ack 200 without re-sending.
+ const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
+ injectSmtp(sendMailMock);
+ process.env.SMTP_HOST = 'smtp.test';
+ process.env.SMTP_FROM = 'billing@dashcaddy.test';
+
+ const sessionId = `cs_test_layer2_${crypto.randomBytes(4).toString('hex')}`;
+ const eventA = buildSessionEvent({
+ productId: 'pro-30d',
+ sessionId,
+ eventId: `evt_A_${crypto.randomBytes(4).toString('hex')}`,
+ });
+ eventA.type = 'checkout.session.completed';
+
+ const eventB = buildSessionEvent({
+ productId: 'pro-30d',
+ sessionId,
+ eventId: `evt_B_${crypto.randomBytes(4).toString('hex')}`,
+ });
+ eventB.type = 'checkout.session.async_payment_succeeded';
+
+ // First event: completes the payment, sends the invoice.
+ const sigA = buildSignedPayload(eventA);
+ const resultA = await bridge.handleWebhook({ rawBody: sigA.rawBody, signatureHeader: sigA.signatureHeader });
+ expect(resultA.status).toBe(200);
+ expect(resultA.body.delivered).toBe(true);
+ expect(resultA.body.deduplicated).toBeUndefined();
+ expect(sendMailMock).toHaveBeenCalledTimes(1);
+ const firstInvoice = sendMailMock.mock.calls[0][0].attachments[0].filename;
+
+ // Second event for the SAME session: must NOT re-send (different event
+ // id, so layer-1 dedup doesn't catch it; layer-2 must).
+ const sigB = buildSignedPayload(eventB);
+ const resultB = await bridge.handleWebhook({ rawBody: sigB.rawBody, signatureHeader: sigB.signatureHeader });
+ expect(resultB.status).toBe(200);
+ expect(resultB.body.delivered).toBe(true);
+ expect(resultB.body.deduplicated).toBe(true);
+ // CRITICAL: only ONE SMTP call. Two invoice emails with different invoice
+ // numbers for one charge is a financial-document bug.
+ expect(sendMailMock).toHaveBeenCalledTimes(1);
+ const secondInvoice = sendMailMock.mock.calls[0][0].attachments[0].filename;
+ expect(secondInvoice).toBe(firstInvoice); // same invoice number
+ });
+});
diff --git a/dashcaddy-api/package-lock.json b/dashcaddy-api/package-lock.json
index 56a8b69..3b962a5 100644
--- a/dashcaddy-api/package-lock.json
+++ b/dashcaddy-api/package-lock.json
@@ -21,6 +21,7 @@
"lru-cache": "^10.4.3",
"nodemailer": "^8.0.4",
"otplib": "^12.0.1",
+ "pdfkit": "^0.15.2",
"png-to-ico": "^2.1.8",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.3",
@@ -33,8 +34,12 @@
"devDependencies": {
"eslint": "^8.57.1",
"jest": "^29.7.0",
+ "pdf-parse": "^1.1.4",
"prettier": "^3.8.1",
"supertest": "^6.3.4"
+ },
+ "engines": {
+ "node": ">=20.0.0"
}
},
"node_modules/@babel/code-frame": {
@@ -1743,6 +1748,15 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
+ "node_modules/@swc/helpers": {
+ "version": "0.3.17",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz",
+ "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1990,6 +2004,22 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -2019,6 +2049,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -2263,6 +2308,24 @@
"node": ">=8"
}
},
+ "node_modules/brotli": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.1.2"
+ }
+ },
+ "node_modules/browserify-zlib": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "~1.0.5"
+ }
+ },
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -2367,6 +2430,24 @@
"node": ">= 0.8"
}
},
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -2515,6 +2596,15 @@
"node": ">=12"
}
},
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -2800,6 +2890,13 @@
"node": "*"
}
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.",
+ "license": "MIT"
+ },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -2850,6 +2947,38 @@
}
}
},
+ "node_modules/deep-equal": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
+ "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.0",
+ "call-bind": "^1.0.5",
+ "es-get-iterator": "^1.1.3",
+ "get-intrinsic": "^1.2.2",
+ "is-arguments": "^1.1.1",
+ "is-array-buffer": "^3.0.2",
+ "is-date-object": "^1.0.5",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "isarray": "^2.0.5",
+ "object-is": "^1.1.5",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.4",
+ "regexp.prototype.flags": "^1.5.1",
+ "side-channel": "^1.0.4",
+ "which-boxed-primitive": "^1.0.2",
+ "which-collection": "^1.0.1",
+ "which-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2867,6 +2996,40 @@
"node": ">=0.10.0"
}
},
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -2926,6 +3089,12 @@
"wrappy": "1"
}
},
+ "node_modules/dfa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
+ "license": "MIT"
+ },
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@@ -3116,6 +3285,26 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-get-iterator": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
+ "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.3",
+ "has-symbols": "^1.0.3",
+ "is-arguments": "^1.1.1",
+ "is-map": "^2.0.2",
+ "is-set": "^2.0.2",
+ "is-string": "^1.0.7",
+ "isarray": "^2.0.5",
+ "stop-iteration-iterator": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -3735,6 +3924,38 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/fontkit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz",
+ "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==",
+ "license": "MIT",
+ "dependencies": {
+ "@swc/helpers": "^0.3.13",
+ "brotli": "^1.3.2",
+ "clone": "^2.1.2",
+ "deep-equal": "^2.0.5",
+ "dfa": "^1.2.0",
+ "restructure": "^2.0.1",
+ "tiny-inflate": "^1.0.3",
+ "unicode-properties": "^1.3.1",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
@@ -3835,6 +4056,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -4003,6 +4233,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -4013,6 +4255,18 @@
"node": ">=8"
}
},
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -4029,7 +4283,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -4222,6 +4475,20 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -4231,6 +4498,39 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -4238,12 +4538,55 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
"license": "MIT"
},
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -4260,6 +4603,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -4302,6 +4661,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -4312,6 +4683,22 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@@ -4322,6 +4709,51 @@
"node": ">=8"
}
},
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@@ -4335,6 +4767,39 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-unsafe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
@@ -4347,6 +4812,40 @@
],
"license": "MIT"
},
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -5052,6 +5551,13 @@
"node": ">= 20"
}
},
+ "node_modules/jpeg-exif": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
+ "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT"
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5240,6 +5746,25 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/linebreak": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
+ "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "0.0.8",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/linebreak/node_modules/base64-js": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
+ "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -5559,6 +6084,13 @@
"node": ">=10.5.0"
}
},
+ "node_modules/node-ensure": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
+ "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
@@ -5647,6 +6179,51 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/object-is": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
+ "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -5774,6 +6351,12 @@
"node": ">=6"
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5878,6 +6461,36 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
+ "node_modules/pdf-parse": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz",
+ "integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-ensure": "^0.0.0"
+ },
+ "engines": {
+ "node": ">=6.8.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/mehmet-kozan"
+ }
+ },
+ "node_modules/pdfkit": {
+ "version": "0.15.2",
+ "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.15.2.tgz",
+ "integrity": "sha512-s3GjpdBFSCaeDSX/v73MI5UsPqH1kjKut2AXCgxQ5OH10lPVOu5q5vLAG0OCpz/EYqKsTSw1WHpENqMvp43RKg==",
+ "license": "MIT",
+ "dependencies": {
+ "crypto-js": "^4.2.0",
+ "fontkit": "^1.8.1",
+ "jpeg-exif": "^1.1.4",
+ "linebreak": "^1.0.2",
+ "png-js": "^1.0.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5921,6 +6534,14 @@
"node": ">=8"
}
},
+ "node_modules/png-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
+ "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
+ "dependencies": {
+ "browserify-zlib": "^0.2.0"
+ }
+ },
"node_modules/png-to-ico": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/png-to-ico/-/png-to-ico-2.1.8.tgz",
@@ -5953,6 +6574,15 @@
"node": ">=12.13.0"
}
},
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6298,6 +6928,26 @@
"node": ">= 6"
}
},
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -6373,6 +7023,12 @@
"node": ">=10"
}
},
+ "node_modules/restructure": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz",
+ "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==",
+ "license": "MIT"
+ },
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
@@ -6454,6 +7110,23 @@
],
"license": "MIT"
},
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -6530,6 +7203,38 @@
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -6811,6 +7516,19 @@
"node": ">= 0.8"
}
},
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -7056,6 +7774,12 @@
"node": ">=0.2.6"
}
},
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "license": "MIT"
+ },
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -7089,8 +7813,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "0.14.5",
@@ -7159,6 +7882,32 @@
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
+ "node_modules/unicode-properties": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
+ "node_modules/unicode-trie/node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "license": "MIT"
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -7373,12 +8122,70 @@
"node": ">= 8"
}
},
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
+ "node_modules/which-typed-array": {
+ "version": "1.1.22",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
+ "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==",
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/dashcaddy-api/package.json b/dashcaddy-api/package.json
index 544266b..38726aa 100644
--- a/dashcaddy-api/package.json
+++ b/dashcaddy-api/package.json
@@ -35,6 +35,7 @@
"lru-cache": "^10.4.3",
"nodemailer": "^8.0.4",
"otplib": "^12.0.1",
+ "pdfkit": "^0.15.2",
"png-to-ico": "^2.1.8",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.3",
@@ -47,6 +48,7 @@
"devDependencies": {
"eslint": "^8.57.1",
"jest": "^29.7.0",
+ "pdf-parse": "^1.1.4",
"prettier": "^3.8.1",
"supertest": "^6.3.4"
}
diff --git a/dashcaddy-api/scripts/stripe-license-bridge.js b/dashcaddy-api/scripts/stripe-license-bridge.js
index 3bfd9c5..706878c 100644
--- a/dashcaddy-api/scripts/stripe-license-bridge.js
+++ b/dashcaddy-api/scripts/stripe-license-bridge.js
@@ -106,6 +106,7 @@ const path = require('path');
const { generateCodes, loadSecret } = require('../license-keygen');
const platformPaths = require('../platform-paths');
const catalog = require('../src/billing/catalog');
+const invoice = require('../src/billing/invoice');
const { createFulfillmentStore, DELIVERY_LEASE_MS } = require('../src/billing/fulfillment-store');
// โโ Configuration (env-driven) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -244,33 +245,69 @@ function eventSeen(eventId) {
// โโ Email delivery โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/**
- * Send the license key email. If SMTP is configured, real send via
+ * Send the license key + invoice email. If SMTP is configured, real send via
* nodemailer; if not, log the full email body to stdout so the operator
* can deliver manually in dev/test environments.
*
+ * The email is multipart/alternative (text + HTML, matching the same
+ * branded content) with a branded PDF invoice attached. Rendered by
+ * src/billing/invoice.js โ see that module for the security/escape rules.
+ *
* Returns { delivered: bool, via: 'smtp' | 'dev-console' }.
*/
-async function deliverCode({ to, code, durationDays, eventId, productId }) {
- const subject = `Your DashCaddy Pro license (${durationDays} days)`;
- const text = [
- 'Thank you for purchasing DashCaddy Pro.',
- '',
- `Your license key is valid for ${durationDays} days:`,
- '',
- ` ${code}`,
- '',
- 'To install on your DashCaddy host:',
- ' 1. Open https:///admin/license',
- ' 2. Paste the key into the "Activate license" field',
- ' 3. Submit โ Pro features unlock immediately.',
- '',
- 'The same key is also revealed on your purchase success page; keep it safe.',
- '',
- 'Need help? Reply to this email and we will assist.',
- '',
- `Reference: ${eventId}`,
- `Product: ${productId}`,
- ].join('\n');
+async function deliverCode({ to, code, durationDays, eventId, productId, customerName, sessionId, amountCents, currency, supportUrl, issuedAt }) {
+ const product = catalog.getProduct(productId);
+ if (!product) {
+ // Should never happen โ catalog resolution happens upstream. Defensive
+ // throw so the operator notices misconfiguration instead of silently
+ // sending a half-blank invoice.
+ throw new Error(`deliverCode: unknown productId ${productId}`);
+ }
+
+ const invoiceInput = {
+ email: to,
+ customerName: customerName || '',
+ code,
+ durationDays,
+ productLabel: product.label,
+ productId: product.id,
+ amountCents: amountCents != null ? amountCents : product.amountCents,
+ currency: currency || 'USD',
+ eventId,
+ sessionId: sessionId || '',
+ supportUrl: supportUrl || 'https://dashcaddy.net',
+ issuedAt: issuedAt || new Date().toISOString(),
+ };
+
+ const { subject, html } = invoice.renderLicenseEmailHtml(invoiceInput);
+ const text = invoice.renderLicenseEmailText(invoiceInput);
+
+ // PDF generation can throw on poison-pill inputs that survive sanitization
+ // (e.g. lone surrogates in customer name that PDFKit's WinAnsi encoder
+ // rejects, or malformed `issuedAt` after the bridge passes a bad value).
+ // We try the PDF and degrade gracefully: send text+HTML WITHOUT the PDF
+ // attachment so the customer still gets the license + invoice link rather
+ // than nothing. The fulfillment record still marks `delivered` โ the
+ // license was persisted upstream, so lookup always works regardless.
+ let pdfBuffer = null;
+ let pdfError = null;
+ try {
+ pdfBuffer = await invoice.renderInvoicePdf(invoiceInput);
+ } catch (err) {
+ pdfError = err;
+ log('warn', 'pdf-render-failed-degrading-to-text-only', {
+ eventId, sessionId, error: err.message,
+ });
+ }
+
+ // Sanitize the PDF filename โ event id has Stripe's prefix and underscores
+ // which are safe, but we constrain the charset anyway for attachment
+ // parsers that may be picky.
+ const safeInvoiceNumber = invoice.sanitizeFilenameSegment(
+ invoice.generateInvoiceNumber(eventId),
+ 'invoice'
+ );
+ const attachmentFilename = `DashCaddy-Pro-Invoice-${safeInvoiceNumber}.pdf`;
const smtp = _smtpConfig();
if (!smtp.host || !smtp.from) {
@@ -281,7 +318,10 @@ async function deliverCode({ to, code, durationDays, eventId, productId }) {
// operator seeing the bridge logs IS the documented delivery path
// when SMTP is unconfigured. In production, the bridge refuses to
// boot without SMTP configured (see checkFatalConfig).
- log('info', 'smtp-not-configured, falling back to dev-console delivery', { to, durationDays, code });
+ log('info', 'smtp-not-configured, falling back to dev-console delivery', {
+ to, durationDays, code, invoiceNumber: safeInvoiceNumber,
+ pdfBytes: pdfBuffer ? pdfBuffer.length : null, pdfError: pdfError && pdfError.message,
+ });
return { delivered: true, via: 'dev-console' };
}
@@ -294,7 +334,24 @@ async function deliverCode({ to, code, durationDays, eventId, productId }) {
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
tls: { rejectUnauthorized: process.env.NODE_ENV === 'production' },
});
- await transporter.sendMail({ from: smtp.from, to, subject, text });
+ const mailArgs = {
+ from: smtp.from,
+ to,
+ subject,
+ text,
+ html,
+ };
+ if (pdfBuffer) {
+ mailArgs.attachments = [
+ {
+ filename: attachmentFilename,
+ content: pdfBuffer,
+ contentType: 'application/pdf',
+ encoding: 'base64',
+ },
+ ];
+ }
+ await transporter.sendMail(mailArgs);
return { delivered: true, via: 'smtp' };
}
@@ -464,6 +521,57 @@ async function fulfillCheckout({ id, session }) {
const sessionId = session.id || '';
if (!sessionId) return { status: 400, body: { delivered: false, reason: 'missing-session-id' } };
+ // Stripe sends the customer's name on `customer_details.name` for hosted
+ // Checkout (sometimes blank โ they may have entered only an email). We
+ // pass it through to the invoice renderer for the "Hi " greeting
+ // and the bill-to block.
+ const customerName = (session.customer_details && session.customer_details.name) || '';
+
+ // Amount comes from the session's line_items (Stripe Checkout totals).
+ // Older sessions may not have line_items expanded โ fall back to the
+ // session amount_total, then to the catalog amount so the invoice is
+ // never blank. The invoice is a financial document โ we ALWAYS render
+ // the catalog's canonical amount when Stripe doesn't tell us a different
+ // one, because the catalog is the single source of truth for DashCaddy's
+ // pricing. This prevents Stripe Checkout config drift (e.g. a test
+ // coupon, a multi-seat plan we don't support) from producing invoices
+ // that don't match the user's actual entitlement.
+ let amountCents = null;
+ let currency = (session.currency || 'USD').toString().toUpperCase();
+ const lineItems = session.line_items && session.line_items.data;
+ if (Array.isArray(lineItems) && lineItems.length > 0) {
+ // Sum ALL line items, not just lineItems[0]. The previous version
+ // silently dropped quantity > 1 or multi-item carts, producing
+ // invoices whose total didn't match the Stripe charge. session.amount_total
+ // does this automatically too, but reading line items ourselves lets us
+ // log a warning when Stripe's amount_total disagrees with the line-item
+ // sum (indicative of a Stripe-side bug or tampering).
+ const sumFromLineItems = lineItems.reduce((acc, item) => {
+ if (item && item.amount_total != null) return acc + item.amount_total;
+ if (item && item.price && item.price.unit_amount != null) return acc + item.price.unit_amount;
+ return acc;
+ }, 0);
+ if (sumFromLineItems > 0) amountCents = sumFromLineItems;
+ if (lineItems[0] && lineItems[0].currency) currency = lineItems[0].currency.toUpperCase();
+ }
+ if (amountCents == null && session.amount_total != null) {
+ amountCents = session.amount_total;
+ }
+ // Final fallback: catalog's canonical price for this product. This is
+ // the single source of truth โ if Stripe sends 0 or NaN, we render the
+ // catalog price rather than a $0.00 invoice for a real charge.
+ if (amountCents == null || !Number.isFinite(amountCents) || amountCents <= 0) {
+ log('warn', 'amount-fell-back-to-catalog', {
+ eventId: id, sessionId, stripeAmountCents: amountCents, catalogAmountCents: product.amountCents,
+ });
+ amountCents = product.amountCents;
+ }
+ // Currency must always be a 3-letter ISO code; sanitize otherwise.
+ if (!/^[A-Z]{3}$/.test(currency)) {
+ log('warn', 'invalid-currency-from-stripe', { eventId: id, sessionId, stripeCurrency: currency });
+ currency = 'USD';
+ }
+
const claim = await fulfillmentStore.claim({
eventId: id, sessionId, productId: product.id, durationDays, email,
});
@@ -479,10 +587,49 @@ async function fulfillCheckout({ id, session }) {
if (deliveryClaim.busy) {
return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
}
+ // Layer-2 delivery idempotency: if the claim was NOT successful AND the
+ // record is already `delivered`, an earlier event (or this same event via
+ // layer-1) already produced an invoice email. Stripe may legitimately send
+ // `checkout.session.completed` AND `checkout.session.async_payment_succeeded`
+ // for the same Checkout Session (delayed-payment methods). Without this
+ // guard the customer receives TWO invoice emails with TWO different
+ // invoice numbers for one charge. Ack 200 so Stripe stops retrying.
+ if (deliveryClaim.claimed === false
+ && deliveryClaim.record
+ && deliveryClaim.record.status === 'delivered') {
+ log('info', 'delivery-already-completed', {
+ eventId: id, sessionId, ownerEventId: deliveryClaim.record.eventId,
+ });
+ return {
+ status: 200,
+ body: {
+ delivered: true,
+ deduplicated: true,
+ codeId: deliveryClaim.record.codeId,
+ productId: deliveryClaim.record.productId,
+ durationDays: deliveryClaim.record.durationDays,
+ deliveredVia: deliveryClaim.record.deliveredVia || 'smtp',
+ },
+ };
+ }
let delivery;
try {
- delivery = await deliverCode({ to: email, code, durationDays, eventId: id, productId: product.id });
+ delivery = await deliverCode({
+ to: email,
+ code,
+ durationDays,
+ eventId: id,
+ productId: product.id,
+ customerName,
+ sessionId,
+ amountCents,
+ currency,
+ // Pin issuedAt to the ORIGINAL claim's createdAt so a retry days later
+ // renders the same "Issued" date. Falls back to now() for first-time.
+ issuedAt: claim.record && claim.record.createdAt,
+ supportUrl: process.env.DASHCADDY_SUPPORT_URL || 'https://dashcaddy.net',
+ });
} catch (err) {
log('error', 'email-delivery-failed', { eventId: id, sessionId, error: err.message });
await fulfillmentStore.markDeliveryFailed({ sessionId, ownerToken: id, error: err.message });
diff --git a/dashcaddy-api/src/billing/invoice.js b/dashcaddy-api/src/billing/invoice.js
new file mode 100644
index 0000000..9c3c62b
--- /dev/null
+++ b/dashcaddy-api/src/billing/invoice.js
@@ -0,0 +1,643 @@
+'use strict';
+
+/**
+ * DashCaddy Stripe invoice + license email rendering.
+ *
+ * Three responsibilities, all pure (no I/O, no SMTP, no Stripe SDK):
+ *
+ * 1. `renderLicenseEmailHtml({ ... })` โ branded HTML email body. Dark navy
+ * theme matching dashcaddy.net / status.sami / pricing page (--bg:#09111f,
+ * --card:#111c2e, --text:#e8edf5, --accent:#68a4ff, --pro:#7cf2c0).
+ * Inline CSS only โ no