Skip to content

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|ABC123DEF456

QR Code Fields

PositionFieldDescription
1MST SellerSeller's tax code
2Invoice NumberFull invoice number
3Issue DateFormat: YYYYMMDD
4Total AmountGrand total
5VAT AmountTotal VAT
6CQT CodeTax 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
// - Inter

Font 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

ErrorCodeDescription
PDF_GENERATION_FAILEDE001Failed to generate PDF
FONT_NOT_FOUNDE002Required font not available
QR_GENERATION_FAILEDE003Failed to generate QR code
STORAGE_FAILEDE004Failed to save PDF
TEMPLATE_NOT_FOUNDE005PDF template not found

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.