Files
linumiq-invoice/app/services/invoice/generateInvoice.server.tsx
T

406 lines
12 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 { 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;
}
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 orderGid = toOrderGid(args.orderId);
const settings = await db.shopSettings.upsert({
where: { shopDomain },
update: {},
create: { shopDomain },
});
const order = await loadOrderForInvoice(admin, orderGid);
// Find latest existing invoice (excluding storno) for this order.
const latest = await db.invoice.findFirst({
where: { shopDomain, orderId: orderGid, kind: "invoice", cancelledAt: null },
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
if (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
: await allocateInvoiceNumber(settings, order.orderNumber);
// Compose view model and render PDF.
const viewModel = composeInvoice({ order, settings, invoiceNumber });
// 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 unpaid + IBAN configured + enabled).
if (
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 filename = `Rechnung-${sanitiseForFilename(invoiceNumber)}.pdf`;
const upload = await uploadPdfToShopifyFiles(admin, {
bytes: pdfBuffer,
filename,
alt: `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: "invoice",
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).
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}`;
}
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)}`);
}
}