202 lines
5.9 KiB
TypeScript
202 lines
5.9 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 {
|
|
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;
|
|
}
|