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: }`, 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 { 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( , )) 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 { 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; }