PDF Generation with PDFKit: Invoices, Reports, and Certificates

Generate professional PDFs with PDFKit in Node.js. Covers invoice templates, tables, headers, footers, fonts, images, streaming, and NestJS integration for production PDF generation.

E
ECOSIRE Research and Development Team
|19 मार्च 202611 मिनट पढ़ें2.4k शब्द|

पीडीएफकिट के साथ पीडीएफ जनरेशन: चालान, रिपोर्ट और प्रमाण पत्र

पीडीएफ जनरेशन उन सुविधाओं में से एक है जो तब तक सरल लगती है जब तक आप इसे लागू करना शुरू नहीं करते। गतिशील तालिकाएँ जो पृष्ठों को सही ढंग से ओवरफ़्लो करती हैं, अरबी आरटीएल समर्थन के साथ बहु-भाषा पाठ, उचित पृष्ठ क्रमांकन, पृष्ठों पर सुसंगत शीर्षलेख और पादलेख, एम्बेडेड लोगो - इन सभी के लिए एक लेआउट इंजन की आवश्यकता होती है जो आपके लिए जटिलता को संभालता है। PDFKit एक निम्न-स्तरीय लेकिन शक्तिशाली Node.js PDF जनरेशन लाइब्रेरी है जो आपको प्रत्येक तत्व पर सटीक नियंत्रण प्रदान करती है।

यह मार्गदर्शिका NestJS में उत्पादन पीडीएफ पीढ़ी के लिए पीडीएफकिट पैटर्न को कवर करती है: इनवॉइस टेम्प्लेट, पेजिनेशन के साथ डेटा टेबल, मल्टी-कॉलम लेआउट, कस्टम फ़ॉन्ट, छवि एम्बेडिंग और स्ट्रीमिंग डिलीवरी - संलग्न पीडीएफ को अनुलग्नक के रूप में ईमेल करने तक।

मुख्य बातें

  • PDFKit Node.js में प्रोग्रामेटिक रूप से PDF उत्पन्न करता है - कोई ब्राउज़र नहीं, कोई कठपुतली नहीं, कोई HTML-से-पीडीएफ रूपांतरण नहीं
  • भेजने से पहले पीडीएफ को हमेशा पूरा होने तक बफर करें - क्लाइंट को अधूरी पीडीएफ स्ट्रीम न करें
  • लगातार क्रॉस-प्लेटफ़ॉर्म रेंडरिंग के लिए registerFont() के माध्यम से फ़ॉन्ट एम्बेड करें - कभी भी सिस्टम फ़ॉन्ट पर भरोसा न करें
  • बहु-पृष्ठ दस्तावेज़ों को मैन्युअल पेज ब्रेक डिटेक्शन की आवश्यकता होती है: doc.page.height के विरुद्ध doc.y स्थिति की जाँच करें
  • अरबी और आरटीएल पाठ के लिए, उचित अरबी फ़ॉन्ट (नोटो सैन्स अरबी) का उपयोग करें और पाठ दिशा को मैन्युअल रूप से संभालें
  • AWS S3 पर PDF स्ट्रीम करें और एक पूर्व-हस्ताक्षरित URL लौटाएँ - अपने API के माध्यम से बड़े बफ़र्स पास न करें
  • परीक्षण के लिए doc.pipe(fs.createWriteStream()) का उपयोग करें; HTTP स्ट्रीमिंग के लिए doc.pipe(res); ईमेल अनुलग्नकों के लिए बफर
  • अभिलेखीय अनुपालन के लिए पीडीएफ/ए मेटाडेटा जोड़ें: Title, Author, Creator, Producer फ़ील्ड

स्थापना

pnpm add pdfkit
pnpm add -D @types/pdfkit

छवि समर्थन (पीएनजी, जेपीईजी) और फ़ॉन्ट सब्सेटिंग के लिए, किसी अतिरिक्त पैकेज की आवश्यकता नहीं है - पीडीएफकिट उन्हें मूल रूप से संभालता है।


बुनियादी चालान संरचना

// src/modules/billing/pdf/invoice.generator.ts
import PDFDocument from 'pdfkit';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';

interface InvoiceData {
  invoiceNumber: string;
  date: Date;
  dueDate: Date;
  customer: {
    name: string;
    email: string;
    address: string;
    city: string;
    country: string;
  };
  items: Array<{
    description: string;
    quantity: number;
    unitPrice: number;
    total: number;
  }>;
  subtotal: number;
  tax: number;
  total: number;
  currency: string;
  notes?: string;
}

