apps/recallassess/recallassess-api/src/api/shared/invoice/invoice-pdf.service.ts
Single source of truth for generating / serving invoice PDFs (admin + portal).
S3 behaviour (aligned with pre/post-bat report PDFs):
private/invoices/{invoice_number}_..pdf by LastModified, verify the bytes
look like a real invoice PDF; if none or corrupt → SSR render → upload new key → serve.
Properties |
Methods |
|
constructor(prisma: BNestPrismaService, config: ConfigService, reportService: BNestReportService)
|
||||||||||||
|
Parameters :
|
| Private Async bodyToBuffer | ||||||
bodyToBuffer(body: unknown)
|
||||||
|
Parameters :
Returns :
Promise<Buffer | null>
|
| Private buildNewInvoiceS3Key | ||||||
buildNewInvoiceS3Key(invoiceNumber: string)
|
||||||
|
Parameters :
Returns :
string
|
| Private Async findLatestPlausibleInvoicePdf |
findLatestPlausibleInvoicePdf(bucket: string, invoiceNumber: string)
|
|
List
Returns :
Promise<literal type | null>
|
| Async generateSignedUrl | ||||||||||||||||
generateSignedUrl(id: number, expiresInSeconds: number, authorization?: string)
|
||||||||||||||||
|
Parameters :
Returns :
Promise<string>
|
| Async generateSignedUrlForCompany | ||||||||||||
generateSignedUrlForCompany(companyId: number, invoiceNumber: string, authorization?: string)
|
||||||||||||
|
Parameters :
Returns :
Promise<string>
|
| Private getBucketName |
getBucketName()
|
|
Returns :
string
|
| Private getFilename | ||||||
getFilename(invoice: InvoicePdfMeta)
|
||||||
|
Parameters :
Returns :
string
|
| Private Async getObjectBuffer |
getObjectBuffer(bucket: string, key: string)
|
|
Returns :
Promise<Buffer | null>
|
| Async getPdfBuffer |
getPdfBuffer(id: number, authorization?: string)
|
|
Returns :
Promise<literal type>
|
| Async getPdfBufferForCompany | ||||||||||||
getPdfBufferForCompany(companyId: number, invoiceNumber: string, authorization?: string)
|
||||||||||||
|
Parameters :
Returns :
Promise<literal type>
|
| Private invoiceS3ListPrefix | ||||||
invoiceS3ListPrefix(invoiceNumber: string)
|
||||||
|
Same prefix family as legacy keys (
Parameters :
Returns :
string
|
| Private isPlausibleInvoicePdfBuffer | ||||||
isPlausibleInvoicePdfBuffer(buffer: Buffer | null | undefined)
|
||||||
|
Parameters :
Returns :
boolean
|
| Private Async loadInvoiceMeta | ||||||
loadInvoiceMeta(id: number)
|
||||||
|
Parameters :
Returns :
Promise<InvoicePdfMeta>
|
| Private logPdfBufferStats | |||||||||
logPdfBufferStats(stage: string, buffer: Buffer)
|
|||||||||
|
Parameters :
Returns :
void
|
| Private Async putInvoicePdf |
putInvoicePdf(bucket: string, key: string, buffer: Buffer)
|
|
Returns :
Promise<void>
|
| Private Async renderInvoicePdfBuffer |
renderInvoicePdfBuffer(invoiceId: number, authorization?: string)
|
|
Returns :
Promise<Buffer>
|
| scheduleWarmInvoicePdf | ||||||
scheduleWarmInvoicePdf(invoiceId: number)
|
||||||
|
Non-blocking warm (mirrors “generate after CR feedback” without slowing webhooks).
Parameters :
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.
Returns :
Promise<string | null>
|
| 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);
}
}