diff --git a/package-lock.json b/package-lock.json index 5dadd9e..04ad715 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.4.0", "@stellar/stellar-sdk": "^14.5.0", + "@types/pdfkit": "^0.17.6", "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.5.0", @@ -19,6 +20,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.1", + "pdfkit": "^0.19.1", "pg": "^8.14.1", "reflect-metadata": "^0.2.2", "resend": "^6.14.0", @@ -1995,6 +1997,18 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", @@ -2381,6 +2395,15 @@ "node": ">=20.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -2636,6 +2659,15 @@ "@types/node": "*" } }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -3604,6 +3636,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.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -4063,6 +4113,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "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", @@ -4380,6 +4439,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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -5017,8 +5082,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -5190,6 +5254,23 @@ } } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "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", @@ -6683,6 +6764,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6838,6 +6925,25 @@ "node": ">=10" } }, + "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", @@ -7485,6 +7591,12 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, + "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", @@ -7581,6 +7693,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -7784,6 +7910,14 @@ "pathe": "^2.0.3" } }, + "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/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", @@ -8229,6 +8363,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -8925,6 +9065,12 @@ "node": "*" } }, + "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/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -9378,6 +9524,32 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, + "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", diff --git a/package.json b/package.json index 47ef8b5..3bc5f73 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.4.0", "@stellar/stellar-sdk": "^14.5.0", + "@types/pdfkit": "^0.17.6", "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.5.0", @@ -37,6 +38,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.1", + "pdfkit": "^0.19.1", "pg": "^8.14.1", "reflect-metadata": "^0.2.2", "resend": "^6.14.0", diff --git a/src/controllers/invoice.controllers.ts b/src/controllers/invoice.controllers.ts index 31343f4..880dc91 100644 --- a/src/controllers/invoice.controllers.ts +++ b/src/controllers/invoice.controllers.ts @@ -2,11 +2,14 @@ import { Request, Response } from 'express'; import { createInvoice, getInvoice, + getInvoiceWithMerchant, listInvoices, voidInvoice, } from '../services/invoice.services.js'; import { parseInvoiceListQuery, validateCreateInvoice } from '../utils/invoice.validation.js'; import { AppError } from '../utils/errors.js'; +import { generateInvoicePdf } from '../services/invoice-pdf.services.js'; +import { sendInvoiceEmail } from '../services/email.service.js'; export const createInvoiceController = async (req: Request, res: Response): Promise => { const merchant = req.merchant; @@ -93,3 +96,55 @@ export const voidInvoiceController = async (req: Request, res: Response): Promis res.status(500).json({ error: 'Internal Server Error' }); } }; + +export const getInvoicePdfController = async (req: Request, res: Response): Promise => { + const merchant = req.merchant; + if (!merchant) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const invoice = await getInvoiceWithMerchant(merchant.id, req.params.id); + const pdf = await generateInvoicePdf(invoice, invoice.merchant); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="invoice-${invoice.paymentSlug}.pdf"`, + ); + res.status(200).send(pdf); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +export const sendInvoiceController = async (req: Request, res: Response): Promise => { + const merchant = req.merchant; + if (!merchant) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const invoice = await getInvoiceWithMerchant(merchant.id, req.params.id); + + if (!invoice.email) { + res.status(400).json({ error: 'Invoice has no email on file' }); + return; + } + + await sendInvoiceEmail(invoice, invoice.merchant); + res.status(200).json({ message: 'Invoice email sent' }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + res.status(500).json({ error: 'Internal Server Error' }); + } +}; diff --git a/src/controllers/pay.controllers.ts b/src/controllers/pay.controllers.ts index 8557df8..ab0cf30 100644 --- a/src/controllers/pay.controllers.ts +++ b/src/controllers/pay.controllers.ts @@ -1,6 +1,11 @@ import { Request, Response } from 'express'; -import { resolveInvoiceBySlug, confirmPayment } from '../services/pay.services.js'; +import { + resolveInvoiceBySlug, + confirmPayment, + getInvoiceForPdfBySlug, +} from '../services/pay.services.js'; import { AppError } from '../utils/errors.js'; +import { generateInvoicePdf } from '../services/invoice-pdf.services.js'; export const resolveInvoiceController = async (req: Request, res: Response): Promise => { try { @@ -20,6 +25,31 @@ export const resolveInvoiceController = async (req: Request, res: Response): Pro } }; +export const getInvoicePdfController = async (req: Request, res: Response): Promise => { + try { + const { slug } = req.params; + const invoice = await getInvoiceForPdfBySlug(slug); + const pdf = await generateInvoicePdf(invoice, invoice.merchant); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="invoice-${invoice.paymentSlug}.pdf"`, + ); + res.status(200).send(pdf); + } catch (error) { + if (error instanceof AppError) { + if (error.statusCode === 410 && error.message === 'expired') { + res.status(410).json({ reason: 'expired' }); + return; + } + res.status(error.statusCode).json({ error: error.message }); + return; + } + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + export const confirmPaymentController = async (req: Request, res: Response): Promise => { try { const { slug } = req.params; diff --git a/src/routes/invoice.routes.ts b/src/routes/invoice.routes.ts index 55dc446..facb0ac 100644 --- a/src/routes/invoice.routes.ts +++ b/src/routes/invoice.routes.ts @@ -2,7 +2,9 @@ import { Router } from 'express'; import { createInvoiceController, getInvoiceController, + getInvoicePdfController, listInvoicesController, + sendInvoiceController, voidInvoiceController, } from '../controllers/invoice.controllers.js'; import { authenticateMerchant } from '../middlewares/auth.middleware.js'; @@ -14,6 +16,8 @@ router.use(authenticateMerchant); router.post('/', createInvoiceController); router.get('/', listInvoicesController); router.get('/:id', getInvoiceController); +router.get('/:id/pdf', getInvoicePdfController); +router.post('/:id/send', sendInvoiceController); router.patch('/:id/void', voidInvoiceController); export default router; diff --git a/src/routes/pay.routes.ts b/src/routes/pay.routes.ts index 95b1b99..6c512d0 100644 --- a/src/routes/pay.routes.ts +++ b/src/routes/pay.routes.ts @@ -2,11 +2,13 @@ import { Router } from 'express'; import { resolveInvoiceController, confirmPaymentController, + getInvoicePdfController, } from '../controllers/pay.controllers.js'; const router = Router(); router.get('/:slug', resolveInvoiceController); +router.get('/:slug/pdf', getInvoicePdfController); router.post('/:slug/confirm', confirmPaymentController); export default router; diff --git a/src/services/email.service.ts b/src/services/email.service.ts index 5fd9716..825be5d 100644 --- a/src/services/email.service.ts +++ b/src/services/email.service.ts @@ -1,6 +1,13 @@ import nodemailer from 'nodemailer'; import { Resend } from 'resend'; import { environment } from '../config/environment.js'; +import type { Invoice, Merchant } from '@prisma/client'; +import { generateInvoicePdf } from './invoice-pdf.services.js'; + +export interface EmailAttachment { + filename: string; + content: Buffer; +} const escapeHtml = (value: string): string => value @@ -24,13 +31,19 @@ const buildOtpEmailContent = (firstName: string, code: string) => { return { subject, html, text }; }; -const sendViaResend = async (to: string, subject: string, html: string): Promise => { +const sendViaResend = async ( + to: string, + subject: string, + html: string, + attachments?: EmailAttachment[], +): Promise => { const resend = new Resend(environment.email.resendApiKey); const { error } = await resend.emails.send({ from: environment.email.from, to, subject, html, + attachments: attachments?.map(({ filename, content }) => ({ filename, content })), }); if (error) { @@ -43,6 +56,7 @@ const sendViaSmtp = async ( subject: string, html: string, text: string, + attachments?: EmailAttachment[], ): Promise => { const transporter = nodemailer.createTransport({ host: environment.email.smtp.host, @@ -60,6 +74,7 @@ const sendViaSmtp = async ( subject, html, text, + attachments, }); }; @@ -81,3 +96,49 @@ export const sendOtp = async (to: string, code: string, firstName: string): Prom console.log(`[OTP] Verification code ${code} sent to ${to} for ${firstName}`); } }; + +const buildInvoiceEmailContent = (invoice: Invoice, merchant: Merchant) => { + const merchantName = escapeHtml(merchant.businessName || 'Your merchant'); + const description = escapeHtml(invoice.description); + const subject = `Invoice from ${merchant.businessName || 'Shade'}: ${invoice.description}`; + const html = ` +

