first version
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user