File

apps/recallassess/recallassess-api/src/api/shared/invoice/invoice-pdf.service.ts

Description

Single source of truth for generating / serving invoice PDFs (admin + portal).

S3 behaviour (aligned with pre/post-bat report PDFs):

  • Objects live under prefix private/invoices/{invoice_number}_.
  • On download / signed URL: list that prefix, pick newest .pdf by LastModified, verify the bytes look like a real invoice PDF; if none or corrupt → SSR render → upload new key → serve.
  • After successful payment, scheduleWarmInvoicePdf can pre-upload so the first click is fast.

Index

Properties
  • Private Readonly logger
  • Private Readonly s3
Methods

Constructor

constructor(prisma: BNestPrismaService, config: ConfigService, reportService: BNestReportService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
config ConfigService No
reportService BNestReportService No

Methods

Private Async bodyToBuffer
bodyToBuffer(body: unknown)
Parameters :
Name Type Optional
body unknown No
Returns : Promise<Buffer | null>
Private buildNewInvoiceS3Key
buildNewInvoiceS3Key(invoiceNumber: string)
Parameters :
Name Type Optional
invoiceNumber string No
Returns : string
Private Async findLatestPlausibleInvoicePdf
findLatestPlausibleInvoicePdf(bucket: string, invoiceNumber: string)

List private/invoices/{number}_*.pdf, newest first; return first object whose bytes pass isPlausibleInvoicePdfBuffer.

Parameters :
Name Type Optional
bucket string No
invoiceNumber string No
Returns : Promise<literal type | null>
Async generateSignedUrl
generateSignedUrl(id: number, expiresInSeconds: number, authorization?: string)
Parameters :
Name Type Optional Default value
id number No
expiresInSeconds number No 3600
authorization string Yes
Returns : Promise<string>
Async generateSignedUrlForCompany
generateSignedUrlForCompany(companyId: number, invoiceNumber: string, authorization?: string)
Parameters :
Name Type Optional
companyId number No
invoiceNumber string No
authorization string Yes
Returns : Promise<string>
Private getBucketName
getBucketName()
Returns : string
Private getFilename
getFilename(invoice: InvoicePdfMeta)
Parameters :
Name Type Optional
invoice InvoicePdfMeta No
Returns : string
Private Async getObjectBuffer
getObjectBuffer(bucket: string, key: string)
Parameters :
Name Type Optional
bucket string No
key string No
Returns : Promise<Buffer | null>
Async getPdfBuffer
getPdfBuffer(id: number, authorization?: string)
Parameters :
Name Type Optional
id number No
authorization string Yes
Returns : Promise<literal type>
Async getPdfBufferForCompany
getPdfBufferForCompany(companyId: number, invoiceNumber: string, authorization?: string)
Parameters :
Name Type Optional
companyId number No
invoiceNumber string No
authorization string Yes
Returns : Promise<literal type>
Private invoiceS3ListPrefix
invoiceS3ListPrefix(invoiceNumber: string)

Same prefix family as legacy keys (…/INV_r3_v….pdf) so listing still finds older rows until replaced.

Parameters :
Name Type Optional
invoiceNumber string No
Returns : string
Private isPlausibleInvoicePdfBuffer
isPlausibleInvoicePdfBuffer(buffer: Buffer | null | undefined)
Parameters :
Name Type Optional
buffer Buffer | null | undefined No
Returns : boolean
Private Async loadInvoiceMeta
loadInvoiceMeta(id: number)
Parameters :
Name Type Optional
id number No
Private logPdfBufferStats
logPdfBufferStats(stage: string, buffer: Buffer)
Parameters :
Name Type Optional
stage string No
buffer Buffer No
Returns : void
Private Async putInvoicePdf
putInvoicePdf(bucket: string, key: string, buffer: Buffer)
Parameters :
Name Type Optional
bucket string No
key string No
buffer Buffer No
Returns : Promise<void>
Private Async renderInvoicePdfBuffer
renderInvoicePdfBuffer(invoiceId: number, authorization?: string)
Parameters :
Name Type Optional
invoiceId number No
authorization string Yes
Returns : Promise<Buffer>
scheduleWarmInvoicePdf
scheduleWarmInvoicePdf(invoiceId: number)

Non-blocking warm (mirrors “generate after CR feedback” without slowing webhooks).

Parameters :
Name Type Optional
invoiceId number No
Returns : void
Async warmInvoicePdfToS3
warmInvoicePdfToS3(invoiceId: number, authorization?: string)

Pre-generate and upload invoice PDF (e.g. after Stripe marks PAID). Idempotent if a plausible file already exists.

Parameters :
Name Type Optional
invoiceId number No
authorization string Yes
Returns : Promise<string | null>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(InvoicePdfService.name)
Private Readonly s3
Type : S3Client | null
import {
  GetObjectCommand,
  ListObjectsV2Command,
  PutObjectCommand,
  S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { BNestReportService } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";

function isS3NotFoundError(err: unknown): boolean {
  if (typeof err !== "object" || err === null) {
    return false;
  }
  const name = (err as { name?: string }).name;
  const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
  return name === "NotFound" || name === "NoSuchKey" || status === 404;
}

type InvoicePdfMeta = {
  invoice_number: string;
  status: string;
  updated_at: Date | null;
  created_at: Date;
};

/**
 * Bump when the PDF bytes layout changes (same idea as report revisions).
 * S3 objects are also selected by **LastModified** + plausibility, not only this token.
 */
const INVOICE_PDF_RENDERER_VERSION = 6;

/**
 * Single source of truth for generating / serving invoice PDFs (admin + portal).
 *
 * **S3 behaviour (aligned with pre/post-bat report PDFs):**
 * - Objects live under prefix `private/invoices/{invoice_number}_`.
 * - On download / signed URL: list that prefix, pick **newest** `.pdf` by `LastModified`, verify the bytes
 *   look like a real invoice PDF; if none or corrupt → **SSR render → upload new key** → serve.
 * - After successful payment, {@link scheduleWarmInvoicePdf} can pre-upload so the first click is fast.
 */
@Injectable()
export class InvoicePdfService {
  private readonly logger = new Logger(InvoicePdfService.name);
  private readonly s3: S3Client | null;

  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly config: ConfigService,
    private readonly reportService: BNestReportService,
  ) {
    const awsRegion = this.config.get("AWS_REGION");
    const awsAccessKeyId = this.config.get("AWS_ACCESS_KEY_ID");
    const awsSecretAccessKey = this.config.get("AWS_SECRET_ACCESS_KEY");
    if (awsRegion) {
      this.s3 = new S3Client({
        region: awsRegion,
        ...(awsAccessKeyId && awsSecretAccessKey
          ? {
              credentials: {
                accessKeyId: awsAccessKeyId,
                secretAccessKey: awsSecretAccessKey,
              },
            }
          : {}),
      });
    } else {
      this.s3 = null;
      this.logger.warn("AWS_REGION not configured, invoice PDFs will be generated in-memory only");
    }
  }

  private getBucketName(): string {
    const bucket = this.config.get("AWS_S3_MEDIA_BUCKET");
    if (!bucket) {
      throw new Error("AWS_S3_MEDIA_BUCKET environment variable is required for invoice PDF upload");
    }
    return bucket;
  }

  /** Same prefix family as legacy keys (`…/INV_r3_v….pdf`) so listing still finds older rows until replaced. */
  private invoiceS3ListPrefix(invoiceNumber: string): string {
    return `private/invoices/${invoiceNumber}_`;
  }

  private buildNewInvoiceS3Key(invoiceNumber: string): string {
    return `${this.invoiceS3ListPrefix(invoiceNumber)}r${INVOICE_PDF_RENDERER_VERSION}_${Date.now()}.pdf`;
  }

  private getFilename(invoice: InvoicePdfMeta): string {
    return `${invoice.invoice_number}.pdf`;
  }

  private async loadInvoiceMeta(id: number): Promise<InvoicePdfMeta> {
    const invoice = await this.prisma.client.invoice.findUnique({
      where: { id },
      select: {
        invoice_number: true,
        status: true,
        updated_at: true,
        created_at: true,
      },
    });
    if (!invoice) {
      throw new NotFoundException(`Invoice with ID ${id} not found`);
    }
    return invoice;
  }

  private async bodyToBuffer(body: unknown): Promise<Buffer | null> {
    if (!body) {
      return null;
    }
    const b = body as { transformToByteArray?: () => Promise<Uint8Array> };
    if (typeof b.transformToByteArray === "function") {
      const bytes = await b.transformToByteArray();
      return Buffer.from(bytes);
    }
    return null;
  }

  private isPlausibleInvoicePdfBuffer(buffer: Buffer | null | undefined): boolean {
    if (!buffer || buffer.length < 2200) {
      return false;
    }
    if (!buffer.subarray(0, 5).toString("latin1").startsWith("%PDF")) {
      return false;
    }
    // Fluid invoice template includes this literal (not true for empty-shell / error PDFs).
    return buffer.includes(Buffer.from("RECALL"));
  }

  private logPdfBufferStats(stage: string, buffer: Buffer): void {
    const len = buffer?.length ?? 0;
    const magic = len >= 5 ? buffer.subarray(0, 5).toString("latin1") : "";
    const okMagic = magic.startsWith("%PDF");
    const hasRecall = buffer.includes(Buffer.from("RECALL"));
    this.logger.log(`Invoice PDF ${stage}: bytes=${len} magicOk=${okMagic} hasRecallText=${hasRecall}`);
    if (!okMagic) {
      this.logger.error(`Invoice PDF ${stage}: missing %PDF header — not a valid PDF buffer`);
    }
    if (!hasRecall) {
      this.logger.warn(`Invoice PDF ${stage}: missing embedded RECALL marker — treating as bad for cache hits`);
    }
    if (len < 2200) {
      this.logger.warn(
        `Invoice PDF ${stage}: very small (${len} bytes) — likely blank; set ANGULAR_SSR_HTML_DEBUG=1 or ANGULAR_SSR_HTML_LOG_PATH to inspect SSR HTML`,
      );
    }
  }

  private async getObjectBuffer(bucket: string, key: string): Promise<Buffer | null> {
    if (!this.s3) {
      return null;
    }
    try {
      const obj = await this.s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
      return await this.bodyToBuffer(obj.Body);
    } catch (e: unknown) {
      if (isS3NotFoundError(e)) {
        return null;
      }
      throw e;
    }
  }

  /**
   * List `private/invoices/{number}_*.pdf`, newest first; return first object whose bytes pass {@link isPlausibleInvoicePdfBuffer}.
   */
  private async findLatestPlausibleInvoicePdf(
    bucket: string,
    invoiceNumber: string,
  ): Promise<{ key: string; buffer: Buffer } | null> {
    if (!this.s3) {
      return null;
    }
    const prefix = this.invoiceS3ListPrefix(invoiceNumber);
    const keys: { key: string; lastModified: number }[] = [];
    let continuationToken: string | undefined;

    do {
      const out = await this.s3.send(
        new ListObjectsV2Command({
          Bucket: bucket,
          Prefix: prefix,
          ContinuationToken: continuationToken,
        }),
      );
      for (const o of out.Contents ?? []) {
        const k = o.Key;
        if (!k || !k.toLowerCase().endsWith(".pdf")) {
          continue;
        }
        keys.push({ key: k, lastModified: o.LastModified?.getTime() ?? 0 });
      }
      continuationToken = out.IsTruncated ? out.NextContinuationToken : undefined;
    } while (continuationToken);

    keys.sort((a, b) => b.lastModified - a.lastModified);

    for (const { key } of keys) {
      const buf = await this.getObjectBuffer(bucket, key);
      if (buf && this.isPlausibleInvoicePdfBuffer(buf)) {
        this.logger.log(`Invoice PDF plausible cache hit: ${key}`);
        return { key, buffer: buf };
      }
      if (buf) {
        this.logPdfBufferStats(`s3-reject-${key}`, buf);
        this.logger.warn(`Invoice PDF S3 object implausible, skipping: ${key}`);
      }
    }

    this.logger.log(`No plausible invoice PDF in S3 under prefix=${prefix} (count=${keys.length})`);
    return null;
  }

  private async putInvoicePdf(bucket: string, key: string, buffer: Buffer): Promise<void> {
    if (!this.s3) {
      return;
    }
    await this.s3.send(
      new PutObjectCommand({
        Bucket: bucket,
        Key: key,
        Body: buffer,
        ContentType: "application/pdf",
        ContentDisposition: "attachment",
      }),
    );
    this.logger.log(`Uploaded invoice PDF to S3: ${key}`);
  }

  private async renderInvoicePdfBuffer(invoiceId: number, authorization?: string): Promise<Buffer> {
    this.logger.log(
      `Rendering invoice PDF (SSR): invoiceId=${invoiceId} auth=${authorization ? "Bearer/API-key present" : "none (SSR env key only)"}`,
    );
    const buffer = await this.reportService.generateReportPdf(
      "invoice",
      "fluid",
      {
        invoice_id: String(invoiceId),
      },
      authorization,
    );
    this.logPdfBufferStats("fresh-render", buffer);
    if (!this.isPlausibleInvoicePdfBuffer(buffer)) {
      this.logger.error(
        `Invoice PDF render failed plausibility check for invoiceId=${invoiceId} — admin SSR bundle or invoice data may be wrong`,
      );
    }
    return buffer;
  }

  /**
   * Pre-generate and upload invoice PDF (e.g. after Stripe marks PAID). Idempotent if a plausible file already exists.
   */
  async warmInvoicePdfToS3(invoiceId: number, authorization?: string): Promise<string | null> {
    const invoice = await this.loadInvoiceMeta(invoiceId);
    if (!this.s3) {
      return null;
    }
    const bucket = this.getBucketName();
    const hit = await this.findLatestPlausibleInvoicePdf(bucket, invoice.invoice_number);
    if (hit) {
      return hit.key;
    }
    const pdfBuffer = await this.renderInvoicePdfBuffer(invoiceId, authorization);
    if (!this.isPlausibleInvoicePdfBuffer(pdfBuffer)) {
      throw new Error(`Invoice ${invoiceId}: rendered PDF failed plausibility check; not uploading to S3`);
    }
    const key = this.buildNewInvoiceS3Key(invoice.invoice_number);
    await this.putInvoicePdf(bucket, key, pdfBuffer);
    return key;
  }

  /** Non-blocking warm (mirrors “generate after CR feedback” without slowing webhooks). */
  scheduleWarmInvoicePdf(invoiceId: number): void {
    setImmediate(() => {
      this.warmInvoicePdfToS3(invoiceId).catch((e: unknown) => {
        this.logger.warn(
          `Invoice PDF warm failed invoiceId=${invoiceId}: ${e instanceof Error ? e.message : String(e)}`,
        );
      });
    });
  }

  async generateSignedUrl(id: number, expiresInSeconds = 3600, authorization?: string): Promise<string> {
    const invoice = await this.loadInvoiceMeta(id);
    if (!this.s3) {
      throw new Error("S3 is not configured. Set AWS_REGION (and credentials) to enable invoice PDF signed URLs.");
    }
    const bucket = this.getBucketName();

    let key: string | null = null;
    const hit = await this.findLatestPlausibleInvoicePdf(bucket, invoice.invoice_number);
    if (hit) {
      key = hit.key;
    } else {
      const pdfBuffer = await this.renderInvoicePdfBuffer(id, authorization);
      key = this.buildNewInvoiceS3Key(invoice.invoice_number);
      await this.putInvoicePdf(bucket, key, pdfBuffer);
    }

    return await getSignedUrl(this.s3, new GetObjectCommand({ Bucket: bucket, Key: key }), {
      expiresIn: expiresInSeconds,
    });
  }

  async getPdfBuffer(id: number, authorization?: string): Promise<{ buffer: Buffer; filename: string }> {
    const invoice = await this.loadInvoiceMeta(id);
    const filename = this.getFilename(invoice);

    if (!this.s3) {
      const buffer = await this.renderInvoicePdfBuffer(id, authorization);
      return { buffer, filename };
    }

    const bucket = this.getBucketName();
    const hit = await this.findLatestPlausibleInvoicePdf(bucket, invoice.invoice_number);
    if (hit) {
      this.logPdfBufferStats(`s3-latest-${hit.key}`, hit.buffer);
      return { buffer: hit.buffer, filename };
    }

    const pdfBuffer = await this.renderInvoicePdfBuffer(id, authorization);
    if (this.isPlausibleInvoicePdfBuffer(pdfBuffer)) {
      await this.putInvoicePdf(bucket, this.buildNewInvoiceS3Key(invoice.invoice_number), pdfBuffer);
    } else {
      this.logger.warn(`Invoice ${id}: skipping S3 upload — rendered PDF did not pass plausibility check`);
    }

    return { buffer: pdfBuffer, filename };
  }

  async generateSignedUrlForCompany(
    companyId: number,
    invoiceNumber: string,
    authorization?: string,
  ): Promise<string> {
    const invoice = await this.prisma.client.invoice.findFirst({
      where: { invoice_number: invoiceNumber, company_id: companyId },
      select: { id: true },
    });
    if (!invoice) {
      throw new NotFoundException("Invoice not found.");
    }
    return this.generateSignedUrl(invoice.id, 3600, authorization);
  }

  async getPdfBufferForCompany(
    companyId: number,
    invoiceNumber: string,
    authorization?: string,
  ): Promise<{ buffer: Buffer; filename: string }> {
    const invoice = await this.prisma.client.invoice.findFirst({
      where: { invoice_number: invoiceNumber, company_id: companyId },
      select: { id: true },
    });
    if (!invoice) {
      throw new NotFoundException("Invoice not found.");
    }
    return this.getPdfBuffer(invoice.id, authorization);
  }
}

results matching ""

    No results matching ""