first version

This commit is contained in:
Gerhard Scheikl
2026-04-28 21:56:11 +02:00
parent 0f75dbaccb
commit 5b2aa5d62b
50 changed files with 5514 additions and 481 deletions
@@ -0,0 +1,201 @@
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 {
generateInvoice,
sanitiseForFilename,
toOrderGid,
uploadPdfToShopifyFiles,
writeOrderMetafields,
type GeneratedInvoice,
} from "./generateInvoice.server";
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
import { getLogoDataUrl } from "./logoCache.server";
import { InvoiceDocument } from "./pdf/InvoiceDocument";
export interface CancelAndReissueArgs {
shopDomain: string;
admin: AdminApiContext;
orderId: string;
}
export interface CancelAndReissueResult {
storno: {
invoiceId: string;
invoiceNumber: string;
pdfUrl: string;
};
newInvoice: GeneratedInvoice;
}
/**
* Cancels the latest sent invoice for an order by issuing a Stornorechnung
* (negative amounts, references the original number) and then issuing a
* brand-new invoice with a fresh number reflecting the corrected data.
*
* Both documents are uploaded to Shopify Files. The original invoice row is
* marked `cancelledAt = now()`, the storno is persisted as
* `Invoice { kind: 'storno', cancelsInvoiceId: <original.id> }`, and the
* order's metafields are updated to point at the new invoice (with the
* storno PDF URL written to a separate metafield).
*/
export async function cancelAndReissue(
args: CancelAndReissueArgs,
): Promise<CancelAndReissueResult> {
const { shopDomain, admin } = args;
const orderGid = toOrderGid(args.orderId);
const settings = await db.shopSettings.upsert({
where: { shopDomain },
update: {},
create: { shopDomain },
});
const original = await db.invoice.findFirst({
where: {
shopDomain,
orderId: orderGid,
kind: "invoice",
cancelledAt: null,
},
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
if (!original) {
throw new Error("No active invoice found for this order to cancel.");
}
const order = await loadOrderForInvoice(admin, orderGid);
// Storno number: same as original with a "-S" suffix (so it is visually
// tied to the cancelled invoice and never collides with the new number).
const stornoNumber = `${original.invoiceNumber}-S`;
const stornoView = composeInvoice({
order,
settings,
invoiceNumber: stornoNumber,
storno: { cancelsNumber: original.invoiceNumber },
});
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
if (logoDataUrl) stornoView.issuer.logoDataUrl = logoDataUrl;
const stornoBuffer = (await renderToBuffer(
<InvoiceDocument invoice={stornoView} />,
)) as Buffer;
const stornoUpload = await uploadPdfToShopifyFiles(admin, {
bytes: stornoBuffer,
filename: `Stornorechnung-${sanitiseForFilename(stornoNumber)}.pdf`,
alt: `Stornorechnung ${stornoNumber}`,
});
const stornoRow = await db.$transaction(async (tx) => {
const created = await tx.invoice.create({
data: {
shopDomain,
orderId: orderGid,
orderName: order.name,
orderNumber: order.orderNumber,
invoiceNumber: stornoNumber,
language: stornoView.language,
kind: "storno",
version: 1,
cancelsInvoiceId: original.id,
pdfFileGid: stornoUpload.fileGid,
pdfUrl: stornoUpload.url,
totalsJson: JSON.stringify(stornoView.totals),
customerJson: JSON.stringify({
recipient: stornoView.recipient,
isB2B: stornoView.isB2B,
recipientVatId: stornoView.recipientVatId,
}),
status: "issued",
},
});
await tx.invoice.update({
where: { id: original.id },
data: { cancelledAt: new Date(), status: "cancelled" },
});
return created;
});
// Best-effort: link the storno PDF + previous number on the order.
try {
await writeStornoSidecarMetafields(admin, orderGid, {
stornoUrl: stornoUpload.url,
previousNumber: original.invoiceNumber,
});
} catch (err) {
console.warn("Storno sidecar metafield write failed:", err);
}
// Now issue a brand-new invoice (fresh number from the configured mode).
const newInvoice = await generateInvoice({ shopDomain, admin, orderId: orderGid });
// The metafields written by generateInvoice already point at the new
// invoice's PDF URL/number/version. The storno sidecar metafields above
// remain referencing the storno PDF and the previous number.
return {
storno: {
invoiceId: stornoRow.id,
invoiceNumber: stornoRow.invoiceNumber,
pdfUrl: stornoUpload.url,
},
newInvoice,
};
}
const STORNO_METAFIELDS_MUTATION = `#graphql
mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id namespace key }
userErrors { field message }
}
}
`;
async function writeStornoSidecarMetafields(
admin: AdminApiContext,
orderGid: string,
data: { stornoUrl: string; previousNumber: string },
): Promise<void> {
const res = await admin.graphql(STORNO_METAFIELDS_MUTATION, {
variables: {
metafields: [
{
ownerId: orderGid,
namespace: "linumiq_invoice",
key: "storno_pdf_url",
type: "url",
value: data.stornoUrl,
},
{
ownerId: orderGid,
namespace: "linumiq_invoice",
key: "previous_number",
type: "single_line_text_field",
value: data.previousNumber,
},
],
},
});
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 (storno) failed: ${JSON.stringify(errs)}`);
}
// suppress unused-import warnings when the orchestrator path doesn't use this:
void writeOrderMetafields;
}