@Injectable()
export class InvoiceGenerator {
  private readonly primaryColor = '#0f172a';
  private readonly accentColor  = '#f59e0b';
  private readonly mutedColor   = '#64748b';
  private readonly borderColor  = '#e2e8f0';

  constructor(private config: ConfigService) {}

  async generate(data: InvoiceData): Promise<Buffer> {
    return new Promise((resolve, reject) => {
      const buffers: Buffer[] = [];
      const doc = new PDFDocument({
        size: 'A4',
        margin: 50,
        info: {
          Title:    `Invoice ${data.invoiceNumber}`,
          Author:   'ECOSIRE Private Limited',
          Creator:  'ECOSIRE Billing System',
          Producer: 'PDFKit',
        },
      });

      // Collect chunks into buffer (do not stream incomplete PDFs)
      doc.on('data', (chunk: Buffer) => buffers.push(chunk));
      doc.on('end', () => resolve(Buffer.concat(buffers)));
      doc.on('error', reject);

      this.renderHeader(doc, data);
      this.renderCustomerSection(doc, data);
      this.renderItemsTable(doc, data);
      this.renderTotals(doc, data);
      if (data.notes) this.renderNotes(doc, data.notes);
      this.renderFooter(doc, data);

      doc.end();
    });
  }

  private renderHeader(doc: PDFKit.PDFDocument, data: InvoiceData): void {
    // Company logo
    const logoPath = path.join(process.cwd(), 'public', 'logo-dark.png');
    if (fs.existsSync(logoPath)) {
      doc.image(logoPath, 50, 45, { width: 120 });
    } else {
      doc
        .fontSize(18)
        .fillColor(this.primaryColor)
        .font('Helvetica-Bold')
        .text('ECOSIRE', 50, 50);
    }

    // Invoice label and number
    doc
      .fontSize(28)
      .fillColor(this.accentColor)
      .font('Helvetica-Bold')
      .text('INVOICE', 0, 50, { align: 'right' });

    doc
      .fontSize(10)
      .fillColor(this.mutedColor)
      .font('Helvetica')
      .text(`#${data.invoiceNumber}`, 0, 82, { align: 'right' });

    // Divider line
    doc
      .moveTo(50, 110)
      .lineTo(545, 110)
      .lineWidth(1)
      .strokeColor(this.borderColor)
      .stroke();

    doc.moveDown(2);
  }

  private renderCustomerSection(doc: PDFKit.PDFDocument, data: InvoiceData): void {
    const startY = 130;

    // Billed To
    doc
      .fontSize(8)
      .fillColor(this.mutedColor)
      .font('Helvetica-Bold')
      .text('BILLED TO', 50, startY)
      .fontSize(11)
      .fillColor(this.primaryColor)
      .text(data.customer.name, 50, startY + 16)
      .fontSize(9)
      .fillColor(this.mutedColor)
      .font('Helvetica')
      .text(data.customer.email, 50, startY + 32)
      .text(data.customer.address, 50, startY + 46)
      .text(`${data.customer.city}, ${data.customer.country}`, 50, startY + 60);

    // Invoice dates (right column)
    const dateRows = [
      ['Invoice Date:', this.formatDate(data.date)],
      ['Due Date:',     this.formatDate(data.dueDate)],
      ['Currency:',     data.currency],
    ];

    let dateY = startY;
    for (const [label, value] of dateRows) {
      doc
        .fontSize(9)
        .fillColor(this.mutedColor)
        .font('Helvetica')
        .text(label, 350, dateY, { width: 90 });
      doc
        .fontSize(9)
        .fillColor(this.primaryColor)
        .font('Helvetica-Bold')
        .text(value, 445, dateY, { width: 100, align: 'right' });
      dateY += 18;
    }

    doc.moveDown(5);
  }

