Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 084 / 100

Receipts and invoice numbering: best practices explained

Documents finance teams trust.

Expona Admin9 min read

After 84 days of building payment flows, you've handled authorization, settlement, and webhooks—but the document your finance team actually cares about is the one they'll file and audit. Receipts and invoice numbering: best practices explained covers the unglamorous but mission-critical work of generating documents that survive tax season, chargebacks, and regulatory scrutiny. Getting this wrong costs time, reputation, and sometimes money.

Sequential invoice numbers

Sequential numbering isn't optional compliance theater. It's forensic evidence. When a customer disputes a charge or an auditor asks "where are invoices 1042 through 1047," you need to produce them. Gaps, duplicates, or resets create red flags that suggest either incompetence or intentional manipulation.

The pattern you choose should balance readability with uniqueness. Most teams use a prefix (company initials or year), a sequence counter, and sometimes a check digit. A real example: INV-2025-0001847. The year in the middle handles calendar resets cleanly. The leading zeros ensure lexicographic sorting matches numeric order.

Implementation requires a single source of truth. A database sequence or atomic counter prevents two concurrent requests from grabbing the same number. Here's a pattern that works in practice:

typescript

async function generateInvoiceNumber(stripeChargeId: string, customerId: string, db: Database): Promise {
  // Use a database transaction to ensure atomicity
  const invoice = await db.transaction(async (trx) => {
    // Lock the invoice_sequence table to prevent concurrent increments
    const [{ nextSequence }] = await trx.raw(
      'SELECT nextval(?::regclass) as nextSequence',
      ['invoice_sequence_seq']
    );

    const year = new Date().getFullYear();
    const invoiceNumber = `INV-${year}-${String(nextSequence).padStart(6, '0')}`;

    // Store the mapping
    await trx('invoices').insert({
      invoice_number: invoiceNumber,
      stripe_charge_id: stripeChargeId,
      customer_id: customerId,
      created_at: new Date(),
    });

    return invoiceNumber;
  });

  return invoice.invoice_number;
}

PostgreSQL sequences handle the heavy lifting. nextval() guarantees no two calls get the same value, even under high concurrency. Wrap the entire operation in a transaction so the sequence increment and the database insert are atomic. If the insert fails, the transaction rolls back—but the sequence value is consumed. That's fine; a gap in numbering is acceptable. Reusing the same number is not.

For high-volume operations, some teams pre-allocate ranges or use UUIDs with a secondary sequential display number. The invoice itself shows the human-friendly INV-2025-0001847, but the database key is a UUID for resilience.

Include tax details

Tax is where receipts stop being courtesy documents and become legal obligations. Different jurisdictions have different rules. The EU requires VAT identification. US states want sales tax breakdowns. The UK needs HMRC compliance. Your invoice must show what tax was applied, to which line items, and why.

At minimum, include:

  • Subtotal before tax
  • Tax rate and jurisdiction
  • Tax amount (absolute, not just a rate)
  • Total after tax

If you operate internationally, include the customer's tax ID (if registered) and a note about reverse charge or exemption status. A B2B invoice to a VAT-registered EU company might read: "Reverse charge applies. Customer to account for VAT." This protects you in audits and clarifies the customer's accounting obligations.

Stripe's Tax API can compute this for you. You pass the line items, customer location, and shipping address; it returns the tax breakdown. Here's the pattern:

typescript

async function calculateInvoiceTax(
  customerId: string,
  lineItems: Array<{ amount: number; description: string }>,
  shippingAddress: { country: string; state?: string; postal_code: string }
): Promise<{ subtotal: number; taxAmount: number; total: number; taxBreakdown: object }> {
  
  const taxCalculation = await stripe.tax.calculations.create({
    currency: 'usd',
    customer_details: {
      address: shippingAddress,
      taxId: 'txid_1234', // if customer provided
    },
    line_items: lineItems.map((item) => ({
      amount: Math.round(item.amount * 100), // in cents
      reference: item.description,
    })),
  });

  const subtotal = taxCalculation.line_items.data.reduce((sum, item) => sum + item.unit_amount, 0);
  const taxAmount = taxCalculation.tax_amount;
  const total = subtotal + taxAmount;

  return {
    subtotal: subtotal / 100,
    taxAmount: taxAmount / 100,
    total: total / 100,
    taxBreakdown: taxCalculation.tax_breakdown,
  };
}

