feat(offers): generate Angebot/Offer PDFs for draft orders

This commit is contained in:
Gerhard Scheikl
2026-05-09 19:26:33 +02:00
parent 1ec4faaac5
commit 6224597497
8 changed files with 465 additions and 41 deletions
+46 -20
View File
@@ -6,6 +6,7 @@ import db from "../../db.server";
import { composeInvoice } from "./composeInvoice";
import { buildGiroCodeDataUrl } from "./girocode";
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
import { loadDraftOrderForOffer } from "./loadDraftOrderForOffer.server";
import { getLogoDataUrl } from "./logoCache.server";
import { attachLineItemImages } from "./productImageCache.server";
import { allocateInvoiceNumber } from "./numbering.server";
@@ -19,6 +20,15 @@ export interface GenerateInvoiceArgs {
orderId: string;
/** When true, bypass the "sent invoice is locked" rule and regenerate in place. */
forceRegenerate?: boolean;
/**
* Document kind. Default "invoice". When "offer":
* - `orderId` is interpreted as a DraftOrder id (numeric or GID).
* - The number is the draft order's name (e.g. "D1") rather than an
* allocated invoice number.
* - GiroCode is suppressed and the dueDate is repurposed as the offer's
* validity expiry.
*/
kind?: "invoice" | "offer";
}
export interface GeneratedInvoice {
@@ -44,7 +54,8 @@ export async function generateInvoice(
args: GenerateInvoiceArgs,
): Promise<GeneratedInvoice> {
const { shopDomain, admin } = args;
const orderGid = toOrderGid(args.orderId);
const kind = args.kind ?? "invoice";
const orderGid = kind === "offer" ? toDraftOrderGid(args.orderId) : toOrderGid(args.orderId);
const settings = await db.shopSettings.upsert({
where: { shopDomain },
@@ -52,15 +63,17 @@ export async function generateInvoice(
create: { shopDomain },
});
const order = await loadOrderForInvoice(admin, orderGid);
const order = kind === "offer"
? await loadDraftOrderForOffer(admin, orderGid)
: await loadOrderForInvoice(admin, orderGid);
// Find latest existing invoice (excluding storno) for this order.
// Find latest existing document of this kind for this (draft) order.
const latest = await db.invoice.findFirst({
where: { shopDomain, orderId: orderGid, kind: "invoice", cancelledAt: null },
where: { shopDomain, orderId: orderGid, kind, cancelledAt: null },
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
if (latest && latest.sentAt && !args.forceRegenerate) {
if (kind === "invoice" && latest && latest.sentAt && !args.forceRegenerate) {
throw new Error(
`Invoice ${latest.invoiceNumber} has already been sent. Use cancel-and-reissue to correct it.`,
);
@@ -68,10 +81,12 @@ export async function generateInvoice(
const invoiceNumber = latest
? latest.invoiceNumber
: await allocateInvoiceNumber(settings, order.orderNumber);
: kind === "offer"
? order.name // e.g. "D1" — Shopify's draft order name is the offer number.
: await allocateInvoiceNumber(settings, order.orderNumber);
// Compose view model and render PDF.
const viewModel = composeInvoice({ order, settings, invoiceNumber });
const viewModel = composeInvoice({ order, settings, invoiceNumber, offer: kind === "offer" });
// Logo (cached).
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
@@ -80,8 +95,9 @@ export async function generateInvoice(
// Product images for each line (best-effort, parallel, in-process cache).
await attachLineItemImages(viewModel.lines);
// GiroCode (only for unpaid + IBAN configured + enabled).
// GiroCode (only for invoices that are unpaid + IBAN configured + enabled).
if (
kind === "invoice" &&
settings.giroCodeEnabled &&
settings.iban &&
!viewModel.paid &&
@@ -101,12 +117,13 @@ export async function generateInvoice(
const pdfBuffer = await renderInvoicePdf(viewModel);
const filename = `Rechnung-${sanitiseForFilename(invoiceNumber)}.pdf`;
const filenamePrefix = kind === "offer" ? "Angebot" : "Rechnung";
const filename = `${filenamePrefix}-${sanitiseForFilename(invoiceNumber)}.pdf`;
const upload = await uploadPdfToShopifyFiles(admin, {
bytes: pdfBuffer,
filename,
alt: `Invoice ${invoiceNumber}`,
alt: kind === "offer" ? `Offer ${invoiceNumber}` : `Invoice ${invoiceNumber}`,
});
const version = latest ? latest.version + 1 : 1;
@@ -141,7 +158,7 @@ export async function generateInvoice(
orderNumber: order.orderNumber,
invoiceNumber,
language: viewModel.language,
kind: "invoice",
kind,
version: 1,
pdfFileGid: upload.fileGid,
pdfUrl: upload.url,
@@ -152,15 +169,18 @@ export async function generateInvoice(
});
// Link the latest PDF on the order via metafields (best-effort; do not
// fail the whole operation if scopes are missing).
try {
await writeOrderMetafields(admin, orderGid, {
pdfUrl: upload.url,
number: invoiceNumber,
version: invoice.version,
});
} catch (err) {
console.warn("Order metafield write failed:", err);
// fail the whole operation if scopes are missing). Skip for offers since
// draft orders don't accept the same metafields.
if (kind === "invoice") {
try {
await writeOrderMetafields(admin, orderGid, {
pdfUrl: upload.url,
number: invoiceNumber,
version: invoice.version,
});
} catch (err) {
console.warn("Order metafield write failed:", err);
}
}
return {
@@ -179,6 +199,12 @@ export function toOrderGid(input: string): string {
return `gid://shopify/Order/${input}`;
}
/** Same idea for DraftOrder ids. */
export function toDraftOrderGid(input: string): string {
if (input.startsWith("gid://")) return input;
return `gid://shopify/DraftOrder/${input}`;
}
function sanitiseForFilename(s: string): string {
return s.replace(/[^A-Za-z0-9._-]/g, "_");
}