  private renderItemsTable(doc: PDFKit.PDFDocument, data: InvoiceData): void {
    const tableTop = 265;
    const colWidths = { desc: 230, qty: 60, unitPrice: 90, total: 90 };
    const startX = 50;

    // Table header background
    doc
      .rect(startX, tableTop, 495, 22)
      .fillColor(this.primaryColor)
      .fill();

    // Header text
    doc
      .fontSize(9)
      .fillColor('#ffffff')
      .font('Helvetica-Bold')
      .text('DESCRIPTION', startX + 8, tableTop + 6, { width: colWidths.desc })
      .text('QTY', startX + colWidths.desc + 8, tableTop + 6, { width: colWidths.qty, align: 'center' })
      .text('UNIT PRICE', startX + colWidths.desc + colWidths.qty + 8, tableTop + 6, { width: colWidths.unitPrice, align: 'right' })
      .text('TOTAL', startX + colWidths.desc + colWidths.qty + colWidths.unitPrice + 8, tableTop + 6, { width: colWidths.total, align: 'right' });

    let rowY = tableTop + 28;
    const fmt = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: data.currency,
    });

    for (const [index, item] of data.items.entries()) {
      // Alternating row background
      if (index % 2 === 0) {
        doc
          .rect(startX, rowY - 4, 495, 24)
          .fillColor('#f8fafc')
          .fill();
      }

      doc
        .fontSize(9)
        .fillColor(this.primaryColor)
        .font('Helvetica')
        .text(item.description, startX + 8, rowY, { width: colWidths.desc })
        .text(String(item.quantity), startX + colWidths.desc + 8, rowY, { width: colWidths.qty, align: 'center' })
        .text(fmt.format(item.unitPrice), startX + colWidths.desc + colWidths.qty + 8, rowY, { width: colWidths.unitPrice, align: 'right' })
        .text(fmt.format(item.total), startX + colWidths.desc + colWidths.qty + colWidths.unitPrice + 8, rowY, { width: colWidths.total, align: 'right' });

      rowY += 24;

      // Page break check
      if (rowY > doc.page.height - 150) {
        doc.addPage();
        rowY = 50;
      }
    }

    // Table bottom border
    doc
      .moveTo(startX, rowY + 4)
      .lineTo(545, rowY + 4)
      .lineWidth(1)
      .strokeColor(this.borderColor)
      .stroke();

    doc.y = rowY + 20;
  }

  private renderTotals(doc: PDFKit.PDFDocument, data: InvoiceData): void {
    const fmt = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: data.currency,
    });

    const totalsX = 350;
    let currentY = doc.y + 10;

    const rows = [
      ['Subtotal', fmt.format(data.subtotal), false],
      [`Tax (${((data.tax / data.subtotal) * 100).toFixed(0)}%)`, fmt.format(data.tax), false],
      ['Total Due', fmt.format(data.total), true],
    ];

    for (const [label, value, isTotal] of rows) {
      if (isTotal) {
        doc
          .rect(totalsX, currentY - 4, 195, 28)
          .fillColor(this.accentColor)
          .fill();
        doc
          .fontSize(11)
          .fillColor('#000000')
          .font('Helvetica-Bold')
          .text(label as string, totalsX + 8, currentY + 2, { width: 90 })
          .text(value as string, totalsX + 98, currentY + 2, { width: 89, align: 'right' });
      } else {
        doc
          .fontSize(9)
          .fillColor(this.mutedColor)
          .font('Helvetica')
          .text(label as string, totalsX + 8, currentY, { width: 90 })
          .fillColor(this.primaryColor)
          .text(value as string, totalsX + 98, currentY, { width: 89, align: 'right' });
      }
      currentY += 28;
    }

    doc.y = currentY + 10;
  }

  private renderNotes(doc: PDFKit.PDFDocument, notes: string): void {
    doc.moveDown(2);
    doc
      .fontSize(9)
      .fillColor(this.mutedColor)
      .font('Helvetica-Bold')
      .text('NOTES', 50, doc.y);
    doc
      .fontSize(9)
      .fillColor(this.primaryColor)
      .font('Helvetica')
      .text(notes, 50, doc.y + 12, { width: 495 });
  }

  private renderFooter(doc: PDFKit.PDFDocument, _data: InvoiceData): void {
    const footerY = doc.page.height - 60;

    doc
      .moveTo(50, footerY - 10)
      .lineTo(545, footerY - 10)
      .lineWidth(1)
      .strokeColor(this.borderColor)
      .stroke();

    doc
      .fontSize(8)
      .fillColor(this.mutedColor)
      .font('Helvetica')
      .text(
        'ECOSIRE Private Limited  |  [email protected]  |  ecosire.com',
        50,
        footerY,
        { align: 'center', width: 495 }
      );
  }

  private formatDate(date: Date): string {
    return new Intl.DateTimeFormat('en-US', {
      year: 'numeric', month: 'long', day: 'numeric',
    }).format(date);
  }
}

