import React from "react"; import { renderToBuffer } from "@react-pdf/renderer"; import type { AdminApiContext } from "@shopify/shopify-app-react-router/server"; 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"; import { InvoiceDocument } from "./pdf/InvoiceDocument"; import type { InvoiceViewModel } from "./types"; export interface GenerateInvoiceArgs { shopDomain: string; admin: AdminApiContext; /** Either a numeric Shopify order id or a full GID. */ 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 { invoiceId: string; invoiceNumber: string; pdfUrl: string; pdfFileGid: string; version: number; reused: boolean; } /** * Top-level orchestrator. Loads order + settings, composes the view model, * renders the PDF, uploads to Shopify Files and persists an Invoice row. * * Idempotency rules: * - If a non-cancelled Invoice for the order exists and is unsent, it is * regenerated in place (new file, same number, version++). * - If the latest is `sent` and not cancelled, generation is refused (caller * must use the cancel-and-reissue flow). Future phase. */ export async function generateInvoice( args: GenerateInvoiceArgs, ): Promise { const { shopDomain, admin } = args; const kind = args.kind ?? "invoice"; const orderGid = kind === "offer" ? toDraftOrderGid(args.orderId) : toOrderGid(args.orderId); const settings = await db.shopSettings.upsert({ where: { shopDomain }, update: {}, create: { shopDomain }, }); const order = kind === "offer" ? await loadDraftOrderForOffer(admin, orderGid) : await loadOrderForInvoice(admin, orderGid); // Find latest existing document of this kind for this (draft) order. const latest = await db.invoice.findFirst({ where: { shopDomain, orderId: orderGid, kind, cancelledAt: null }, orderBy: [{ version: "desc" }, { createdAt: "desc" }], }); 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.`, ); } const invoiceNumber = latest ? latest.invoiceNumber : 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, offer: kind === "offer" }); // Logo (cached). const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl); if (logoDataUrl) viewModel.issuer.logoDataUrl = logoDataUrl; // Product images for each line (best-effort, parallel, in-process cache). await attachLineItemImages(viewModel.lines); // GiroCode (only for invoices that are unpaid + IBAN configured + enabled). if ( kind === "invoice" && settings.giroCodeEnabled && settings.iban && !viewModel.paid && viewModel.totals.gross > 0 ) { viewModel.giroCodePngDataUrl = await buildGiroCodeDataUrl({ beneficiaryName: [settings.companyName, settings.legalForm].filter(Boolean).join(" ") || "Beneficiary", iban: settings.iban, bic: settings.bic, amount: viewModel.totals.gross, currency: viewModel.currency, remittance: invoiceNumber, }); } const pdfBuffer = await renderInvoicePdf(viewModel); const filenamePrefix = kind === "offer" ? "Angebot" : "Rechnung"; const filename = `${filenamePrefix}-${sanitiseForFilename(invoiceNumber)}.pdf`; const upload = await uploadPdfToShopifyFiles(admin, { bytes: pdfBuffer, filename, alt: kind === "offer" ? `Offer ${invoiceNumber}` : `Invoice ${invoiceNumber}`, }); const version = latest ? latest.version + 1 : 1; const totalsJson = JSON.stringify(viewModel.totals); const customerJson = JSON.stringify({ recipient: viewModel.recipient, isB2B: viewModel.isB2B, recipientVatId: viewModel.recipientVatId, customerEmail: order.customer?.email, }); // Persist (upsert by latest row when regenerating in place). const invoice = latest ? await db.invoice.update({ where: { id: latest.id }, data: { version, pdfFileGid: upload.fileGid, pdfUrl: upload.url, totalsJson, customerJson, issuedAt: new Date(), status: "issued", lastError: "", }, }) : await db.invoice.create({ data: { shopDomain, orderId: orderGid, orderName: order.name, orderNumber: order.orderNumber, invoiceNumber, language: viewModel.language, kind, version: 1, pdfFileGid: upload.fileGid, pdfUrl: upload.url, totalsJson, customerJson, status: "issued", }, }); // Link the latest PDF on the order via metafields (best-effort; do not // 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 { invoiceId: invoice.id, invoiceNumber, pdfUrl: upload.url, pdfFileGid: upload.fileGid, version: invoice.version, reused: !!latest, }; } /** Convert legacy numeric ids to a full Shopify GID. */ export function toOrderGid(input: string): string { if (input.startsWith("gid://")) return input; 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, "_"); } export { sanitiseForFilename }; /** Renders the PDF as a Node Buffer. */ export async function renderInvoicePdf(view: InvoiceViewModel): Promise { // @react-pdf returns a Node Buffer in node environments. return renderToBuffer() as Promise; } /* ------------------------------------------------------------------ */ /* Shopify Files upload */ /* ------------------------------------------------------------------ */ interface UploadResult { fileGid: string; url: string; } const STAGED_UPLOAD_MUTATION = `#graphql mutation stagedUploads($input: [StagedUploadInput!]!) { stagedUploadsCreate(input: $input) { stagedTargets { url resourceUrl parameters { name value } } userErrors { field message } } } `; const FILE_CREATE_MUTATION = `#graphql mutation fileCreate($files: [FileCreateInput!]!) { fileCreate(files: $files) { files { id alt createdAt ... on GenericFile { url } } userErrors { field message } } } `; const FILE_QUERY = `#graphql query File($id: ID!) { node(id: $id) { ... on GenericFile { id url } } } `; interface StagedTarget { url: string; resourceUrl: string; parameters: { name: string; value: string }[]; } interface UploadInput { bytes: Buffer; filename: string; alt: string; } export async function uploadPdfToShopifyFiles( admin: AdminApiContext, input: UploadInput, ): Promise { const stagedRes = await admin.graphql(STAGED_UPLOAD_MUTATION, { variables: { input: [ { filename: input.filename, mimeType: "application/pdf", httpMethod: "POST", resource: "FILE", fileSize: input.bytes.length.toString(), }, ], }, }); const stagedJson = (await stagedRes.json()) as { data?: { stagedUploadsCreate?: { stagedTargets?: StagedTarget[]; userErrors?: { field: string[]; message: string }[]; }; }; }; const stagedErr = stagedJson.data?.stagedUploadsCreate?.userErrors ?? []; if (stagedErr.length > 0) { throw new Error(`stagedUploadsCreate failed: ${JSON.stringify(stagedErr)}`); } const target = stagedJson.data?.stagedUploadsCreate?.stagedTargets?.[0]; if (!target) throw new Error("stagedUploadsCreate returned no target"); // POST the bytes to the staged URL (multipart form with the parameters). const form = new FormData(); for (const p of target.parameters) form.append(p.name, p.value); form.append( "file", new Blob([new Uint8Array(input.bytes)], { type: "application/pdf" }), input.filename, ); const putRes = await fetch(target.url, { method: "POST", body: form }); if (!putRes.ok) { const text = await putRes.text().catch(() => ""); throw new Error(`Staged upload POST failed (${putRes.status}): ${text}`); } // Register the file with Shopify. const fileRes = await admin.graphql(FILE_CREATE_MUTATION, { variables: { files: [ { alt: input.alt, contentType: "FILE", originalSource: target.resourceUrl, }, ], }, }); const fileJson = (await fileRes.json()) as { data?: { fileCreate?: { files?: { id: string; url?: string | null }[]; userErrors?: { field: string[]; message: string }[]; }; }; }; const fileErr = fileJson.data?.fileCreate?.userErrors ?? []; if (fileErr.length > 0) { throw new Error(`fileCreate failed: ${JSON.stringify(fileErr)}`); } const file = fileJson.data?.fileCreate?.files?.[0]; if (!file) throw new Error("fileCreate returned no file"); // The CDN url is populated asynchronously after Shopify ingests the file. // Poll a few times for it to appear. let url = file.url ?? ""; if (!url) { for (let i = 0; i < 8 && !url; i++) { await sleep(500); const q = await admin.graphql(FILE_QUERY, { variables: { id: file.id } }); const qj = (await q.json()) as { data?: { node?: { url?: string | null } | null }; }; url = qj.data?.node?.url ?? ""; } } return { fileGid: file.id, url }; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /* ------------------------------------------------------------------ */ /* Order metafield linkage */ /* ------------------------------------------------------------------ */ const METAFIELDS_SET_MUTATION = `#graphql mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { metafields { id namespace key } userErrors { field message } } } `; export async function writeOrderMetafields( admin: AdminApiContext, orderGid: string, data: { pdfUrl: string; number: string; version: number }, ): Promise { const res = await admin.graphql(METAFIELDS_SET_MUTATION, { variables: { metafields: [ { ownerId: orderGid, namespace: "linumiq_invoice", key: "pdf_url", type: "url", value: data.pdfUrl, }, { ownerId: orderGid, namespace: "linumiq_invoice", key: "number", type: "single_line_text_field", value: data.number, }, { ownerId: orderGid, namespace: "linumiq_invoice", key: "version", type: "number_integer", value: data.version.toString(), }, ], }, }); const json = (await res.json()) as { data?: { metafieldsSet?: { userErrors?: { field: string[]; message: string }[]; }; }; }; const errs = json.data?.metafieldsSet?.userErrors ?? []; if (errs.length > 0) { throw new Error(`metafieldsSet failed: ${JSON.stringify(errs)}`); } }