432 lines
13 KiB
TypeScript
432 lines
13 KiB
TypeScript
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<GeneratedInvoice> {
|
|
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<Buffer> {
|
|
// @react-pdf returns a Node Buffer in node environments.
|
|
return renderToBuffer(<InvoiceDocument invoice={view} />) as Promise<Buffer>;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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<UploadResult> {
|
|
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<void> {
|
|
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<void> {
|
|
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)}`);
|
|
}
|
|
}
|