feat(offers): generate Angebot/Offer PDFs for draft orders
This commit is contained in:
@@ -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, "_");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user