NestJS नियंत्रक एकीकरण

// src/modules/billing/billing.controller.ts
@Controller('billing')
export class BillingController {
  constructor(
    private readonly billingService: BillingService,
    private readonly invoiceGenerator: InvoiceGenerator,
    private readonly emailService: EmailService,
  ) {}

  @Get('invoices/:id/pdf')
  async downloadInvoice(
    @Param('id') id: string,
    @Req() req: AuthenticatedRequest,
    @Res() res: Response
  ): Promise<void> {
    const invoice = await this.billingService.getInvoice(id, req.user.organizationId);

    if (!invoice) {
      throw new NotFoundException('Invoice not found');
    }

    const pdfBuffer = await this.invoiceGenerator.generate({
      invoiceNumber: invoice.number,
      date: invoice.createdAt,
      dueDate: invoice.dueDate,
      customer: {
        name: req.user.name,
        email: req.user.email,
        address: invoice.billingAddress,
        city: invoice.billingCity,
        country: invoice.billingCountry,
      },
      items: invoice.lineItems,
      subtotal: invoice.subtotal,
      tax: invoice.tax,
      total: invoice.total,
      currency: invoice.currency,
    });

    res.set({
      'Content-Type':        'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`,
      'Content-Length':      pdfBuffer.length,
      'Cache-Control':       'private, no-cache',
    });

    res.send(pdfBuffer);
  }

  @Post('invoices/:id/email')
  async emailInvoice(
    @Param('id') id: string,
    @Req() req: AuthenticatedRequest
  ) {
    const invoice = await this.billingService.getInvoice(id, req.user.organizationId);
    const pdfBuffer = await this.invoiceGenerator.generate({
      invoiceNumber: invoice.number,
      // ... populate from invoice data
    } as InvoiceData);

    // Non-blocking email send
    this.emailService.sendInvoiceEmail(
      req.user.email,
      req.user.name,
      invoice.number,
      pdfBuffer
    ).catch((err) =>
      this.logger.warn(`Invoice email failed: ${err.message}`)
    );

    return { message: 'Invoice sent to ' + req.user.email };
  }
}

पृष्ठ संख्याओं के साथ बहु-पृष्ठ रिपोर्ट

// For reports spanning multiple pages
async generateReport(data: ReportData): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const buffers: Buffer[] = [];
    const doc = new PDFDocument({ size: 'A4', margin: 50 });

    doc.on('data', (chunk: Buffer) => buffers.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(buffers)));
    doc.on('error', reject);

    const pageCount = Math.ceil(data.rows.length / 30); // 30 rows per page
    let currentPage = 0;

    // Add page numbers to every page on finalize
    doc.on('pageAdded', () => {
      currentPage++;
      doc
        .fontSize(8)
        .fillColor('#94a3b8')
        .text(
          `Page ${currentPage}`,
          0,
          doc.page.height - 30,
          { align: 'center', width: doc.page.width }
        );
    });

    // Render content
    this.renderReportHeader(doc, data);

    let rowIndex = 0;
    for (const row of data.rows) {
      if (doc.y > doc.page.height - 100) {
        doc.addPage(); // Triggers 'pageAdded' for footer
        this.renderReportHeader(doc, data); // Re-render header on new page
      }
      this.renderTableRow(doc, row, rowIndex++);
    }

    doc.end();
  });
}

S3 पर स्ट्रीमिंग

बड़ी रिपोर्टों के लिए, मेमोरी में बफरिंग के बजाय सीधे S3 पर स्ट्रीम करें:

import { PassThrough } from 'stream';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

async function generateAndUploadToS3(
  invoiceData: InvoiceData,
  s3: S3Client,
  bucket: string,
  key: string
): Promise<string> {
  const passThrough = new PassThrough();

  const doc = new PDFDocument({ size: 'A4', margin: 50 });
  doc.pipe(passThrough);

  // Render invoice content
  renderInvoice(doc, invoiceData);
  doc.end();

  // Upload stream to S3
  await s3.send(new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    Body: passThrough,
    ContentType: 'application/pdf',
    Metadata: {
      'invoice-number': invoiceData.invoiceNumber,
    },
  }));

  return `s3://${bucket}/${key}`;
}

प्रमाणपत्र टेम्पलेट

async generateCertificate(data: CertificateData): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const buffers: Buffer[] = [];
    const doc = new PDFDocument({
      size: [792, 612], // Letter landscape
      margin: 60,
      layout: 'landscape',
    });

    doc.on('data', (chunk: Buffer) => buffers.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(buffers)));
    doc.on('error', reject);

    // Background
    doc.rect(0, 0, 792, 612).fillColor('#fefce8').fill();

    // Gold border (double line)
    doc
      .rect(20, 20, 752, 572)
      .lineWidth(3)
      .strokeColor('#f59e0b')
      .stroke();
    doc
      .rect(28, 28, 736, 556)
      .lineWidth(1)
      .strokeColor('#f59e0b')
      .stroke();

    // Title
    doc
      .fontSize(36)
      .fillColor('#0f172a')
      .font('Helvetica-Bold')
      .text('CERTIFICATE OF COMPLETION', 60, 100, { align: 'center' });

    // Recipient name
    doc
      .fontSize(28)
      .fillColor('#f59e0b')
      .text(data.recipientName, 60, 200, { align: 'center' });

    // Course name
    doc
      .fontSize(16)
      .fillColor('#0f172a')
      .font('Helvetica')
      .text(`has successfully completed`, 60, 260, { align: 'center' })
      .fontSize(20)
      .font('Helvetica-Bold')
      .text(data.courseName, 60, 290, { align: 'center' });

    // Date and signature
    doc
      .fontSize(11)
      .fillColor('#64748b')
      .font('Helvetica')
      .text(`Issued on ${this.formatDate(data.issuedDate)}`, 60, 400, { align: 'center' });

    doc.end();
  });
}

अक्सर पूछे जाने वाले प्रश्न

पीडीएफ पीढ़ी के लिए पीडीएफकिट बनाम कठपुतली - मुझे किसका उपयोग करना चाहिए?

संरचित दस्तावेजों के लिए पीडीएफकिट: चालान, रिपोर्ट, प्रमाण पत्र, पत्र। यह तेज़ है (कोई ब्राउज़र स्टार्टअप नहीं), न्यूनतम मेमोरी का उपयोग करता है, और लेआउट पर पिक्सेल-परिपूर्ण नियंत्रण देता है। HTML-से-पीडीएफ रूपांतरण के लिए कठपुतली - सबसे अच्छा जब आपके पास पहले से ही एक HTML टेम्पलेट है और इसे जटिल सीएसएस, वेब फोंट और गतिशील जावास्क्रिप्ट सहित प्रस्तुत करने की आवश्यकता है। कठपुतली 5-10 गुना धीमी है और इसके लिए हेडलेस क्रोम प्रक्रिया की आवश्यकता होती है, लेकिन यह वेब-युग के लेआउट को बेहतर ढंग से संभालता है। चालान और व्यावसायिक दस्तावेज़ों के लिए, PDFKit सही विकल्प है।

मैं PDFKit में गतिशील सामग्री के लिए पेज ब्रेक कैसे प्रबंधित करूं?

पीडीएफकिट ऑटो-पेगिनेट नहीं करता है - आपको प्रत्येक तत्व को प्रस्तुत करने से पहले doc.page.height - margin के विरुद्ध doc.y की जांच करनी होगी। पैटर्न: if (doc.y > doc.page.height - minSpaceNeeded) { doc.addPage(); renderPageHeader(doc); }. तालिका पंक्तियों के लिए, एक पंक्ति के लिए आवश्यक न्यूनतम स्थान की गणना करें और प्रत्येक पंक्ति से पहले जांचें। बहु-पंक्ति पाठ के लिए, ऊँचाई का अनुमान fontSize * lineCount * 1.2 के रूप में लगाएं।

मैं PDFKit में कस्टम फ़ॉन्ट कैसे एम्बेड करूं?

