Untitled

 avatar
unknown
plain_text
12 days ago
12 kB
0
Indexable
import PDFDocument from 'pdfkit';
import fs from 'fs';

interface Invoice {
  companyName?: string;
  invoice?: string;
  billTo?: string;
  billToInfo?: Record<string, any>;
  shipTo?: string;
  shipToInfo?: Record<string, any>;
  paymentDate?: string;
  paymentDateInfo?: string;
  paymentTerms?: string;
  paymentTermsInfo?: string;
  dueDate?: string;
  dueDateInfo?: string;
  poNumber?: string;
  poNumberInfo?: string;
  headerTextColor?: string;
  headerBgColor?: string;
  amountDue?: string;
  calc?: {
    due?: number;
    subTotal?: number;
  };
  item?: string;
  quantity?: string;
  rate?: string;
  amount?: string;
  rows?: Array<{
    title: string;
    quantity: number;
    rate: number;
  }>;
  subtotal?: string;
  shipping?: string;
  shippingInfo?: number;
  tax?: string;
  taxInfo?: number;
  discount?: string;
  discountInfo?: number;
  total?: string;
  totalInfo?: number;
  amountPaid?: string;
  amountPaidInfo?: number;
  note?: string;
  noteInfo?: string;
  terms?: string;
  termsInfo?: string;
}

function createInvoice(invoice: Invoice, outputPath: string): void {
  if (!invoice || typeof invoice !== 'object') {
    throw new Error('Invalid input invoice provided.');
  }
  console.log(invoice, 'invoice');
  const doc = new PDFDocument({ margin: 35, size: 'A4' });
  doc.pipe(fs.createWriteStream(outputPath));

  doc.font('Helvetica');

  generateHeader(doc, invoice);

  const dynamicSectionsEndY = generateDynamicSections(doc, invoice, 50, 140);

  const tableStartY = Math.max(dynamicSectionsEndY, 300);
  generateInvoiceDetails(doc, invoice);
  generateInvoiceTable(doc, invoice, tableStartY);

  doc.end();
}

function generateHeader(doc: PDFDocument, invoice: Invoice): void {
  doc
    .image('rootdevs-logo.png', 20, 25, { width: 160, fit: [100, 100] })
    .fillColor('#444444')
    .fontSize(10)
    .text(`${invoice?.companyName}`, 30, 70, {
      width: 350,
    })
    .fontSize(25)
    .text(`${invoice?.invoice}`, 20, 36, { align: 'right' })
    .fontSize(14)
    .moveDown();
}

function generateDynamicSections(doc: PDFDocument, data: Invoice, startX: number, startY: number): number {
  const sectionSpacing = 152;
  let currentX = startX;
  let maxY = startY;
  const extraSpacing = 4; // Add extra space between fields

  // Helper function to flatten nested objects
  const flattenObject = (obj: Record<string, any>, parentKey = ''): Record<string, any> => {
    return Object.keys(obj).reduce((acc, key) => {
      const newKey = parentKey ? `${parentKey}.${key}` : key;
      if (typeof obj[key] === 'object' && obj[key] !== null) {
        Object.assign(acc, flattenObject(obj[key], newKey));
      } else {
        acc[newKey] = obj[key];
      }
      return acc;
    }, {});
  };

  // Process billToInfo
  const billToInfo = flattenObject(data.billToInfo || {});
  let currentY = startY;

  // Write section name for Bill To
  doc
    .fillColor('#666666')
    .font('Helvetica')
    .fontSize(11)
    .text(`${data.billTo}`, currentX - 20, currentY);
  currentY += 15;

  // Process each field in billToInfo
  Object.keys(billToInfo).forEach((key) => {
    const value = billToInfo[key];

    // Write field and value with text wrapping
    const wrappedText = `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`;
    doc
      .fillColor('#333333')
      .font('Helvetica')
      .fontSize(10)
      .text(wrappedText, currentX - 20, currentY, {
        width: 160, // Wrap text to the specified width
        lineBreak: true, // Enable line wrapping for long text
      });

    // Calculate the height of the text block
    const textHeight = doc.heightOfString(wrappedText, { width: 160 });

    // Update currentY based on the actual height of the text block
    currentY += textHeight + extraSpacing; // Add extra space after each field
  });

  // Update maxY based on the last field's Y position
  maxY = Math.max(maxY, currentY);

  // Move to the next section (Ship To)
  currentX += sectionSpacing;

  // Process shipToInfo
  const shipToInfo = flattenObject(data.shipToInfo || {});
  currentY = startY;

  // Write section name for Ship To
  doc
    .fillColor('#666666')
    .font('Helvetica')
    .fontSize(11)
    .text(`${data.shipTo}`, currentX, currentY);
  currentY += 15;

  // Process each field in shipToInfo
  Object.keys(shipToInfo).forEach((key) => {
    const value = shipToInfo[key];

    // Write field and value with text wrapping
    const wrappedText = `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`;
    doc
      .fillColor('#333333')
      .font('Helvetica')
      .fontSize(10)
      .text(wrappedText, currentX, currentY, {
        width: 160, // Wrap text to the specified width
        lineBreak: true, // Enable line wrapping for long text
      });

    // Calculate the height of the text block
    const textHeight = doc.heightOfString(wrappedText, { width: 160 });

    // Update currentY based on the actual height of the text block
    currentY += textHeight + extraSpacing; // Add extra space after each field
  });

  // Update maxY based on the last field's Y position
  maxY = Math.max(maxY, currentY);

  return maxY;
}

