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