feat(offers): generate Angebot/Offer PDFs for draft orders
This commit is contained in:
@@ -28,6 +28,14 @@ interface ComposeArgs {
|
||||
storno?: { cancelsNumber: string };
|
||||
/** Optional override for invoice/delivery date (defaults to order date). */
|
||||
issueDate?: Date;
|
||||
/**
|
||||
* When true, render as an Angebot/Offer instead of an invoice:
|
||||
* - `kind = "offer"`
|
||||
* - no payment-due date (the dueDate field is repurposed by the renderer
|
||||
* as the offer's validity expiry).
|
||||
* - GiroCode and payment-terms text are suppressed.
|
||||
*/
|
||||
offer?: boolean;
|
||||
}
|
||||
|
||||
export function composeInvoice({
|
||||
@@ -37,6 +45,7 @@ export function composeInvoice({
|
||||
forceLanguage,
|
||||
storno,
|
||||
issueDate,
|
||||
offer,
|
||||
}: ComposeArgs): InvoiceViewModel {
|
||||
const language = forceLanguage
|
||||
?? pickLanguage(order.customer?.locale ?? settings.defaultLanguage);
|
||||
@@ -51,9 +60,13 @@ export function composeInvoice({
|
||||
|
||||
const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt);
|
||||
const deliveryDate = invoiceDate;
|
||||
const dueDate = !storno && settings.paymentTermDays > 0
|
||||
? addDays(invoiceDate, settings.paymentTermDays)
|
||||
: undefined;
|
||||
// For offers we treat `dueDate` as the offer's validity expiry (default 30
|
||||
// days from issue). The PDF renderer renders a different label.
|
||||
const dueDate = offer
|
||||
? addDays(invoiceDate, 30)
|
||||
: !storno && settings.paymentTermDays > 0
|
||||
? addDays(invoiceDate, settings.paymentTermDays)
|
||||
: undefined;
|
||||
|
||||
const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID";
|
||||
|
||||
@@ -80,7 +93,7 @@ export function composeInvoice({
|
||||
return {
|
||||
language,
|
||||
currency: order.currencyCode,
|
||||
kind: storno ? "storno" : "invoice",
|
||||
kind: storno ? "storno" : offer ? "offer" : "invoice",
|
||||
number: invoiceNumber,
|
||||
cancelsNumber: storno?.cancelsNumber,
|
||||
invoiceDate,
|
||||
|
||||
@@ -6,6 +6,7 @@ import db from "../../db.server";
|
||||
import { composeInvoice } from "./composeInvoice";
|
||||
import { buildGiroCodeDataUrl } from "./girocode";
|
||||
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
|
||||
import { loadDraftOrderForOffer } from "./loadDraftOrderForOffer.server";
|
||||
import { getLogoDataUrl } from "./logoCache.server";
|
||||
import { attachLineItemImages } from "./productImageCache.server";
|
||||
import { allocateInvoiceNumber } from "./numbering.server";
|
||||
@@ -19,6 +20,15 @@ export interface GenerateInvoiceArgs {
|
||||
orderId: string;
|
||||
/** When true, bypass the "sent invoice is locked" rule and regenerate in place. */
|
||||
forceRegenerate?: boolean;
|
||||
/**
|
||||
* Document kind. Default "invoice". When "offer":
|
||||
* - `orderId` is interpreted as a DraftOrder id (numeric or GID).
|
||||
* - The number is the draft order's name (e.g. "D1") rather than an
|
||||
* allocated invoice number.
|
||||
* - GiroCode is suppressed and the dueDate is repurposed as the offer's
|
||||
* validity expiry.
|
||||
*/
|
||||
kind?: "invoice" | "offer";
|
||||
}
|
||||
|
||||
export interface GeneratedInvoice {
|
||||
@@ -44,7 +54,8 @@ export async function generateInvoice(
|
||||
args: GenerateInvoiceArgs,
|
||||
): Promise<GeneratedInvoice> {
|
||||
const { shopDomain, admin } = args;
|
||||
const orderGid = toOrderGid(args.orderId);
|
||||
const kind = args.kind ?? "invoice";
|
||||
const orderGid = kind === "offer" ? toDraftOrderGid(args.orderId) : toOrderGid(args.orderId);
|
||||
|
||||
const settings = await db.shopSettings.upsert({
|
||||
where: { shopDomain },
|
||||
@@ -52,15 +63,17 @@ export async function generateInvoice(
|
||||
create: { shopDomain },
|
||||
});
|
||||
|
||||
const order = await loadOrderForInvoice(admin, orderGid);
|
||||
const order = kind === "offer"
|
||||
? await loadDraftOrderForOffer(admin, orderGid)
|
||||
: await loadOrderForInvoice(admin, orderGid);
|
||||
|
||||
// Find latest existing invoice (excluding storno) for this order.
|
||||
// Find latest existing document of this kind for this (draft) order.
|
||||
const latest = await db.invoice.findFirst({
|
||||
where: { shopDomain, orderId: orderGid, kind: "invoice", cancelledAt: null },
|
||||
where: { shopDomain, orderId: orderGid, kind, cancelledAt: null },
|
||||
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
if (latest && latest.sentAt && !args.forceRegenerate) {
|
||||
if (kind === "invoice" && latest && latest.sentAt && !args.forceRegenerate) {
|
||||
throw new Error(
|
||||
`Invoice ${latest.invoiceNumber} has already been sent. Use cancel-and-reissue to correct it.`,
|
||||
);
|
||||
@@ -68,10 +81,12 @@ export async function generateInvoice(
|
||||
|
||||
const invoiceNumber = latest
|
||||
? latest.invoiceNumber
|
||||
: await allocateInvoiceNumber(settings, order.orderNumber);
|
||||
: kind === "offer"
|
||||
? order.name // e.g. "D1" — Shopify's draft order name is the offer number.
|
||||
: await allocateInvoiceNumber(settings, order.orderNumber);
|
||||
|
||||
// Compose view model and render PDF.
|
||||
const viewModel = composeInvoice({ order, settings, invoiceNumber });
|
||||
const viewModel = composeInvoice({ order, settings, invoiceNumber, offer: kind === "offer" });
|
||||
|
||||
// Logo (cached).
|
||||
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
|
||||
@@ -80,8 +95,9 @@ export async function generateInvoice(
|
||||
// Product images for each line (best-effort, parallel, in-process cache).
|
||||
await attachLineItemImages(viewModel.lines);
|
||||
|
||||
// GiroCode (only for unpaid + IBAN configured + enabled).
|
||||
// GiroCode (only for invoices that are unpaid + IBAN configured + enabled).
|
||||
if (
|
||||
kind === "invoice" &&
|
||||
settings.giroCodeEnabled &&
|
||||
settings.iban &&
|
||||
!viewModel.paid &&
|
||||
@@ -101,12 +117,13 @@ export async function generateInvoice(
|
||||
|
||||
const pdfBuffer = await renderInvoicePdf(viewModel);
|
||||
|
||||
const filename = `Rechnung-${sanitiseForFilename(invoiceNumber)}.pdf`;
|
||||
const filenamePrefix = kind === "offer" ? "Angebot" : "Rechnung";
|
||||
const filename = `${filenamePrefix}-${sanitiseForFilename(invoiceNumber)}.pdf`;
|
||||
|
||||
const upload = await uploadPdfToShopifyFiles(admin, {
|
||||
bytes: pdfBuffer,
|
||||
filename,
|
||||
alt: `Invoice ${invoiceNumber}`,
|
||||
alt: kind === "offer" ? `Offer ${invoiceNumber}` : `Invoice ${invoiceNumber}`,
|
||||
});
|
||||
|
||||
const version = latest ? latest.version + 1 : 1;
|
||||
@@ -141,7 +158,7 @@ export async function generateInvoice(
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber,
|
||||
language: viewModel.language,
|
||||
kind: "invoice",
|
||||
kind,
|
||||
version: 1,
|
||||
pdfFileGid: upload.fileGid,
|
||||
pdfUrl: upload.url,
|
||||
@@ -152,15 +169,18 @@ export async function generateInvoice(
|
||||
});
|
||||
|
||||
// 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);
|
||||
// fail the whole operation if scopes are missing). Skip for offers since
|
||||
// draft orders don't accept the same metafields.
|
||||
if (kind === "invoice") {
|
||||
try {
|
||||
await writeOrderMetafields(admin, orderGid, {
|
||||
pdfUrl: upload.url,
|
||||
number: invoiceNumber,
|
||||
version: invoice.version,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Order metafield write failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -179,6 +199,12 @@ export function toOrderGid(input: string): string {
|
||||
return `gid://shopify/Order/${input}`;
|
||||
}
|
||||
|
||||
/** Same idea for DraftOrder ids. */
|
||||
export function toDraftOrderGid(input: string): string {
|
||||
if (input.startsWith("gid://")) return input;
|
||||
return `gid://shopify/DraftOrder/${input}`;
|
||||
}
|
||||
|
||||
function sanitiseForFilename(s: string): string {
|
||||
return s.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ export type InvoiceLanguage = "de" | "en";
|
||||
export interface InvoiceStrings {
|
||||
invoice: string;
|
||||
stornoInvoice: string;
|
||||
offer: string;
|
||||
offerNumber: string;
|
||||
offerDate: string;
|
||||
offerValidUntil: (until: string) => string;
|
||||
stornoReference: (originalNumber: string) => string;
|
||||
invoiceNumber: string;
|
||||
invoiceDate: string;
|
||||
@@ -53,6 +57,10 @@ export interface InvoiceStrings {
|
||||
const de: InvoiceStrings = {
|
||||
invoice: "Rechnung",
|
||||
stornoInvoice: "Stornorechnung",
|
||||
offer: "Angebot",
|
||||
offerNumber: "Angebots-Nr.",
|
||||
offerDate: "Angebotsdatum",
|
||||
offerValidUntil: (d) => `Dieses Angebot ist gültig bis ${d}.`,
|
||||
stornoReference: (n) => `Storno zu Rechnung Nr. ${n}`,
|
||||
invoiceNumber: "Rechnungs-Nr.",
|
||||
invoiceDate: "Rechnungsdatum",
|
||||
@@ -106,6 +114,10 @@ const de: InvoiceStrings = {
|
||||
const en: InvoiceStrings = {
|
||||
invoice: "Invoice",
|
||||
stornoInvoice: "Cancellation invoice",
|
||||
offer: "Offer",
|
||||
offerNumber: "Offer no.",
|
||||
offerDate: "Offer date",
|
||||
offerValidUntil: (d) => `This offer is valid until ${d}.`,
|
||||
stornoReference: (n) => `Cancels invoice no. ${n}`,
|
||||
invoiceNumber: "Invoice no.",
|
||||
invoiceDate: "Invoice date",
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
|
||||
|
||||
import type {
|
||||
RawAddress,
|
||||
RawLineItem,
|
||||
RawMoney,
|
||||
RawOrderForInvoice,
|
||||
RawTaxLine,
|
||||
} from "./loadOrderForInvoice.server";
|
||||
|
||||
/**
|
||||
* Loads a Shopify DraftOrder and adapts it to the same `RawOrderForInvoice`
|
||||
* shape used for completed orders, so the rest of the pipeline (composer,
|
||||
* PDF, etc.) doesn't need to know whether it's rendering an invoice or an
|
||||
* offer.
|
||||
*
|
||||
* Drafts have no `processedAt` (we use createdAt) and no
|
||||
* `displayFinancialStatus` (we treat them as not paid).
|
||||
*/
|
||||
const QUERY = `#graphql
|
||||
query DraftOrderForOffer($id: ID!) {
|
||||
draftOrder(id: $id) {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
currencyCode
|
||||
taxesIncluded
|
||||
customer {
|
||||
firstName
|
||||
lastName
|
||||
email
|
||||
locale
|
||||
}
|
||||
billingAddress {
|
||||
name
|
||||
company
|
||||
address1
|
||||
address2
|
||||
zip
|
||||
city
|
||||
province
|
||||
countryCode: countryCodeV2
|
||||
}
|
||||
shippingAddress {
|
||||
name
|
||||
company
|
||||
address1
|
||||
address2
|
||||
zip
|
||||
city
|
||||
province
|
||||
countryCode: countryCodeV2
|
||||
}
|
||||
subtotalPriceSet { shopMoney { amount currencyCode } }
|
||||
totalTaxSet { shopMoney { amount currencyCode } }
|
||||
totalPriceSet { shopMoney { amount currencyCode } }
|
||||
taxLines {
|
||||
title
|
||||
rate
|
||||
ratePercentage
|
||||
priceSet { shopMoney { amount currencyCode } }
|
||||
}
|
||||
lineItems(first: 250) {
|
||||
edges {
|
||||
node {
|
||||
title
|
||||
sku
|
||||
quantity
|
||||
originalUnitPriceSet { shopMoney { amount currencyCode } }
|
||||
image { url altText }
|
||||
taxLines {
|
||||
title
|
||||
rate
|
||||
ratePercentage
|
||||
priceSet { shopMoney { amount currencyCode } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
purchasingEntity {
|
||||
... on PurchasingCompany {
|
||||
company { name }
|
||||
location {
|
||||
taxRegistrationId
|
||||
billingAddress {
|
||||
address1
|
||||
address2
|
||||
zip
|
||||
city
|
||||
countryCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface RawAdminResponse {
|
||||
data?: {
|
||||
draftOrder?: {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
currencyCode: string;
|
||||
taxesIncluded: boolean;
|
||||
customer: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string | null;
|
||||
locale: string | null;
|
||||
} | null;
|
||||
billingAddress: RawAddress | null;
|
||||
shippingAddress: RawAddress | null;
|
||||
subtotalPriceSet: { shopMoney: RawMoney } | null;
|
||||
totalTaxSet: { shopMoney: RawMoney } | null;
|
||||
totalPriceSet: { shopMoney: RawMoney } | null;
|
||||
taxLines: RawTaxLine[];
|
||||
lineItems: { edges: { node: RawLineItem & { image?: { url: string | null } | null } }[] };
|
||||
purchasingEntity: {
|
||||
company?: { name: string } | null;
|
||||
location?: {
|
||||
taxRegistrationId: string | null;
|
||||
billingAddress: RawAddress | null;
|
||||
} | null;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDraftOrderForOffer(
|
||||
admin: AdminApiContext,
|
||||
draftOrderGid: string,
|
||||
): Promise<RawOrderForInvoice> {
|
||||
const response = await admin.graphql(QUERY, { variables: { id: draftOrderGid } });
|
||||
const json = (await response.json()) as RawAdminResponse;
|
||||
const draft = json.data?.draftOrder;
|
||||
if (!draft) {
|
||||
throw new Error(`Draft order ${draftOrderGid} not found.`);
|
||||
}
|
||||
|
||||
const purchasingCompany = draft.purchasingEntity?.company
|
||||
? {
|
||||
name: draft.purchasingEntity.company.name,
|
||||
vatId: draft.purchasingEntity.location?.taxRegistrationId ?? null,
|
||||
address: draft.purchasingEntity.location?.billingAddress ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Drafts don't have a numeric "order number" — use a hash of the GID as a
|
||||
// numeric proxy for the invoice-counter signature (not actually used when
|
||||
// generating offers, but kept non-zero to satisfy downstream code).
|
||||
const orderNumber = parseInt(draft.id.replace(/[^0-9]/g, "").slice(-9), 10) || 0;
|
||||
|
||||
return {
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
orderNumber,
|
||||
createdAt: draft.createdAt,
|
||||
processedAt: null,
|
||||
currencyCode: draft.currencyCode,
|
||||
displayFinancialStatus: null,
|
||||
taxesIncluded: draft.taxesIncluded,
|
||||
customer: draft.customer,
|
||||
billingAddress: draft.billingAddress,
|
||||
shippingAddress: draft.shippingAddress,
|
||||
subtotalSet: draft.subtotalPriceSet,
|
||||
totalTaxSet: draft.totalTaxSet,
|
||||
totalPriceSet: draft.totalPriceSet,
|
||||
taxLines: draft.taxLines || [],
|
||||
lineItems: (draft.lineItems?.edges || []).map((e) => {
|
||||
const node = e.node;
|
||||
return {
|
||||
title: node.title,
|
||||
sku: node.sku,
|
||||
quantity: node.quantity,
|
||||
originalUnitPriceSet: node.originalUnitPriceSet,
|
||||
taxLines: node.taxLines,
|
||||
imageUrl: node.image?.url ?? null,
|
||||
};
|
||||
}),
|
||||
purchasingEntity: { company: purchasingCompany },
|
||||
};
|
||||
}
|
||||
@@ -246,7 +246,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
|
||||
return (
|
||||
<Document
|
||||
title={`${t.invoice} ${invoice.number}`}
|
||||
title={`${invoice.kind === "offer" ? t.offer : t.invoice} ${invoice.number}`}
|
||||
author={invoice.issuer.companyName}
|
||||
creator="LinumIQ Invoice"
|
||||
>
|
||||
@@ -268,17 +268,19 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
<View style={styles.metaBlock}>
|
||||
<View style={styles.metaTable}>
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.invoiceNumber}</Text>
|
||||
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerNumber : t.invoiceNumber}</Text>
|
||||
<Text style={styles.invoiceNumberBig}>{invoice.number}</Text>
|
||||
</View>
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.invoiceDate}</Text>
|
||||
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
|
||||
</View>
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.deliveryDate}</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
|
||||
</View>
|
||||
{invoice.kind !== "offer" && (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.deliveryDate}</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{invoice.recipientVatId ? (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.customerVatId}</Text>
|
||||
@@ -290,7 +292,12 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>
|
||||
{invoice.kind === "storno" ? t.stornoInvoice : t.invoice} Nr. {invoice.number}
|
||||
{invoice.kind === "storno"
|
||||
? t.stornoInvoice
|
||||
: invoice.kind === "offer"
|
||||
? t.offer
|
||||
: t.invoice}{" "}
|
||||
Nr. {invoice.number}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.paragraph}>{t.salutationGeneric}</Text>
|
||||
@@ -342,7 +349,11 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!invoice.paid && (
|
||||
{invoice.kind === "offer" ? (
|
||||
<Text style={[styles.paragraph, { marginTop: 16 }]}>
|
||||
{invoice.dueDate ? t.offerValidUntil(formatDate(invoice.dueDate, invoice.language)) : null}
|
||||
</Text>
|
||||
) : !invoice.paid && (
|
||||
<Text style={[styles.paragraph, { marginTop: 16 }]}>
|
||||
{invoice.dueDate
|
||||
? t.paymentTerms(
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface InvoiceViewModel {
|
||||
currency: string;
|
||||
|
||||
// Identity
|
||||
kind: "invoice" | "storno";
|
||||
kind: "invoice" | "storno" | "offer";
|
||||
number: string;
|
||||
/** Only set for storno: the original invoice number being cancelled. */
|
||||
cancelsNumber?: string;
|
||||
|
||||
Reference in New Issue
Block a user