Store the tax breakdown in your invoice record. When the tax authority asks why an invoice shows a certain rate, you need documentation. Stripe's calculation is auditable and time-stamped. Reference it in the PDF.

Common pitfall: using only the tax rate without showing the absolute amount. "8.875% sales tax" is not as useful as "$8.88 sales tax" when the customer wants to reconcile. Always show both.

Store PDFs for records

Sending a receipt is not the same as having a receipt on file. Email gets deleted, cloud storage gets hacked, or the customer's mailbox fills up. You need a durable, retrievable copy for every invoice.

The standard approach is to generate a PDF on payment success, store it in cloud storage (S3, GCS, Azure Blob), and keep the URL in your database. This gives you:

  • A permanent audit trail
  • Protection against customer disputes ("I never got a receipt")
  • Compliance with retention laws (often 7 years for tax documents)
  • Fast retrieval without regenerating the PDF

Use a library like html2pdf or puppeteer to convert an HTML template to PDF. Puppeteer is heavier but more reliable for complex layouts and embedded images.

typescript

import puppeteer from 'puppeteer';

async function generateInvoicePDF(
  invoiceData: {
    invoiceNumber: string;
    customerName: string;
    itemDescription: string;
    subtotal: number;
    taxAmount: number;
    total: number;
  },
  htmlTemplate: string
): Promise {
  
  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox'], // for containerized environments
  });

  const page = await browser.newPage();

  // Render the HTML with the invoice data
  const html = htmlTemplate
    .replace('{{invoiceNumber}}', invoiceData.invoiceNumber)
    .replace('{{customerName}}', invoiceData.customerName)
    .replace('{{itemDescription}}', invoiceData.itemDescription)
    .replace('{{subtotal}}', invoiceData.subtotal.toFixed(2))
    .replace('{{taxAmount}}', invoiceData.taxAmount.toFixed(2))
    .replace('{{total}}', invoiceData.total.toFixed(2));

  await page.setContent(html, { waitUntil: 'networkidle0' });

  const pdfBuffer = await page.pdf({
    format: 'A4',
    margin: { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' },
    printBackground: true,
  });

  await browser.close();

  return pdfBuffer;
}

async function uploadPDFToStorage(
  pdfBuffer: Buffer,
  invoiceNumber: string,
  s3Client: any
): Promise {
  
  const key = `invoices/${invoiceNumber}.pdf`;
  
  await s3Client.putObject({
    Bucket: process.env.INVOICE_BUCKET,
    Key: key,
    Body: pdfBuffer,
    ContentType: 'application/pdf',
    ServerSideEncryption: 'AES256',
  }).promise();

  // Return a signed URL valid for 1 year
  const url = s3Client.getSignedUrl('getObject', {
    Bucket: process.env.INVOICE_BUCKET,
    Key: key,
    Expires: 365 * 24 * 60 * 60,
  });

  return url;
}

Store the S3 URL in your invoices table alongside the invoice number and charge ID. This creates a retrievable chain: customer disputes charge → look up charge in Stripe → find invoice record → fetch PDF from S3 → present evidence.

Retention: plan for at least 7 years if you're in the US, longer if you're in the EU or serving those regions. Set up a lifecycle policy on S3 or configure a job to archive old invoices to cheaper cold storage. Deleting invoices is risky; moving them to Glacier is sensible.

Send automatically after payment

Manual invoice sending doesn't scale and introduces human error. The best practice is to automate the entire flow: payment succeeds → webhook fires → invoice generated → PDF stored → email sent. All within seconds, with no intervention.

Hook into charge.succeeded webhooks from Stripe. This event includes the charge ID, customer email, and amount. From there, trigger your invoice generation pipeline.

typescript

