first version
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
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 { 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;
|
||||
|
||||
// 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 || "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)}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user