function generateInvoiceDetails(doc: PDFDocument, invoice: Invoice): void {
  const detailsTop = 130;
  let currentY = detailsTop;

  // Helper function to check if a value is empty
  const isEmpty = (value: any): boolean => {
    return value === null || value === undefined || value === '';
  };

  // Render fields only if both the label and value are not empty
  if (!isEmpty(invoice?.paymentDate) && !isEmpty(invoice.paymentDateInfo)) {
    doc
      .fontSize(10)
      .fillColor('#666666')
      .text(`${invoice.paymentDate}`, 0, currentY, {
        width: 470,
        align: 'right',
      })
      .fillColor('#000000')
      .text(formatDate(new Date(invoice.paymentDateInfo!)), 0, currentY, {
        width: 550,
        align: 'right',
      });
    currentY += 25; // Move to the next line
  }

  if (!isEmpty(invoice?.paymentTerms) && !isEmpty(invoice.paymentTermsInfo)) {
    doc
      .fontSize(10)
      .fillColor('#666666')
      .text(`${invoice.paymentTerms}`, 0, currentY, {
        width: 470,
        align: 'right',
      })
      .fillColor('#000000')
      .text(`${invoice.paymentTermsInfo}`, 0, currentY, {
        width: 550,
        align: 'right',
      });
    currentY += 25; // Move to the next line
  }

  if (!isEmpty(invoice?.dueDate) && !isEmpty(invoice.dueDateInfo)) {
    doc
      .fontSize(10)
      .fillColor('#666666')
      .text(`${invoice.dueDate}`, 0, currentY, {
        width: 470,
        align: 'right',
      })
      .fillColor('#000000')
      .text(formatDate(new Date(invoice.dueDateInfo!)), 0, currentY, {
        width: 550,
        align: 'right',
      });
    currentY += 25; // Move to the next line
  }

  if (!isEmpty(invoice?.poNumber) && !isEmpty(invoice.poNumberInfo)) {
    doc
      .fontSize(10)
      .fillColor('#666666')
      .text(`${invoice.poNumber}`, 0, currentY, {
        width: 470,
        align: 'right',
      })
      .fillColor('#000000')
      .text(`${invoice.poNumberInfo}`, 0, currentY, {
        width: 550,
        align: 'right',
      });
    currentY += 25; // Move to the next line
  }

  // Draw the line
  doc
    .fillColor(`${invoice.headerTextColor?.slice(0, 7)}`)
    .lineWidth(20)
    .lineCap('round')
    .moveTo(560, currentY + 4)
    .lineTo(390, currentY + 4)
    .strokeColor(`${invoice.headerBgColor?.slice(0, 7)}`)
    .stroke();

  // Render amount due only if it is not empty
  if (!isEmpty(invoice?.amountDue) && !isEmpty(invoice.calc?.due)) {
    doc
      .font('Helvetica-Bold')
      .text(`${invoice.amountDue}`, 0, currentY, {
        width: 470,
        align: 'right',
      })
      .text(`${invoice.calc.due}`, 0, currentY, {
        width: 550,
        align: 'right',
      });
  }
}