async function handleChargeSucceeded(event: Stripe.Event, db: Database, mailer: Mailer): Promise {
  const charge = event.data.object as Stripe.Charge;

  // Fetch customer from your database
  const customer = await db('customers').where('stripe_customer_id', charge.customer).first();
  
  if (!customer || !customer.email) {
    console.error(`No customer found for charge ${charge.id}`);
    return;
  }

  // Generate invoice number
  const invoiceNumber = await generateInvoiceNumber(charge.id, customer.id, db);

  // Calculate tax (if applicable)
  const taxInfo = await calculateInvoiceTax(
    customer.id,
    [{ amount: charge.amount / 100, description: 'Service charge' }],
    { country: charge.billing_details?.address?.country || 'US' }
  );

  // Prepare invoice data
  const invoiceData = {
    invoiceNumber,
    customerName: customer.name,
    itemDescription: 'Service charge',
    subtotal: taxInfo.subtotal,
    taxAmount: taxInfo.taxAmount,
    total: taxInfo.total,
    stripeChargeId: charge.id,
    issuedAt: new Date(),
  };

  // Generate PDF
  const pdfBuffer = await generateInvoicePDF(invoiceData, invoiceHtmlTemplate);
  const pdfUrl = await uploadPDFToStorage(pdfBuffer, invoiceNumber, s3Client);

  // Store invoice record
  await db('invoices').insert({
    invoice_number: invoiceNumber,
    customer_id: customer.id,
    stripe_charge_id: charge.id,
    subtotal_cents: Math.round(taxInfo.subtotal * 100),
    tax_cents: Math.round(taxInfo.taxAmount * 100),
    total_cents: Math.round(taxInfo.total * 100),
    pdf_url: pdfUrl,
    status: 'issued',
    issued_at: new Date(),
  });

  // Send email
  await mailer.send({
    to: customer.email,
    subject: `Invoice ${invoiceNumber} from Acme Corp`,
    templateId: 'invoice_received',
    data: {
      invoiceNumber,
      total: taxInfo.total.toFixed(2),
      downloadUrl: pdfUrl,
    },
  });

  console.log(`Invoice ${invoiceNumber} generated, stored, and emailed to ${customer.email}`);
}

This webhook handler is idempotent. If it runs twice for the same charge (webhooks can retry), the second run will either update the existing invoice record or fail gracefully. Use database constraints (UNIQUE(stripe_charge_id)) to prevent duplicates.

Test this flow with Stripe's webhook testing tool. Send a fake charge.succeeded event and verify that your function generates the invoice, stores the PDF, and sends the email. Log everything. When something fails in production (and it will), clear logs are invaluable.

Timing: send the receipt within minutes, not hours. Customers expect quick confirmation. If your PDF generation or email service is slow, consider queuing the work on a background job processor and sending an acknowledgment immediately.

Common mistakes

  • Resetting invoice numbers annually: Creates ambiguity. Use a year prefix or use truly global sequences. "INV-001" from 2024 and "INV-001" from 2025 look identical without context.
  • Omitting tax breakdowns: Leads to customer confusion and audit friction. Always show the calculation.
  • Storing PDFs only in email: Email is transient. Cloud storage is permanent.
  • Sending invoices synchronously in the payment webhook: If your email service is down, the webhook fails and Stripe retries, creating duplicate invoices. Queue the work asynchronously.
  • Forgetting to validate customer email addresses: Bad email in Stripe means undelivered receipts. Validate during onboarding.
  • Using floats for currency math: Always use integers (cents) or a decimal library to avoid rounding errors.

Key takeaways

  • Use sequential invoice numbers with a clear prefix and atomic database operations to ensure no duplicates.
  • Include full tax breakdowns—rate, jurisdiction, and absolute amount—for audit compliance.
  • Store PDFs in durable cloud storage with a retrieval URL in your database for 7+ year retention.
  • Automate receipt delivery via webhook handlers that run asynchronously and idempotently.
  • Test the entire flow end-to-end with Stripe's webhook simulator before production.
  • Log everything: invoice generation, tax calculations, PDF uploads, and email sends. You'll need these logs during disputes.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles