feat: integrate PDF generation for invoices using Handlebars and Puppeteer, and add invoice template

This commit is contained in:
Astrian Zheng 2025-01-12 10:39:40 +11:00
parent 9e0a71866a
commit daab8ec07d
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
5 changed files with 1404 additions and 19 deletions

1155
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,10 +29,12 @@
"debug": "^4.4.0",
"decimal.js": "^10.4.3",
"dotenv": "^16.4.7",
"handlebars": "^4.7.8",
"install": "^0.13.0",
"koa": "^2.15.3",
"koa-body": "^6.0.1",
"koa-route": "^4.0.1",
"npm": "^11.0.0"
"npm": "^11.0.0",
"puppeteer": "^24.0.0"
}
}

View File

@ -3,6 +3,10 @@ import { ErrorDescEnum, HttpError } from '../classes/HttpError'
import { PrismaClient } from '@prisma/client'
import InvoiceItem from '../classes/InvoiceItem'
import Invoice from '../classes/Invoice'
import { readFileSync } from 'fs'
import dayjs from 'dayjs'
import handlebars from 'handlebars'
import puppeteer from 'puppeteer'
console.log = Debug('invoiceIssuer:func/issueInvoice.ts')
@ -23,7 +27,7 @@ export default async (prisma: PrismaClient, payerId: number, period: Date[], ite
})
if (!payer) throw new HttpError(ErrorDescEnum.related_item_not_found, 400, ['payer_id'])
// 创建新收据
// 在数据库创建新收据
const payerInfo = {
id: payer.payer_id,
name: payer.payer_name,
@ -34,4 +38,69 @@ export default async (prisma: PrismaClient, payerId: number, period: Date[], ite
const newInvoice = new Invoice(0, new Date(), period, items, dueDate, payerInfo)
if (note) await newInvoice.setNote(note)
await newInvoice.save(prisma)
// 拼接收据编号INV-yyyymmdd-suffix
const suffix = newInvoice.suffixCode
const dateCode = dayjs(newInvoice.issueTime).format('YYYYMMDD')
const invoiceNumber = `INV-${dateCode}-${suffix}`
console.log(`New invoice created: ${invoiceNumber}`)
// 根据模板创建 PDF
// 从 ../resources/template.html 读取 handlebars 模板
const template = readFileSync('./src/resources/template.html', 'utf-8')
// 写入数据
console.log(payerInfo.address.split('\n')[0])
const data = {
invoiceNumber: invoiceNumber,
invoiceDate: dayjs(newInvoice.issueTime).format('D, MMMM YYYY'),
payee: JSON.parse(process.env.PAYEE_JSON || '{}') || {
name: 'Payee Name',
address: ['Payee Address Line 1', 'Payee Address Line 2'],
abn: '12345678901'
},
payer: {
name: payerInfo.name,
address_line_one: payerInfo.address.split('\n')[0],
address_line_two: payerInfo.address.split('\n')[1],
abn: payerInfo.abn
},
billingPeriod: {
start: dayjs(period[0]).format('D, MMMM YYYY'),
end: dayjs(period[1]).format('D, MMMM YYYY')
},
dueDate: dayjs(dueDate).format('D, MMMM YYYY'),
amountDue: `\$${newInvoice.getTotal().toFixed(2)}`,
items: items.map(item => {
return {
description: item.description,
quantity: item.quantity,
unit: item.unit,
rate: `\$${(item.unitPrice ?? 0).toFixed(2)}`,
amount: `\$${(item.getSubtotal() ?? 0).toFixed(2)}`
}
}),
subtotal: `\$${newInvoice.getTotal().toFixed(2)}`,
paymentInstructions: JSON.parse(process.env.PAYMENT_JSON || '{}') || {
bankName: 'Bank Name',
accountName: 'Account Name',
accountNumber: '123456789',
bsb: '123456'
},
}
// 使用 handlebars 渲染模板
const render = handlebars.compile(template)
const html = render(data)
// 使用 puppeteer 生成 PDF
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(html)
await page.pdf({
path: `./dist/${invoiceNumber}.pdf`,
format: 'A4',
printBackground: true
})
await browser.close()
}

View File

@ -0,0 +1,150 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Invoice</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap" rel="stylesheet">
</head>
<body>
<div style="display: flex; justify-content: space-between;">
<div style="font-size: 30px; font-weight: bold;">Invoice</div>
<div style="text-align: right;">
<div>#{{invoiceNumber}}</div>
<div>{{invoiceDate}}</div>
</div>
</div>
<hr />
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<div style="font-weight: bold;">Billed To</div>
<div>
<div style="font-weight: 600; font-size: 20px">{{payer.name}}</div>
<div style="margin-top: 0.5rem; display: flex; flex-direction: column; gap: .5rem;">
<div>
<div style="font-weight: 600;">Address</div>
<div>{{payer.address_line_one}}</div>
<div>{{payer.address_line_two}}</div>
</div>
<div>
<div style="font-weight: 600;">ABN</div>
<div>{{payer.abn}}</div>
</div>
</div>
</div>
</div>
<div style="text-align: right;">
<div style="font-weight: bold;">Payee</div>
<div>
<div style="font-weight: 600; font-size: 20px;">{{payee.name}}</div>
<div style="margin-top: 0.5rem; display: flex; flex-direction: column; gap: .5rem;">
<div>
<div style="font-weight: 600;">Address</div>
<div>{{payee.address}}</div>
<div>{{payee.city}}, {{payee.state}} {{payee.zip}}</div>
</div>
<div>
<div style="font-weight: 600;">ABN</div>
<div>{{payee.abn}}</div>
</div>
</div>
</div>
</div>
</div>
<div style="display: flex; margin-top: 2rem; gap: 1rem; text-align: center;">
<div style="flex: 1;">
<div style="font-weight: bold;">Billing period</div>
<div>{{billingPeriod.start}} - {{billingPeriod.end}}</div>
</div>
<div style="border-left: 1px #ccc; border-left-style: solid; padding-left: 1rem;"></div>
<div style="flex: 1;">
<div style="font-weight: bold;">Due date</div>
<div>{{dueDate}}</div>
</div>
<div style="border-left: 1px #ccc; border-left-style: solid; padding-left: 1rem;"></div>
<div style="flex: 1;">
<div style="font-weight: bold;">Amount due</div>
<div>{{amountDue}}</div>
</div>
</div>
<table style="width: 100%; margin-top: 2rem;">
<thead style="border-top: 2px solid #ccc; border-bottom: 2px solid #ccc;">
<tr>
<th style="text-align: left; padding: .5rem 0;">Item</th>
<th style="text-align: right;">Rate</th>
<th style="text-align: right;">Unit</th>
<th style="text-align: right;">Amount</th>
<th style="text-align: right;">Total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr style="border-bottom: 1px solid #ccc;">
<td style="padding: .5rem 0;">{{this.description}}</td>
<td style="text-align: right;">{{this.rate}}</td>
<td style="text-align: right;">{{this.unit}}</td>
<td style="text-align: right;">{{this.amount}}</td>
<td style="text-align: right;">{{this.total}}</td>
</tr>
{{/each}}
<tr style="border-bottom: 2px solid #ccc;">
<td style="text-align: right; padding: .5rem 0; font-weight: 600;" colspan="4">Subtotal</td>
<td style="text-align: right; font-weight: 600;">{{subtotal}}</td>
</tr>
</tbody>
</table>
<div style="margin-top: 2rem; display: flex; flex-direction: column; gap: .5rem;">
<div style="font-weight: bold; font-size: 18px;">Instructions of payment</div>
<div>Please raise a bank transfer to the following account.</div>
<div>
<div style="font-weight: 600;">Bank</div>
<div>{{paymentInstructions.bank}}</div>
</div>
<div>
<div style="font-weight: 600;">BSB</div>
<div>{{paymentInstructions.bsb}}</div>
</div>
<div>
<div style="font-weight: 600;">Account</div>
<div>{{paymentInstructions.account}}</div>
</div>
<div>
<div style="font-weight: 600;">Account name</div>
<div>{{paymentInstructions.accountName}}</div>
</div>
</div>
<div style="margin-top: 2rem; font-size: 14px; color: #666;">
<p>If you have any questions about this invoice, please contact us at <a href="mailto:{{contactEmail}}">{{contactEmail}}</a>.</p>
<p>Thank you for your business.</p>
</div>
</body>
<style>
body {
font-family: 'IBM Plex Sans', sans-serif;
}
hr {
margin-top: 1rem;
border: 0;
border-top: 1px solid #ccc;
}
table {
border-collapse: collapse;
}
a {
color: inherit;
}
</style>
</html>

View File

@ -1,5 +1,6 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
@ -7,8 +8,11 @@
<title>Invoice</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap" rel="stylesheet">
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
rel="stylesheet">
</head>
<body>
<div style="display: flex; justify-content: space-between;">
<div style="font-size: 30px; font-weight: bold;">Invoice</div>
@ -26,10 +30,16 @@
<div style="font-weight: bold;">Billed To</div>
<div>
<div style="font-weight: 600; font-size: 20px">John Doe</div>
<div style="margin-top: 0.5rem;">
<div>111/238 Flinders St.</div>
<div>Melbourne, VIC 3000</div>
<div>ABN: 12 345 678 901</div>
<div style="margin-top: 0.5rem; display: flex; flex-direction: column; gap: .5rem;">
<div>
<div>Address</div>
<div>111/238 Flinders St.</div>
<div>Melbourne, VIC 3000</div>
</div>
<div>
<div>ABN</div>
<div>12 345 678 901</div>
</div>
</div>
</div>
</div>
@ -38,10 +48,16 @@
<div style="font-weight: bold;">Payee</div>
<div>
<div style="font-weight: 600; font-size: 20px;">Zhiwen Zheng</div>
<div style="margin-top: 0.5rem;">
<div>111/238 Flinders St.</div>
<div>Melbourne, VIC 3000</div>
<div>ABN: 12 345 678 901</div>
<div style="margin-top: 0.5rem; display: flex; flex-direction: column; gap: .5rem;">
<div>
<div>Address</div>
<div>123/456 Collins St.</div>
<div>Melbourne, VIC 3000</div>
</div>
<div>
<div>ABN</div>
<div>12 345 678 901</div>
</div>
</div>
</div>
</div>
@ -112,7 +128,8 @@
</div>
<div style="margin-top: 2rem; font-size: 14px; color: #666;">
<p>If you have any questions about this invoice, please contact us at <a href="mailto:astrian@fastmail.com">astrian@fastmail.com</a>.</p>
<p>If you have any questions about this invoice, please contact us at <a
href="mailto:astrian@fastmail.com">astrian@fastmail.com</a>.</p>
<p>Thank you for your business.</p>
</div>
@ -121,16 +138,20 @@
body {
font-family: 'IBM Plex Sans', sans-serif;
}
hr {
margin-top: 1rem;
border: 0;
border-top: 1px solid #ccc;
}
table {
border-collapse: collapse;
}
a {
color: inherit;
}
</style>
</html>
</html>