Hi,

+

${merchantName} has sent you an invoice for ${description}.

+

Amount: ${invoice.amount.toString()} ${escapeHtml(invoice.token)}

+

Status: ${invoice.status}

+

Your invoice is attached as a PDF.

+ `.trim(); + const text = `Hi,\n\n${merchant.businessName || 'Your merchant'} has sent you an invoice for ${invoice.description}.\n\nAmount: ${invoice.amount.toString()} ${invoice.token}\nStatus: ${invoice.status}\n\nYour invoice is attached as a PDF.`; + + return { subject, html, text }; +}; + +/** + * Emails the invoice to `invoice.email` with a freshly generated PDF attached. + * No-ops (does not throw) when the invoice has no email on file — callers + * that need to surface that as a user-facing error (e.g. the /send route) + * should check `invoice.email` before calling this. + */ +export const sendInvoiceEmail = async (invoice: Invoice, merchant: Merchant): Promise => { + if (!invoice.email) { + return; + } + + const pdf = await generateInvoicePdf(invoice, merchant); + const { subject, html, text } = buildInvoiceEmailContent(invoice, merchant); + const attachments: EmailAttachment[] = [ + { filename: `invoice-${invoice.paymentSlug}.pdf`, content: pdf }, + ]; + + switch (environment.email.provider) { + case 'resend': + await sendViaResend(invoice.email, subject, html, attachments); + return; + case 'smtp': + await sendViaSmtp(invoice.email, subject, html, text, attachments); + return; + case 'console': + default: + console.log(`[Invoice email] Invoice ${invoice.paymentSlug} (${pdf.length} byte PDF) sent`); + } +}; diff --git a/src/services/invoice-pdf.services.ts b/src/services/invoice-pdf.services.ts new file mode 100644 index 0000000..769570d --- /dev/null +++ b/src/services/invoice-pdf.services.ts @@ -0,0 +1,123 @@ +import PDFDocument from 'pdfkit'; +import type { Invoice, Merchant } from '@prisma/client'; + +const FIXED_FIAT = 'FIXED_FIAT'; + +// merchant.logo is a free-form string (set via the merchant profile API). Only a +// data: URI can be embedded without giving this "pure" renderer a network +// dependency, so a plain image URL is intentionally skipped rather than fetched. +const DATA_URI_IMAGE = /^data:image\/(png|jpe?g);base64,([a-z0-9+/=]+)$/i; + +const decodeLogo = (logo: string | null): Buffer | null => { + if (!logo) return null; + + const match = DATA_URI_IMAGE.exec(logo.trim()); + if (!match) return null; + + try { + return Buffer.from(match[2], 'base64'); + } catch { + return null; + } +}; + +const formatDate = (date: Date | null): string => { + if (!date) return '-'; + return date + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ' UTC'); +}; + +const formatFiatAmount = ( + fiatAmount: bigint, + fiatDecimals: number, + fiatCurrency: string, +): string => { + const decimals = Math.max(fiatDecimals, 0); + const divisor = 10n ** BigInt(decimals); + const whole = fiatAmount / divisor; + const fraction = fiatAmount % divisor; + + if (decimals === 0) { + return `${whole.toString()} ${fiatCurrency}`; + } + + const fractionStr = fraction.toString().padStart(decimals, '0'); + return `${whole.toString()}.${fractionStr} ${fiatCurrency}`; +}; + +const drawField = (doc: PDFKit.PDFDocument, label: string, value: string): void => { + doc.font('Helvetica-Bold').fontSize(10).text(label, { continued: true }); + doc.font('Helvetica').fontSize(10).text(` ${value}`); + doc.moveDown(0.5); +}; + +/** + * Renders an invoice + merchant pair to a PDF buffer. Pure and side-effect + * free: no database access, no filesystem or network writes. Callers are + * responsible for fetching the records; this only formats what it's given. + */ +export const generateInvoicePdf = (invoice: Invoice, merchant: Merchant): Promise => { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ size: 'A4', margin: 50 }); + const chunks: Buffer[] = []; + + doc.on('data', chunk => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + const logoBuffer = decodeLogo(merchant.logo); + if (logoBuffer) { + try { + doc.image(logoBuffer, { fit: [80, 80] }); + doc.moveDown(); + } catch (err) { + // Corrupt/undecodable image data — skip it rather than fail the render, + // but log so real failures (not just bad merchant uploads) stay visible. + console.error(`Failed to embed invoice logo for merchant ${merchant.id}`, err); + } + } + + doc + .font('Helvetica-Bold') + .fontSize(18) + .text(merchant.businessName || 'Invoice'); + doc.moveDown(); + + doc.font('Helvetica-Bold').fontSize(14).text('Invoice'); + doc.moveDown(0.5); + + doc.font('Helvetica').fontSize(11).text(invoice.description); + doc.moveDown(); + + drawField(doc, 'Amount:', `${invoice.amount.toString()} ${invoice.token}`); + + if ( + invoice.pricingMode === FIXED_FIAT && + invoice.fiatAmount !== null && + invoice.fiatCurrency !== null && + invoice.fiatDecimals !== null + ) { + drawField( + doc, + 'Fiat amount:', + formatFiatAmount(invoice.fiatAmount, invoice.fiatDecimals, invoice.fiatCurrency), + ); + } + + drawField(doc, 'Status:', invoice.status); + drawField(doc, 'Payment link:', invoice.paymentSlug); + drawField(doc, 'Created:', formatDate(invoice.createdAt)); + + if (invoice.datePaid) { + drawField(doc, 'Paid:', formatDate(invoice.datePaid)); + } + + if (invoice.payer) { + drawField(doc, 'Payer address:', invoice.payer); + } + + doc.end(); + }); +}; diff --git a/src/services/invoice.services.ts b/src/services/invoice.services.ts index 29a512b..28e3b54 100644 --- a/src/services/invoice.services.ts +++ b/src/services/invoice.services.ts @@ -134,6 +134,24 @@ export const getInvoice = async (merchantId: string, id: string) => { return sanitizeInvoice(invoice); }; +/** + * Fetches the raw invoice + merchant records, scoped to the owning merchant, + * for the PDF/email flows that need fields beyond the sanitized public view + * (payer address, fiat breakdown, merchant logo). + */ +export const getInvoiceWithMerchant = async (merchantId: string, id: string) => { + const invoice = await prisma.invoice.findFirst({ + where: { id, merchantId }, + include: { merchant: true }, + }); + + if (!invoice) { + throw new AppError(404, 'Invoice not found'); + } + + return invoice; +}; + export const voidInvoice = async (merchantId: string, id: string) => { const invoice = await prisma.invoice.findFirst({ where: { id, merchantId }, diff --git a/src/services/pay.services.ts b/src/services/pay.services.ts index c1333f0..8653398 100644 --- a/src/services/pay.services.ts +++ b/src/services/pay.services.ts @@ -10,6 +10,20 @@ const InvoiceStatus = { REFUNDED: 'REFUNDED', } as const satisfies Record; +const assertInvoiceVisible = (invoice: { status: PrismaInvoiceStatus; expiresAt: Date | null }) => { + if ( + invoice.status === InvoiceStatus.CANCELLED || + invoice.status === InvoiceStatus.PAID || + invoice.status === InvoiceStatus.REFUNDED + ) { + throw new AppError(410, 'Invoice is no longer available'); + } + + if (invoice.expiresAt && invoice.expiresAt < new Date()) { + throw new AppError(410, 'expired'); + } +}; + export const resolveInvoiceBySlug = async (slug: string) => { const invoice = await prisma.invoice.findUnique({ where: { paymentSlug: slug }, @@ -33,17 +47,7 @@ export const resolveInvoiceBySlug = async (slug: string) => { throw new AppError(404, 'Invoice not found'); } - if ( - invoice.status === InvoiceStatus.CANCELLED || - invoice.status === InvoiceStatus.PAID || - invoice.status === InvoiceStatus.REFUNDED - ) { - throw new AppError(410, 'Invoice is no longer available'); - } - - if (invoice.expiresAt && invoice.expiresAt < new Date()) { - throw new AppError(410, 'expired'); - } + assertInvoiceVisible(invoice); return { slug: invoice.paymentSlug, @@ -57,6 +61,27 @@ export const resolveInvoiceBySlug = async (slug: string) => { }; }; +/** + * Fetches the full invoice + merchant records for a publicly visible invoice, + * applying the same 404/410 visibility rules as `resolveInvoiceBySlug`. Used + * by the public PDF download route, which needs raw fields (payer, dates, + * fiat breakdown, logo) rather than the trimmed public-facing view. + */ +export const getInvoiceForPdfBySlug = async (slug: string) => { + const invoice = await prisma.invoice.findUnique({ + where: { paymentSlug: slug }, + include: { merchant: true }, + }); + + if (!invoice) { + throw new AppError(404, 'Invoice not found'); + } + + assertInvoiceVisible(invoice); + + return invoice; +}; + export const confirmPayment = async (slug: string, payerAddress: string, txHash?: string) => { return await prisma.$transaction(async tx => { const invoice = await tx.invoice.findUnique({ @@ -67,17 +92,7 @@ export const confirmPayment = async (slug: string, payerAddress: string, txHash? throw new AppError(404, 'Invoice not found'); } - if ( - invoice.status === InvoiceStatus.CANCELLED || - invoice.status === InvoiceStatus.PAID || - invoice.status === InvoiceStatus.REFUNDED - ) { - throw new AppError(410, 'Invoice is no longer available'); - } - - if (invoice.expiresAt && invoice.expiresAt < new Date()) { - throw new AppError(410, 'expired'); - } + assertInvoiceVisible(invoice); const idempotencyKey = `${invoice.id}-${payerAddress}-${txHash || 'none'}`; diff --git a/src/services/storage/invoice-pdf.storage.ts b/src/services/storage/invoice-pdf.storage.ts new file mode 100644 index 0000000..c39ba2d --- /dev/null +++ b/src/services/storage/invoice-pdf.storage.ts @@ -0,0 +1,13 @@ +/** + * Storage seam for a future "persist/cache invoice PDFs" feature. Not called + * anywhere yet — the download and email flows generate PDFs on demand and + * discard the buffer after use. Exists so a later issue can implement a real + * backend (S3/R2/Supabase) against an already-agreed shape. + */ +export interface InvoicePdfStorage { + upload(key: string, pdf: Buffer): Promise<{ url: string }>; +} + +export const mockInvoicePdfStorage: InvoicePdfStorage = { + upload: async key => ({ url: `mock://invoice-pdfs/${key}` }), +}; diff --git a/tests/integration/auth.email-otp.test.ts b/tests/integration/auth.email-otp.test.ts index 16fb8c1..b6f5039 100644 --- a/tests/integration/auth.email-otp.test.ts +++ b/tests/integration/auth.email-otp.test.ts @@ -7,6 +7,7 @@ const sendOtpMock = jest.fn(async () => undefined); jest.unstable_mockModule('../../src/services/email.service.js', () => ({ __esModule: true, sendOtp: sendOtpMock, + sendInvoiceEmail: jest.fn(async () => undefined), })); const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; diff --git a/tests/integration/invoice.routes.test.ts b/tests/integration/invoice.routes.test.ts index f80e280..156ce1b 100644 --- a/tests/integration/invoice.routes.test.ts +++ b/tests/integration/invoice.routes.test.ts @@ -1,7 +1,16 @@ +import { jest } from '@jest/globals'; import { mockReset } from 'jest-mock-extended'; import jwt from 'jsonwebtoken'; import request from 'supertest'; +const sendInvoiceEmailMock = jest.fn(async () => undefined); + +jest.unstable_mockModule('../../src/services/email.service.js', () => ({ + __esModule: true, + sendOtp: jest.fn(async () => undefined), + sendInvoiceEmail: sendInvoiceEmailMock, +})); + const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; const { environment } = await import('../../src/config/environment.js'); const { default: app } = await import('../../src/app.js'); @@ -61,6 +70,7 @@ const auth = { Authorization: `Bearer ${accessToken}` }; describe('Invoice routes', () => { beforeEach(() => { mockReset(prismaMock); + sendInvoiceEmailMock.mockClear(); }); describe('POST /api/v1/invoices', () => { @@ -203,4 +213,84 @@ describe('Invoice routes', () => { expect(prismaMock.invoice.update).not.toHaveBeenCalled(); }); }); + + describe('GET /api/v1/invoices/:id/pdf', () => { + test('returns 401 when unauthenticated', async () => { + const response = await request(app).get('/api/v1/invoices/invoice-1/pdf'); + + expect(response.status).toBe(401); + }); + + test('returns 404 when the invoice is missing or owned by another merchant', async () => { + authenticate(); + prismaMock.invoice.findFirst.mockResolvedValue(null); + + const response = await request(app).get('/api/v1/invoices/other/pdf').set(auth); + + expect(response.status).toBe(404); + }); + + test('streams a real PDF scoped to the authenticated merchant, never touching disk', async () => { + authenticate(); + prismaMock.invoice.findFirst.mockResolvedValue({ ...baseInvoice, merchant } as any); + + const response = await request(app).get('/api/v1/invoices/invoice-1/pdf').set(auth); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('application/pdf'); + expect(response.headers['content-disposition']).toBe( + `attachment; filename="invoice-${baseInvoice.paymentSlug}.pdf"`, + ); + const body = response.body as Buffer; + expect(Buffer.isBuffer(body)).toBe(true); + expect(body.subarray(0, 5).toString('ascii')).toBe('%PDF-'); + + const findArgs = prismaMock.invoice.findFirst.mock.calls[0][0]; + expect(findArgs.where).toMatchObject({ id: 'invoice-1', merchantId: MERCHANT_ID }); + }); + }); + + describe('POST /api/v1/invoices/:id/send', () => { + test('returns 401 when unauthenticated', async () => { + const response = await request(app).post('/api/v1/invoices/invoice-1/send'); + + expect(response.status).toBe(401); + expect(sendInvoiceEmailMock).not.toHaveBeenCalled(); + }); + + test('returns 404 when the invoice is missing or owned by another merchant', async () => { + authenticate(); + prismaMock.invoice.findFirst.mockResolvedValue(null); + + const response = await request(app).post('/api/v1/invoices/other/send').set(auth); + + expect(response.status).toBe(404); + expect(sendInvoiceEmailMock).not.toHaveBeenCalled(); + }); + + test('returns 400 and does not attempt to send when the invoice has no email set', async () => { + authenticate(); + prismaMock.invoice.findFirst.mockResolvedValue({ + ...baseInvoice, + email: null, + merchant, + } as any); + + const response = await request(app).post('/api/v1/invoices/invoice-1/send').set(auth); + + expect(response.status).toBe(400); + expect(sendInvoiceEmailMock).not.toHaveBeenCalled(); + }); + + test('sends the invoice email when invoice.email is set', async () => { + authenticate(); + const invoiceWithEmail = { ...baseInvoice, email: 'payer@example.com', merchant }; + prismaMock.invoice.findFirst.mockResolvedValue(invoiceWithEmail as any); + + const response = await request(app).post('/api/v1/invoices/invoice-1/send').set(auth); + + expect(response.status).toBe(200); + expect(sendInvoiceEmailMock).toHaveBeenCalledWith(invoiceWithEmail, merchant); + }); + }); }); diff --git a/tests/integration/merchant.register.test.ts b/tests/integration/merchant.register.test.ts index bbd93f6..8d2b9ac 100644 --- a/tests/integration/merchant.register.test.ts +++ b/tests/integration/merchant.register.test.ts @@ -8,6 +8,7 @@ const sendOtpMock = jest.fn(async () => undefined); jest.unstable_mockModule('../../src/services/email.service.js', () => ({ __esModule: true, sendOtp: sendOtpMock, + sendInvoiceEmail: jest.fn(async () => undefined), })); jest.unstable_mockModule('../../src/services/otp.services.js', () => ({ diff --git a/tests/integration/pay.routes.test.ts b/tests/integration/pay.routes.test.ts new file mode 100644 index 0000000..78ec746 --- /dev/null +++ b/tests/integration/pay.routes.test.ts @@ -0,0 +1,109 @@ +import { mockReset } from 'jest-mock-extended'; +import request from 'supertest'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { default: app } = await import('../../src/app.js'); + +const merchant = { + id: 'merchant-1', + merchantId: 1, + address: '0x123', + account: null, + merchantKey: null, + email: 'merchant@example.com', + firstName: null, + lastName: null, + businessName: 'Analytical Engines', + category: null, + description: null, + logo: null, + webhook: null, + active: true, + verified: false, + emailVerified: false, + registered: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +const baseInvoice = { + id: 'invoice-1', + invoiceId: null, + paymentSlug: 'pay-slug-1', + description: 'Website design', + amount: 5000n, + amountPaid: 0n, + amountRefunded: 0n, + token: 'USDC', + merchantId: merchant.id, + payer: null, + email: null, + status: 'PENDING', + pricingMode: 'FIXED_CRYPTO', + fiatCurrency: null, + fiatAmount: null, + fiatDecimals: null, + expiresAt: null, + datePaid: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +describe('Pay routes', () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + describe('GET /api/v1/pay/:slug/pdf', () => { + test('returns 404 when the invoice does not exist', async () => { + prismaMock.invoice.findUnique.mockResolvedValue(null); + + const response = await request(app).get('/api/v1/pay/missing-slug/pdf'); + + expect(response.status).toBe(404); + }); + + test.each(['CANCELLED', 'PAID', 'REFUNDED'])( + 'returns 410 when the invoice status is %s', + async status => { + prismaMock.invoice.findUnique.mockResolvedValue({ + ...baseInvoice, + status, + merchant, + } as any); + + const response = await request(app).get(`/api/v1/pay/${baseInvoice.paymentSlug}/pdf`); + + expect(response.status).toBe(410); + }, + ); + + test('returns 410 with reason "expired" when past expiresAt', async () => { + prismaMock.invoice.findUnique.mockResolvedValue({ + ...baseInvoice, + expiresAt: new Date('2020-01-01T00:00:00.000Z'), + merchant, + } as any); + + const response = await request(app).get(`/api/v1/pay/${baseInvoice.paymentSlug}/pdf`); + + expect(response.status).toBe(410); + expect(response.body).toEqual({ reason: 'expired' }); + }); + + test('returns a real, valid PDF for a publicly visible invoice', async () => { + prismaMock.invoice.findUnique.mockResolvedValue({ ...baseInvoice, merchant } as any); + + const response = await request(app).get(`/api/v1/pay/${baseInvoice.paymentSlug}/pdf`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('application/pdf'); + expect(response.headers['content-disposition']).toBe( + `attachment; filename="invoice-${baseInvoice.paymentSlug}.pdf"`, + ); + const body = response.body as Buffer; + expect(Buffer.isBuffer(body)).toBe(true); + expect(body.subarray(0, 5).toString('ascii')).toBe('%PDF-'); + }); + }); +}); diff --git a/tests/unit/email.service.resend.test.ts b/tests/unit/email.service.resend.test.ts new file mode 100644 index 0000000..5ae540c --- /dev/null +++ b/tests/unit/email.service.resend.test.ts @@ -0,0 +1,109 @@ +import { jest } from '@jest/globals'; + +// Exercise the real 'resend' provider branch. Only the third-party Resend SDK +// is mocked (no live network call in a test run) — sendInvoiceEmail, +// generateInvoicePdf, and all attachment-building logic run for real. +// Save/restore EMAIL_PROVIDER since sibling test modules in this worker rely +// on it being unset (console provider) or set to 'smtp'. +const previousEmailProvider = process.env.EMAIL_PROVIDER; +process.env.EMAIL_PROVIDER = 'resend'; +process.env.RESEND_API_KEY = 'test-resend-key'; +process.env.EMAIL_FROM = 'noreply@shade.test'; + +const sendMock = jest.fn(async () => ({ data: { id: 'email-1' }, error: null })); + +jest.unstable_mockModule('resend', () => ({ + __esModule: true, + Resend: jest.fn().mockImplementation(() => ({ + emails: { send: sendMock }, + })), +})); + +const { sendInvoiceEmail } = await import('../../src/services/email.service.js'); + +if (previousEmailProvider === undefined) { + delete process.env.EMAIL_PROVIDER; +} else { + process.env.EMAIL_PROVIDER = previousEmailProvider; +} + +const baseMerchant = { + id: 'merchant-1', + merchantId: 1, + address: '0x123', + account: null, + merchantKey: null, + email: 'merchant@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + businessName: 'Analytical Engines', + category: 'software', + description: null, + logo: null, + webhook: null, + active: true, + verified: true, + emailVerified: true, + registered: true, + emailOtp: null, + emailOtpExpiresAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} as any; + +const baseInvoice = { + id: 'invoice-1', + invoiceId: null, + paymentSlug: 'slug-abc123', + description: 'Website design', + amount: 5000n, + amountPaid: 0n, + amountRefunded: 0n, + token: 'USDC', + merchantId: 'merchant-1', + payer: null, + email: 'payer@example.com', + status: 'PENDING', + pricingMode: 'FIXED_CRYPTO', + fiatCurrency: null, + fiatAmount: null, + fiatDecimals: null, + expiresAt: null, + datePaid: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} as any; + +describe('sendInvoiceEmail (resend provider)', () => { + beforeEach(() => { + sendMock.mockClear(); + }); + + test('sends the invoice PDF as a real attachment via Resend', async () => { + await sendInvoiceEmail(baseInvoice, baseMerchant); + + expect(sendMock).toHaveBeenCalledTimes(1); + const payload = sendMock.mock.calls[0][0] as any; + + expect(payload.to).toBe('payer@example.com'); + expect(payload.from).toBe('noreply@shade.test'); + expect(payload.attachments).toHaveLength(1); + expect(payload.attachments[0].filename).toBe(`invoice-${baseInvoice.paymentSlug}.pdf`); + expect(Buffer.isBuffer(payload.attachments[0].content)).toBe(true); + expect(payload.attachments[0].content.subarray(0, 5).toString('ascii')).toBe('%PDF-'); + }); + + test('does not call Resend when invoice has no email', async () => { + await sendInvoiceEmail({ ...baseInvoice, email: null }, baseMerchant); + + expect(sendMock).not.toHaveBeenCalled(); + }); + + test('throws when Resend returns an error', async () => { + sendMock.mockResolvedValueOnce({ data: null, error: { message: 'boom' } } as any); + + await expect(sendInvoiceEmail(baseInvoice, baseMerchant)).rejects.toThrow( + 'Failed to send email via Resend: boom', + ); + }); +}); diff --git a/tests/unit/email.service.smtp.test.ts b/tests/unit/email.service.smtp.test.ts new file mode 100644 index 0000000..48f0797 --- /dev/null +++ b/tests/unit/email.service.smtp.test.ts @@ -0,0 +1,106 @@ +import { jest } from '@jest/globals'; + +// Exercise the real 'smtp' provider branch. Only the third-party nodemailer +// SDK is mocked (no live SMTP connection in a test run) — sendInvoiceEmail, +// generateInvoicePdf, and all attachment-building logic run for real. +// Save/restore EMAIL_PROVIDER since sibling test modules in this worker rely +// on it being unset (console provider) or set to 'resend'. +const previousEmailProvider = process.env.EMAIL_PROVIDER; +process.env.EMAIL_PROVIDER = 'smtp'; +process.env.SMTP_HOST = 'smtp.test.local'; +process.env.SMTP_PORT = '587'; +process.env.SMTP_USER = 'user'; +process.env.SMTP_PASS = 'pass'; +process.env.EMAIL_FROM = 'noreply@shade.test'; + +const sendMailMock = jest.fn(async () => ({ messageId: 'msg-1' })); +const createTransportMock = jest.fn(() => ({ sendMail: sendMailMock })); + +jest.unstable_mockModule('nodemailer', () => ({ + __esModule: true, + default: { createTransport: createTransportMock }, +})); + +const { sendInvoiceEmail } = await import('../../src/services/email.service.js'); + +if (previousEmailProvider === undefined) { + delete process.env.EMAIL_PROVIDER; +} else { + process.env.EMAIL_PROVIDER = previousEmailProvider; +} + +const baseMerchant = { + id: 'merchant-1', + merchantId: 1, + address: '0x123', + account: null, + merchantKey: null, + email: 'merchant@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + businessName: 'Analytical Engines', + category: 'software', + description: null, + logo: null, + webhook: null, + active: true, + verified: true, + emailVerified: true, + registered: true, + emailOtp: null, + emailOtpExpiresAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} as any; + +const baseInvoice = { + id: 'invoice-1', + invoiceId: null, + paymentSlug: 'slug-abc123', + description: 'Website design', + amount: 5000n, + amountPaid: 0n, + amountRefunded: 0n, + token: 'USDC', + merchantId: 'merchant-1', + payer: null, + email: 'payer@example.com', + status: 'PENDING', + pricingMode: 'FIXED_CRYPTO', + fiatCurrency: null, + fiatAmount: null, + fiatDecimals: null, + expiresAt: null, + datePaid: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} as any; + +describe('sendInvoiceEmail (smtp provider)', () => { + beforeEach(() => { + sendMailMock.mockClear(); + createTransportMock.mockClear(); + }); + + test('sends the invoice PDF as a real attachment via SMTP', async () => { + await sendInvoiceEmail(baseInvoice, baseMerchant); + + expect(createTransportMock).toHaveBeenCalledWith( + expect.objectContaining({ host: 'smtp.test.local', port: 587 }), + ); + expect(sendMailMock).toHaveBeenCalledTimes(1); + const payload = sendMailMock.mock.calls[0][0] as any; + + expect(payload.to).toBe('payer@example.com'); + expect(payload.attachments).toHaveLength(1); + expect(payload.attachments[0].filename).toBe(`invoice-${baseInvoice.paymentSlug}.pdf`); + expect(Buffer.isBuffer(payload.attachments[0].content)).toBe(true); + expect(payload.attachments[0].content.subarray(0, 5).toString('ascii')).toBe('%PDF-'); + }); + + test('does not call SMTP when invoice has no email', async () => { + await sendInvoiceEmail({ ...baseInvoice, email: null }, baseMerchant); + + expect(sendMailMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/email.service.test.ts b/tests/unit/email.service.test.ts new file mode 100644 index 0000000..406764b --- /dev/null +++ b/tests/unit/email.service.test.ts @@ -0,0 +1,100 @@ +import { jest } from '@jest/globals'; + +// EMAIL_PROVIDER must be unset here, so environment.email.provider resolves to +// the default 'console' branch. This exercises sendInvoiceEmail's real logic +// (the no-op guard and the real generateInvoicePdf call) with nothing mocked. +// Explicitly clear it (and restore afterward) since a sibling test module in +// this worker may have set it for the 'resend'/'smtp' provider branches. +const previousEmailProvider = process.env.EMAIL_PROVIDER; +delete process.env.EMAIL_PROVIDER; + +const { sendInvoiceEmail } = await import('../../src/services/email.service.js'); + +if (previousEmailProvider === undefined) { + delete process.env.EMAIL_PROVIDER; +} else { + process.env.EMAIL_PROVIDER = previousEmailProvider; +} + +const baseMerchant = { + id: 'merchant-1', + merchantId: 1, + address: '0x123', + account: null, + merchantKey: null, + email: 'merchant@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + businessName: 'Analytical Engines', + category: 'software', + description: null, + logo: null, + webhook: null, + active: true, + verified: true, + emailVerified: true, + registered: true, + emailOtp: null, + emailOtpExpiresAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} as any; + +const baseInvoice = { + id: 'invoice-1', + invoiceId: null, + paymentSlug: 'slug-abc123', + description: 'Website design', + amount: 5000n, + amountPaid: 0n, + amountRefunded: 0n, + token: 'USDC', + merchantId: 'merchant-1', + payer: null, + email: 'payer@example.com', + status: 'PENDING', + pricingMode: 'FIXED_CRYPTO', + fiatCurrency: null, + fiatAmount: null, + fiatDecimals: null, + expiresAt: null, + datePaid: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} as any; + +describe('sendInvoiceEmail (console provider — default when EMAIL_PROVIDER unset)', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + test('no-ops without sending anything when invoice.email is not set', async () => { + const invoice = { ...baseInvoice, email: null }; + + await sendInvoiceEmail(invoice, baseMerchant); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + test('generates a real PDF and "sends" (logs) when invoice.email is set', async () => { + await sendInvoiceEmail(baseInvoice, baseMerchant); + + expect(logSpy).toHaveBeenCalledTimes(1); + const [message] = logSpy.mock.calls[0] as [string]; + expect(message).not.toContain('payer@example.com'); + expect(message).toContain(baseInvoice.paymentSlug); + }); + + test('reflects current invoice state on each call (not cached)', async () => { + await sendInvoiceEmail(baseInvoice, baseMerchant); + await sendInvoiceEmail({ ...baseInvoice, status: 'PAID' }, baseMerchant); + + expect(logSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/invoice-pdf.services.test.ts b/tests/unit/invoice-pdf.services.test.ts new file mode 100644 index 0000000..839e37f --- /dev/null +++ b/tests/unit/invoice-pdf.services.test.ts @@ -0,0 +1,143 @@ +import { jest } from '@jest/globals'; +import { generateInvoicePdf } from '../../src/services/invoice-pdf.services.js'; + +const baseMerchant = { + id: 'merchant-1', + merchantId: 1, + address: '0x123', + account: null, + merchantKey: null, + email: 'merchant@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + businessName: 'Analytical Engines', + category: 'software', + description: null, + logo: null, + webhook: null, + active: true, + verified: true, + emailVerified: true, + registered: true, + emailOtp: null, + emailOtpExpiresAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} as any; + +const baseInvoice = { + id: 'invoice-1', + invoiceId: null, + paymentSlug: 'slug-abc123', + description: 'Website design', + amount: 5000n, + amountPaid: 0n, + amountRefunded: 0n, + token: 'USDC', + merchantId: 'merchant-1', + payer: null, + email: null, + status: 'PENDING', + pricingMode: 'FIXED_CRYPTO', + fiatCurrency: null, + fiatAmount: null, + fiatDecimals: null, + expiresAt: null, + datePaid: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} as any; + +// 1x1 transparent PNG, a real (tiny) embeddable image for the logo test. +const TINY_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + +const isValidPdf = (buffer: Buffer): boolean => + buffer.subarray(0, 5).toString('ascii') === '%PDF-' && + buffer.subarray(-6).toString('ascii').includes('%%EOF'); + +describe('generateInvoicePdf', () => { + test('returns a valid, non-empty PDF buffer for a FIXED_CRYPTO invoice', async () => { + const pdf = await generateInvoicePdf(baseInvoice, baseMerchant); + + expect(Buffer.isBuffer(pdf)).toBe(true); + expect(pdf.length).toBeGreaterThan(100); + expect(isValidPdf(pdf)).toBe(true); + }); + + test('does not query the database or touch storage (pure function of its args)', async () => { + // No mocking required to prove this: calling it twice with identical, + // already-in-memory fixture data must succeed both times with no shared + // state or external dependency involved. + const first = await generateInvoicePdf(baseInvoice, baseMerchant); + const second = await generateInvoicePdf(baseInvoice, baseMerchant); + + expect(isValidPdf(first)).toBe(true); + expect(isValidPdf(second)).toBe(true); + }); + + test('includes fiat amount fields when pricingMode is FIXED_FIAT', async () => { + const invoice = { + ...baseInvoice, + pricingMode: 'FIXED_FIAT', + fiatAmount: 999_00n, + fiatCurrency: 'USD', + fiatDecimals: 2, + }; + + const pdf = await generateInvoicePdf(invoice, baseMerchant); + + expect(isValidPdf(pdf)).toBe(true); + expect(pdf.length).toBeGreaterThan(100); + }); + + test('renders paid invoices with datePaid and payer address present', async () => { + const invoice = { + ...baseInvoice, + status: 'PAID', + payer: 'GABCDEF1234567890', + datePaid: new Date('2026-01-05T10:30:00.000Z'), + }; + + const pdf = await generateInvoicePdf(invoice, baseMerchant); + + expect(isValidPdf(pdf)).toBe(true); + }); + + test('embeds a merchant logo supplied as a data URI without throwing', async () => { + const merchant = { ...baseMerchant, logo: `data:image/png;base64,${TINY_PNG_BASE64}` }; + + const pdf = await generateInvoicePdf(baseInvoice, merchant); + + expect(isValidPdf(pdf)).toBe(true); + }); + + test('gracefully skips a non-data-URI logo (e.g. a plain https URL) without a network call', async () => { + const merchant = { ...baseMerchant, logo: 'https://example.com/logo.png' }; + + const pdf = await generateInvoicePdf(baseInvoice, merchant); + + expect(isValidPdf(pdf)).toBe(true); + }); + + test('logs and continues when the logo data URI has valid base64 that is not a valid image', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const merchant = { ...baseMerchant, logo: 'data:image/png;base64,YWJjZGVm' }; + + const pdf = await generateInvoicePdf(baseInvoice, merchant); + + expect(isValidPdf(pdf)).toBe(true); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toContain('Failed to embed invoice logo'); + + errorSpy.mockRestore(); + }); + + test('handles a merchant with no businessName and no logo', async () => { + const merchant = { ...baseMerchant, businessName: null, logo: null }; + + const pdf = await generateInvoicePdf(baseInvoice, merchant); + + expect(isValidPdf(pdf)).toBe(true); + }); +});