feat: integrate PDF generation for invoices using Handlebars and Puppeteer, and add invoice template
This commit is contained in:
parent
9e0a71866a
commit
daab8ec07d
backend
invoice-template
1155
backend/package-lock.json
generated
1155
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
150
backend/src/resources/template.html
Normal file
150
backend/src/resources/template.html
Normal 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>
|
|
@ -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>
|
Loading…
Reference in New Issue
Block a user