function generateInvoiceTable(doc: PDFDocument, invoice: Invoice, tableStartY: number): void {
  let i;
  const invoiceTableTop = 320;

  doc.font('Helvetica-Bold');
  doc
    .lineWidth(25)
    .lineCap('round')
    .moveTo(25, 318)
    .lineTo(565, 318)
    .strokeColor(`${invoice.headerBgColor?.slice(0, 7)}`)
    .stroke();
  doc.fillColor(`${invoice.headerTextColor?.slice(0, 7)}`);
  generateTableRow(
    doc,
    invoiceTableTop - 5,
    `${invoice.item}`,
    `${invoice.quantity}`,
    `${invoice.rate}`,
    `${invoice.amount}`
  );
  doc.fillColor('#000000');
  doc.font('Helvetica');

  for (i = 0; i < (invoice.rows?.length || 0); i++) {
    const item = invoice.rows![i];
    const position = invoiceTableTop + (i + 1) * 24;
    generateTableRow(
      doc,
      position,
      item.title,
      item.quantity,
      item.rate,
      item.quantity * item.rate
    );
  }

  const subtotalPosition = invoiceTableTop + (i + 1) * 27;
  generateHr(doc, subtotalPosition - 15);
  generateTableRow(
    doc,
    subtotalPosition,
    '',
    '',
    `${invoice.subtotal}`,
    formatCurrency(invoice.calc?.subTotal || 0)
  );

  let currentPosition = subtotalPosition + 20;

  if (invoice.shippingInfo) {
    generateTableRow(
      doc,
      currentPosition,
      '',
      '',
      `${invoice.shipping}`,
      formatCurrency(invoice.shippingInfo)
    );
    currentPosition += 20;
  }

  if (invoice.taxInfo) {
    generateTableRow(
      doc,
      currentPosition,
      '',
      '',
      `${invoice.tax}`,
      formatCurrency(invoice.taxInfo)
    );
    currentPosition += 20;
  }
  if (invoice.discountInfo) {
    generateTableRow(
      doc,
      currentPosition,
      '',
      '',
      `${invoice.discount}`,
      formatCurrency(invoice.discountInfo)
    );
    currentPosition += 20;
  }
  if (invoice.totalInfo) {
    generateTableRow(
      doc,
      currentPosition,
      '',
      '',
      `${invoice.total}`,
      formatCurrency(invoice.totalInfo)
    );
    currentPosition += 20;
  }
  if (invoice.amountPaidInfo) {
    generateTableRow(
      doc,
      currentPosition,
      '',
      '',
      `${invoice.amountPaid}`,
      formatCurrency(invoice.amountPaidInfo)
    );
    currentPosition += 20;
  }

  const duePosition = currentPosition;
  doc.font('Helvetica-Bold');
  generateTableRow(
    doc,
    duePosition,
    '',
    '',
    `${invoice.amountDue}`,
    formatCurrency(invoice.calc?.due || 0)
  );

  const tableHeight = duePosition + 20;

  generateNotesAndTerms(doc, invoice, tableHeight);
}

function generateTableRow(doc: PDFDocument, y: number, item: string, unitCost: string, quantity: string, lineTotal: string): void {
  doc
    .fontSize(10)
    .text(item, 30, y)
    .text(unitCost, 250, y, { width: 90, align: 'right' })
    .text(quantity, 350, y, { width: 90, align: 'right' })
    .text(lineTotal, 420, y, { align: 'right' });
}

function generateNotesAndTerms(doc: PDFDocument, invoice: Invoice, tableHeight: number): void {
  let notePosition = tableHeight + 20;
  const lorem1Height = doc.heightOfString(`${invoice?.noteInfo}`, {
    width: 500,
  });
  if (invoice.noteInfo) {
    doc.fontSize(10).text(`${invoice.note}`, 25, notePosition);

    doc.font('Helvetica').text(`${invoice.noteInfo}`, 25, notePosition + 15);
  }

  const termsPosition = notePosition + lorem1Height + 20;

  if (invoice.termsInfo) {
    doc
      .fontSize(10)
      .font('Helvetica-Bold')
      .text(`${invoice.terms}`, 25, termsPosition);

    doc.font('Helvetica').text(`${invoice.termsInfo}`, 25, termsPosition + 15);
  }
}

function formatCurrency(amount: number): string {
  return `$${Number(amount)?.toFixed(2)}`;
}

function generateHr(doc: PDFDocument, y: number): void {
  doc
    .strokeColor('#06243E')
    .lineWidth(0.1)
    .moveTo(27, y)
    .lineTo(562, y)
    .stroke();
}

function formatDate(date: Date): string {
  const day = date.getDate();
  const month = date.getMonth() + 1;
  const year = date.getFullYear();

  return `${year}/${month}/${day}`;
}

export { createInvoice };
Leave a Comment