PDF Generation
Overview
The Invoice Service generates PDF documents that comply with Vietnamese e-invoice display requirements, including company information, itemized details, QR codes for verification, and proper Vietnamese formatting.
PDF Layout
┌────────────────────────────────────────────────────────────┐
│ LOGO │
│ HÓA ĐƠN GIÁ TRỊ GIA TĂNG │
│ (VAT INVOICE) │
├────────────────────────────────────────────────────────────┤
│ Mẫu số: 01GTKT0/001 Ký hiệu: C25TAA │
│ Số: 00000001 Ngày: 20/01/2025 │
├────────────────────────────────────────────────────────────┤
│ ĐƠN VỊ BÁN HÀNG (SELLER) │
│ Tên: Công ty TNHH ABC │
│ MST: 0123456789 │
│ Địa chỉ: 123 Nguyễn Huệ, Q1, TP.HCM │
│ Điện thoại: 028-1234-5678 │
├────────────────────────────────────────────────────────────┤
│ NGƯỜI MUA HÀNG (BUYER) │
│ Tên: Khách hàng XYZ │
│ MST: 9876543210 │
│ Địa chỉ: 456 Lê Lợi, Q1, TP.HCM │
├────────────────────────────────────────────────────────────┤
│ STT │ Tên hàng hóa │ ĐVT │ SL │ Đơn giá │ Thành tiền │
│──────┼───────────────┼─────┼──────┼───────────┼─────────────│
│ 1 │ Sản phẩm A │ Cái │ 2 │ 100,000 │ 200,000 │
│ 2 │ Dịch vụ B │ Lần │ 1 │ 50,000 │ 45,000 │
│ │ │ │ │ CK: 10% │ -5,000 │
├────────────────────────────────────────────────────────────┤
│ Cộng tiền hàng: 245,000 VND │
│ Thuế GTGT (10%): 24,500 VND │
│ Tổng cộng: 269,500 VND │
├────────────────────────────────────────────────────────────┤
│ Số tiền bằng chữ: Hai trăm sáu mươi chín nghìn │
│ năm trăm đồng │
├────────────────────────────────────────────────────────────┤
│ ┌─────────┐ │
│ │ QR CODE │ Mã CQT: ABC123DEF456 │
│ │ │ Ngày ký: 20/01/2025 10:00:00 │
│ └─────────┘ │
└────────────────────────────────────────────────────────────┘PdfGeneratorService
typescript
import PDFDocument from 'pdfkit';
import QRCode from 'qrcode';
@injectable()
class PdfGeneratorService {
/**
* Generate PDF from invoice data
*/
async generatePdf(invoice: Invoice): Promise<Buffer> {
const doc = new PDFDocument({
size: 'A4',
margin: 50,
bufferPages: true,
});
// Register Vietnamese font
doc.registerFont('Vietnamese', 'fonts/NotoSans-Regular.ttf');
doc.font('Vietnamese');
// Generate PDF sections
await this.addHeader(doc, invoice);
await this.addSellerInfo(doc, invoice);
await this.addBuyerInfo(doc, invoice);
await this.addItemsTable(doc, invoice.items);
await this.addTotals(doc, invoice);
await this.addAmountInWords(doc, invoice.grandTotal);
await this.addQrCode(doc, invoice);
await this.addFooter(doc, invoice);
doc.end();
return this.docToBuffer(doc);
}
private async addHeader(doc: PDFKit.PDFDocument, invoice: Invoice): Promise<void> {
// Logo
if (invoice.merchantLogo) {
doc.image(invoice.merchantLogo, 50, 45, { width: 100 });
}
// Title
doc.fontSize(18)
.text('HÓA ĐƠN GIÁ TRỊ GIA TĂNG', { align: 'center' })
.fontSize(12)
.text('(VAT INVOICE)', { align: 'center' })
.moveDown();
// Invoice info
doc.fontSize(10)
.text(`Mẫu số: ${invoice.templateCode}`, { align: 'left' })
.text(`Ký hiệu: ${invoice.templateSymbol}`, { align: 'right', y: doc.y - 15 })
.text(`Số: ${invoice.invoiceNumber}`, { align: 'left' })
.text(`Ngày: ${this.formatDate(invoice.issueDate)}`, { align: 'right', y: doc.y - 15 })
.moveDown();
}
private async addSellerInfo(doc: PDFKit.PDFDocument, invoice: Invoice): Promise<void> {
doc.fontSize(11)
.text('ĐƠN VỊ BÁN HÀNG (Seller)', { underline: true })
.fontSize(10)
.text(`Tên đơn vị: ${invoice.sellerName}`)
.text(`Mã số thuế: ${invoice.sellerTaxCode}`)
.text(`Địa chỉ: ${invoice.sellerAddress}`);
if (invoice.sellerPhone) {
doc.text(`Điện thoại: ${invoice.sellerPhone}`);
}
if (invoice.sellerBankAccount) {
doc.text(`Số TK: ${invoice.sellerBankAccount} - ${invoice.sellerBankName}`);
}
doc.moveDown();
}
private async addBuyerInfo(doc: PDFKit.PDFDocument, invoice: Invoice): Promise<void> {
doc.fontSize(11)
.text('NGƯỜI MUA HÀNG (Buyer)', { underline: true })
.fontSize(10)
.text(`Tên người mua: ${invoice.buyerName}`);
if (invoice.buyerTaxCode) {
doc.text(`Mã số thuế: ${invoice.buyerTaxCode}`);
}
if (invoice.buyerAddress) {
doc.text(`Địa chỉ: ${invoice.buyerAddress}`);
}
doc.moveDown();
}
private async addItemsTable(doc: PDFKit.PDFDocument, items: InvoiceItem[]): Promise<void> {
const tableTop = doc.y;
const colWidths = [30, 200, 50, 50, 80, 90];
const headers = ['STT', 'Tên hàng hóa, dịch vụ', 'ĐVT', 'SL', 'Đơn giá', 'Thành tiền'];
// Draw header
let x = 50;
doc.fontSize(9).font('Vietnamese-Bold');
headers.forEach((header, i) => {
doc.text(header, x, tableTop, { width: colWidths[i], align: 'center' });
x += colWidths[i];
});
// Draw header line
doc.moveTo(50, tableTop + 15).lineTo(550, tableTop + 15).stroke();
// Draw items
doc.font('Vietnamese');
let y = tableTop + 20;
items.forEach((item, index) => {
x = 50;
const values = [
(index + 1).toString(),
item.productName,
item.unit,
item.quantity.toString(),
this.formatCurrency(item.unitPrice),
this.formatCurrency(item.totalBeforeVat),
];
values.forEach((value, i) => {
doc.text(value, x, y, {
width: colWidths[i],
align: i === 0 || i >= 3 ? 'right' : 'left',
});
x += colWidths[i];
});
y += 15;
// Show discount if applicable
if (item.discountAmount > 0) {
doc.text(`Chiết khấu: ${item.discountPercent}%`, 250, y);
doc.text(`-${this.formatCurrency(item.discountAmount)}`, 460, y, {
width: 90,
align: 'right',
});
y += 15;
}
});
// Draw bottom line
doc.moveTo(50, y).lineTo(550, y).stroke();
doc.y = y + 10;
}
private async addTotals(doc: PDFKit.PDFDocument, invoice: Invoice): Promise<void> {
const rightCol = 350;
doc.fontSize(10)
.text('Cộng tiền hàng:', rightCol)
.text(this.formatCurrency(invoice.totalBeforeVat) + ' VND', rightCol + 100, doc.y - 12, {
align: 'right', width: 100,
});
// VAT breakdown
doc.text(`Thuế GTGT (${invoice.vatRate}%):`, rightCol)
.text(this.formatCurrency(invoice.totalVat) + ' VND', rightCol + 100, doc.y - 12, {
align: 'right', width: 100,
});
doc.fontSize(11).font('Vietnamese-Bold')
.text('Tổng cộng tiền thanh toán:', rightCol)
.text(this.formatCurrency(invoice.grandTotal) + ' VND', rightCol + 100, doc.y - 14, {
align: 'right', width: 100,
});
doc.font('Vietnamese');
doc.moveDown();
}
private async addAmountInWords(doc: PDFKit.PDFDocument, amount: number): Promise<void> {
const words = numberToVietnameseWords(amount);
doc.fontSize(10)
.text(`Số tiền viết bằng chữ: ${words}`)
.moveDown();
}
private async addQrCode(doc: PDFKit.PDFDocument, invoice: Invoice): Promise<void> {
// Generate QR code content
const qrContent = [
invoice.sellerTaxCode,
invoice.invoiceNumber,
this.formatDateShort(invoice.issueDate),
invoice.grandTotal.toString(),
invoice.totalVat.toString(),
invoice.cqtCode || '',
].join('|');
// Generate QR code image
const qrDataUrl = await QRCode.toDataURL(qrContent, {
width: 100,
margin: 1,
});
const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
doc.image(qrBuffer, 50, doc.y, { width: 80 });
// Add CQT code
doc.fontSize(9)
.text(`Mã CQT: ${invoice.cqtCode || 'Đang chờ'}`, 140, doc.y - 60)
.text(`Ngày ký: ${this.formatDateTime(invoice.signedAt)}`, 140);
}
private formatCurrency(amount: number): string {
return amount.toLocaleString('vi-VN');
}
private formatDate(date: Date): string {
return date.toLocaleDateString('vi-VN');
}
private formatDateTime(date: Date): string {
return date.toLocaleString('vi-VN');
}
}QR Code Content
QR Code Structure
MST_SELLER|INVOICE_NUMBER|ISSUE_DATE|TOTAL|VAT|CQT_CODE
Example:
0123456789|C25TAA/00000001|20250120|269500|24500|ABC123DEF456QR Code Fields
| Position | Field | Description |
|---|---|---|
| 1 | MST Seller | Seller's tax code |
| 2 | Invoice Number | Full invoice number |
| 3 | Issue Date | Format: YYYYMMDD |
| 4 | Total Amount | Grand total |
| 5 | VAT Amount | Total VAT |
| 6 | CQT Code | Tax authority verification code |
QR Code Generation
typescript
import QRCode from 'qrcode';
async function generateInvoiceQR(invoice: Invoice): Promise<Buffer> {
const content = [
invoice.sellerTaxCode,
invoice.invoiceNumber,
formatDateForQR(invoice.issueDate),
invoice.grandTotal.toString(),
invoice.totalVat.toString(),
invoice.cqtCode,
].join('|');
return QRCode.toBuffer(content, {
errorCorrectionLevel: 'M',
type: 'png',
width: 200,
margin: 2,
});
}
function formatDateForQR(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, '');
}Font Requirements
Vietnamese Font Support
typescript
// Register fonts with proper Vietnamese diacritics support
doc.registerFont('Vietnamese', 'fonts/NotoSans-Regular.ttf');
doc.registerFont('Vietnamese-Bold', 'fonts/NotoSans-Bold.ttf');
doc.registerFont('Vietnamese-Italic', 'fonts/NotoSans-Italic.ttf');
// Alternative fonts with good Vietnamese support:
// - Be Vietnam Pro
// - Roboto
// - Open Sans
// - InterFont Configuration
typescript
const fontConfig = {
regular: path.join(__dirname, '../fonts/NotoSans-Regular.ttf'),
bold: path.join(__dirname, '../fonts/NotoSans-Bold.ttf'),
italic: path.join(__dirname, '../fonts/NotoSans-Italic.ttf'),
};Template Configuration
Template Structure
typescript
interface PdfTemplate {
id: string;
name: string;
logo?: {
path: string;
width: number;
position: { x: number; y: number };
};
colors: {
primary: string;
secondary: string;
text: string;
};
fonts: {
title: { name: string; size: number };
body: { name: string; size: number };
small: { name: string; size: number };
};
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
sections: {
header: boolean;
sellerInfo: boolean;
buyerInfo: boolean;
itemsTable: boolean;
totals: boolean;
amountInWords: boolean;
qrCode: boolean;
footer: boolean;
};
}Default Template
typescript
const defaultTemplate: PdfTemplate = {
id: 'default',
name: 'Standard VAT Invoice',
colors: {
primary: '#1a1a1a',
secondary: '#666666',
text: '#333333',
},
fonts: {
title: { name: 'Vietnamese-Bold', size: 18 },
body: { name: 'Vietnamese', size: 10 },
small: { name: 'Vietnamese', size: 8 },
},
margins: { top: 50, bottom: 50, left: 50, right: 50 },
sections: {
header: true,
sellerInfo: true,
buyerInfo: true,
itemsTable: true,
totals: true,
amountInWords: true,
qrCode: true,
footer: true,
},
};PDF Storage
Storage Options
typescript
interface PdfStorageConfig {
type: 'local' | 'minio' | 's3';
bucket?: string;
pathPrefix?: string;
expiryDays?: number;
}
// Local storage
const localConfig: PdfStorageConfig = {
type: 'local',
pathPrefix: '/var/data/invoices/pdf',
};
// MinIO storage
const minioConfig: PdfStorageConfig = {
type: 'minio',
bucket: 'invoices',
pathPrefix: 'pdf',
expiryDays: 3650, // 10 years
};Storage Service
typescript
@injectable()
class InvoicePdfStorageService {
async savePdf(invoice: Invoice, pdfBuffer: Buffer): Promise<string> {
const filename = this.generateFilename(invoice);
if (this.config.type === 'minio') {
await this.minioClient.putObject(
this.config.bucket,
filename,
pdfBuffer,
{ 'Content-Type': 'application/pdf' },
);
return `${this.config.bucket}/${filename}`;
}
const filePath = path.join(this.config.pathPrefix, filename);
await fs.writeFile(filePath, pdfBuffer);
return filePath;
}
private generateFilename(invoice: Invoice): string {
const year = invoice.issueDate.getFullYear();
const month = String(invoice.issueDate.getMonth() + 1).padStart(2, '0');
return `${year}/${month}/${invoice.invoiceNumber.replace('/', '_')}.pdf`;
}
}Error Handling
| Error | Code | Description |
|---|---|---|
PDF_GENERATION_FAILED | E001 | Failed to generate PDF |
FONT_NOT_FOUND | E002 | Required font not available |
QR_GENERATION_FAILED | E003 | Failed to generate QR code |
STORAGE_FAILED | E004 | Failed to save PDF |
TEMPLATE_NOT_FOUND | E005 | PDF template not found |