फ़ॉन्ट का उपयोग करने से पहले उन्हें doc.registerFont('FontName', '/path/to/font.ttf') के साथ पंजीकृत करें। इसे अपने सर्विस कंस्ट्रक्टर या सेटअप फ़ंक्शन में कॉल करें। पीडीएफकिट 14 मानक पीडीएफ फोंट (हेल्वेटिका, टाइम्स, कूरियर और उनके बोल्ड/इटैलिक वेरिएंट) के साथ आता है - इन्हें पंजीकृत करने की आवश्यकता नहीं है। यूनिकोड समर्थन (अरबी, चीनी, जापानी) के लिए, आपको एक यूनिकोड-संगत टीटीएफ/ओटीएफ फ़ॉन्ट पंजीकृत करना होगा; मानक पीडीएफ फ़ॉन्ट केवल लैटिन वर्णों को कवर करते हैं।

मैं अरबी या अन्य आरटीएल भाषाओं के लिए पीडीएफ कैसे उत्पन्न करूं?

पीडीएफकिट यूनिकोड का समर्थन करता है लेकिन मूल रूप से द्विदिश पाठ पुनर्क्रमण को संभाल नहीं पाता है। अरबी के लिए, नोटो सैन्स अरबी या अमीरी फ़ॉन्ट पंजीकृत करें। पीडीएफकिट में भेजने से पहले पाठ को पुन: व्यवस्थित करने के लिए बीड़ी एल्गोरिदम लाइब्रेरी (जैसे bidi-js) का उपयोग करें। आरटीएल दस्तावेज़ों के लिए लेआउट को मैन्युअल रूप से उलटें (दाएं-संरेखित पाठ, प्रतिबिंबित तालिका कॉलम)। पूरी तरह से परीक्षण करें - अरबी पीडीएफ निर्माण के लिए पाठ को जोड़ने, विशेषक और संख्या दिशा को सावधानीपूर्वक संभालने की आवश्यकता होती है।

मैं डिजिटल हस्ताक्षर या पीडीएफ/ए अनुपालन कैसे जोड़ूं?

PDFKit info विकल्प के माध्यम से अभिलेखीय (शीर्षक, लेखक, विषय, कीवर्ड) के लिए बुनियादी पीडीएफ मेटाडेटा का समर्थन करता है। डिजिटल हस्ताक्षरों के लिए, PDFKit के साथ node-signpdf लाइब्रेरी का उपयोग करें - यह PKCS#7 हस्ताक्षर को एम्बेड करने के लिए पीडीएफ स्ट्रीम को संशोधित करता है। पीडीएफ/ए अनुपालन (दीर्घकालिक अभिलेखीय) के लिए, एम्बेडेड फ़ॉन्ट, कोई पारदर्शिता नहीं, और उचित रंग स्थान परिभाषा सुनिश्चित करें। PDFKit v0.13+ ने इन सुविधाओं के लिए समर्थन में सुधार किया है, लेकिन जटिल अनुपालन आवश्यकताओं को pdf-lib जैसी समर्पित लाइब्रेरी से लाभ हो सकता है।


अगले चरण

PDFKit सीधे आपके Node.js बैकएंड में पेशेवर, प्रोग्रामेटिक पीडीएफ जेनरेशन को सक्षम बनाता है - कोई ब्राउज़र ओवरहेड नहीं, कोई बाहरी सेवाएँ नहीं, कोई प्रति-पृष्ठ मूल्य निर्धारण नहीं। चालान, रिपोर्ट, प्रमाणपत्र और विवरण आपके एपीआई की प्रथम श्रेणी की विशेषताएं बन जाते हैं।

ECOSIRE प्रत्येक NestJS प्रोजेक्ट पर संपूर्ण बिलिंग पाइपलाइन के हिस्से के रूप में पीडीएफ इनवॉइस जेनरेशन, स्वचालित ईमेल डिलीवरी और S3 अभिलेखीय लागू करता है। हमारी बैकएंड इंजीनियरिंग सेवाओं का अन्वेषण करें यह जानने के लिए कि हम उत्पादन-ग्रेड इनवॉइस और रिपोर्टिंग सिस्टम कैसे बनाते हैं।

शेयर करें:
E

लेखक

ECOSIRE Research and Development Team

ECOSIRE में एंटरप्राइज़-ग्रेड डिजिटल उत्पाद बना रहे हैं। Odoo एकीकरण, ई-कॉमर्स ऑटोमेशन, और AI-संचालित व्यावसायिक समाधानों पर अंतर्दृष्टि साझा कर रहे हैं।

WhatsApp पर चैट करें