feat(offers): generate Angebot/Offer PDFs for draft orders

This commit is contained in:
Gerhard Scheikl
2026-05-09 19:26:33 +02:00
parent 1ec4faaac5
commit 6224597497
8 changed files with 465 additions and 41 deletions
+12 -6
View File
@@ -15,15 +15,17 @@ import { sendInvoiceEmail } from "../services/invoice/email.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => { export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { session, cors } = await authenticate.admin(request); const { session, cors } = await authenticate.admin(request);
const orderId = requireOrderId(params); const orderId = requireOrderId(params);
const url = new URL(request.url);
const kind = (url.searchParams.get("kind") === "offer" ? "offer" : "invoice") as "invoice" | "offer";
const orderGid = orderId.startsWith("gid://") const orderGid = orderId.startsWith("gid://")
? orderId ? orderId
: `gid://shopify/Order/${orderId}`; : `gid://shopify/${kind === "offer" ? "DraftOrder" : "Order"}/${orderId}`;
const invoices = await db.invoice.findMany({ const invoices = await db.invoice.findMany({
where: { shopDomain: session.shop, orderId: orderGid }, where: { shopDomain: session.shop, orderId: orderGid },
orderBy: [{ issuedAt: "desc" }], orderBy: [{ issuedAt: "desc" }],
}); });
const latest = invoices.find((i) => i.kind === "invoice" && !i.cancelledAt); const latest = invoices.find((i) => i.kind === kind && !i.cancelledAt);
return cors( return cors(
Response.json({ Response.json({
@@ -41,15 +43,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const orderId = requireOrderId(params); const orderId = requireOrderId(params);
const url = new URL(request.url); const url = new URL(request.url);
let op = url.searchParams.get("action"); let op = url.searchParams.get("action");
if (!op) { let kindParam = url.searchParams.get("kind");
// Also accept the action from the form body (used by the in-app fetcher). if (!op || !kindParam) {
// Also accept the action / kind from the form body (used by the in-app fetcher).
const ct = request.headers.get("content-type") || ""; const ct = request.headers.get("content-type") || "";
if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) { if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
const form = await request.formData(); const form = await request.formData();
op = (form.get("action") as string | null) ?? null; op = op ?? ((form.get("action") as string | null) ?? null);
kindParam = kindParam ?? ((form.get("kind") as string | null) ?? null);
} }
} }
op = op ?? "generate"; op = op ?? "generate";
const kind: "invoice" | "offer" = kindParam === "offer" ? "offer" : "invoice";
try { try {
if (op === "cancel_reissue") { if (op === "cancel_reissue") {
@@ -109,8 +114,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
shopDomain: session.shop, shopDomain: session.shop,
admin, admin,
orderId, orderId,
kind,
}); });
return cors(Response.json({ ok: true, op: "generate", ...result })); return cors(Response.json({ ok: true, op: kind === "offer" ? "generate_offer" : "generate", ...result }));
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.error("invoice action failed:", err); console.error("invoice action failed:", err);
+173 -1
View File
@@ -20,6 +20,20 @@ interface RecentOrder {
pdfUrl?: string; pdfUrl?: string;
} }
interface DraftOrderRow {
id: string; // gid
numericId: string;
name: string;
createdAt: string;
totalPrice: string;
currency: string;
customerName: string;
hasOffer: boolean;
offerNumber?: string;
offerVersion?: number;
pdfUrl?: string;
}
const RECENT_ORDERS_QUERY = `#graphql const RECENT_ORDERS_QUERY = `#graphql
query RecentOrders($first: Int!) { query RecentOrders($first: Int!) {
orders(first: $first, sortKey: CREATED_AT, reverse: true) { orders(first: $first, sortKey: CREATED_AT, reverse: true) {
@@ -35,6 +49,20 @@ const RECENT_ORDERS_QUERY = `#graphql
} }
`; `;
const RECENT_DRAFTS_QUERY = `#graphql
query RecentDrafts($first: Int!) {
draftOrders(first: $first, sortKey: UPDATED_AT, reverse: true, query: "status:open") {
nodes {
id
name
createdAt
totalPriceSet { shopMoney { amount currencyCode } }
customer { firstName lastName }
}
}
}
`;
type Filter = "all" | "missing" | "with"; type Filter = "all" | "missing" | "with";
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -47,6 +75,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
: "all"; : "all";
let orders: RecentOrder[] = []; let orders: RecentOrder[] = [];
let drafts: DraftOrderRow[] = [];
try { try {
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } }); const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } });
const json = (await res.json()) as { const json = (await res.json()) as {
@@ -103,6 +132,56 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
console.warn("Failed to load recent orders:", err); console.warn("Failed to load recent orders:", err);
} }
try {
const res = await admin.graphql(RECENT_DRAFTS_QUERY, { variables: { first: 50 } });
const json = (await res.json()) as {
data?: {
draftOrders?: {
nodes?: Array<{
id: string;
name: string;
createdAt: string;
totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } };
customer?: { firstName: string | null; lastName: string | null } | null;
}>;
};
};
};
const nodes = json.data?.draftOrders?.nodes ?? [];
const draftIds = nodes.map((n) => n.id);
const offers = await db.invoice.findMany({
where: { shopDomain: session.shop, orderId: { in: draftIds }, kind: "offer" },
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
const latestByDraft = new Map<string, (typeof offers)[number]>();
for (const off of offers) {
if (!latestByDraft.has(off.orderId)) latestByDraft.set(off.orderId, off);
}
drafts = nodes.map((n) => {
const off = latestByDraft.get(n.id);
const customer = n.customer
? [n.customer.firstName, n.customer.lastName].filter(Boolean).join(" ").trim()
: "";
return {
id: n.id,
numericId: n.id.replace(/^.*\//, ""),
name: n.name,
createdAt: n.createdAt,
totalPrice: n.totalPriceSet?.shopMoney.amount ?? "",
currency: n.totalPriceSet?.shopMoney.currencyCode ?? "EUR",
customerName: customer || "Guest",
hasOffer: !!off && !off.cancelledAt,
offerNumber: off?.invoiceNumber,
offerVersion: off?.version,
pdfUrl: off?.pdfUrl,
};
});
} catch (err) {
console.warn("Failed to load draft orders:", err);
}
const allCount = orders.length; const allCount = orders.length;
const withCount = orders.filter((o) => o.hasInvoice).length; const withCount = orders.filter((o) => o.hasInvoice).length;
const missingCount = allCount - withCount; const missingCount = allCount - withCount;
@@ -112,6 +191,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return { return {
orders, orders,
drafts,
filter, filter,
counts: { all: allCount, with: withCount, missing: missingCount }, counts: { all: allCount, with: withCount, missing: missingCount },
}; };
@@ -134,7 +214,7 @@ function formatMoney(amount: string, currency: string): string {
} }
export default function InvoicesPage() { export default function InvoicesPage() {
const { orders, filter, counts } = useLoaderData<typeof loader>(); const { orders, drafts, filter, counts } = useLoaderData<typeof loader>();
const navigation = useNavigation(); const navigation = useNavigation();
const isLoading = navigation.state !== "idle"; const isLoading = navigation.state !== "idle";
@@ -195,6 +275,40 @@ export default function InvoicesPage() {
)} )}
</s-section> </s-section>
<s-section heading="Draft orders (offers)">
<s-stack direction="block" gap="base">
<s-paragraph>
Generate a PDF offer (Angebot) for any open draft order. The
offer's number is the draft order name (e.g. <em>D1</em>).
</s-paragraph>
</s-stack>
{drafts.length === 0 ? (
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No open draft orders</s-text>
<s-paragraph tone="neutral">
Create a draft order in Shopify and refresh this page.
</s-paragraph>
</s-stack>
) : (
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Draft</s-table-header>
<s-table-header>Customer</s-table-header>
<s-table-header>Date</s-table-header>
<s-table-header format="numeric">Total</s-table-header>
<s-table-header listSlot="secondary">Offer</s-table-header>
<s-table-header listSlot="labeled">Actions</s-table-header>
</s-table-header-row>
<s-table-body>
{drafts.map((d) => (
<DraftRow key={d.id} draft={d} />
))}
</s-table-body>
</s-table>
)}
</s-section>
<s-section heading="About this page"> <s-section heading="About this page">
<s-stack direction="block" gap="small"> <s-stack direction="block" gap="small">
<s-paragraph> <s-paragraph>
@@ -319,3 +433,61 @@ function OrderRow({ order }: { order: RecentOrder }) {
</s-table-row> </s-table-row>
); );
} }
function DraftRow({ draft }: { draft: DraftOrderRow }) {
const fetcher = useFetcher<{ ok: boolean; error?: string }>();
const isBusy = fetcher.state !== "idle";
const buttonLabel = draft.hasOffer ? "Regenerate offer" : "Generate offer";
return (
<s-table-row>
<s-table-cell>
<s-stack direction="block" gap="none">
<s-link href={`shopify://admin/draft_orders/${draft.numericId}`}>
<s-text type="strong">{draft.name}</s-text>
</s-link>
</s-stack>
</s-table-cell>
<s-table-cell>{draft.customerName}</s-table-cell>
<s-table-cell>{dateFmt.format(new Date(draft.createdAt))}</s-table-cell>
<s-table-cell>{formatMoney(draft.totalPrice, draft.currency)}</s-table-cell>
<s-table-cell>
{draft.hasOffer ? (
<s-stack direction="block" gap="none">
<s-stack direction="inline" gap="small" alignItems="center">
<s-text type="strong">{draft.offerNumber}</s-text>
<s-badge tone="info">Issued</s-badge>
{draft.offerVersion && draft.offerVersion > 1 ? (
<s-text tone="neutral">v{draft.offerVersion}</s-text>
) : null}
</s-stack>
{fetcher.data?.error ? (
<s-text tone="critical">{fetcher.data.error}</s-text>
) : null}
</s-stack>
) : (
<s-text tone="neutral"></s-text>
)}
</s-table-cell>
<s-table-cell>
<s-stack direction="inline" gap="small" justifyContent="end" alignItems="center">
{draft.pdfUrl ? (
<s-link href={draft.pdfUrl} target="_blank">
PDF
</s-link>
) : null}
<fetcher.Form method="post" action={`/api/orders/${draft.numericId}/invoice`}>
<input type="hidden" name="kind" value="offer" />
<s-button
type="submit"
disabled={isBusy}
variant={draft.hasOffer ? "secondary" : "primary"}
>
{isBusy ? "Working…" : buttonLabel}
</s-button>
</fetcher.Form>
</s-stack>
</s-table-cell>
</s-table-row>
);
}
+17 -4
View File
@@ -28,6 +28,14 @@ interface ComposeArgs {
storno?: { cancelsNumber: string }; storno?: { cancelsNumber: string };
/** Optional override for invoice/delivery date (defaults to order date). */ /** Optional override for invoice/delivery date (defaults to order date). */
issueDate?: 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({ export function composeInvoice({
@@ -37,6 +45,7 @@ export function composeInvoice({
forceLanguage, forceLanguage,
storno, storno,
issueDate, issueDate,
offer,
}: ComposeArgs): InvoiceViewModel { }: ComposeArgs): InvoiceViewModel {
const language = forceLanguage const language = forceLanguage
?? pickLanguage(order.customer?.locale ?? settings.defaultLanguage); ?? pickLanguage(order.customer?.locale ?? settings.defaultLanguage);
@@ -51,9 +60,13 @@ export function composeInvoice({
const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt); const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt);
const deliveryDate = invoiceDate; const deliveryDate = invoiceDate;
const dueDate = !storno && settings.paymentTermDays > 0 // For offers we treat `dueDate` as the offer's validity expiry (default 30
? addDays(invoiceDate, settings.paymentTermDays) // days from issue). The PDF renderer renders a different label.
: undefined; const dueDate = offer
? addDays(invoiceDate, 30)
: !storno && settings.paymentTermDays > 0
? addDays(invoiceDate, settings.paymentTermDays)
: undefined;
const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID"; const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID";
@@ -80,7 +93,7 @@ export function composeInvoice({
return { return {
language, language,
currency: order.currencyCode, currency: order.currencyCode,
kind: storno ? "storno" : "invoice", kind: storno ? "storno" : offer ? "offer" : "invoice",
number: invoiceNumber, number: invoiceNumber,
cancelsNumber: storno?.cancelsNumber, cancelsNumber: storno?.cancelsNumber,
invoiceDate, invoiceDate,
+46 -20
View File
@@ -6,6 +6,7 @@ import db from "../../db.server";
import { composeInvoice } from "./composeInvoice"; import { composeInvoice } from "./composeInvoice";
import { buildGiroCodeDataUrl } from "./girocode"; import { buildGiroCodeDataUrl } from "./girocode";
import { loadOrderForInvoice } from "./loadOrderForInvoice.server"; import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
import { loadDraftOrderForOffer } from "./loadDraftOrderForOffer.server";
import { getLogoDataUrl } from "./logoCache.server"; import { getLogoDataUrl } from "./logoCache.server";
import { attachLineItemImages } from "./productImageCache.server"; import { attachLineItemImages } from "./productImageCache.server";
import { allocateInvoiceNumber } from "./numbering.server"; import { allocateInvoiceNumber } from "./numbering.server";
@@ -19,6 +20,15 @@ export interface GenerateInvoiceArgs {
orderId: string; orderId: string;
/** When true, bypass the "sent invoice is locked" rule and regenerate in place. */ /** When true, bypass the "sent invoice is locked" rule and regenerate in place. */
forceRegenerate?: boolean; 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 { export interface GeneratedInvoice {
@@ -44,7 +54,8 @@ export async function generateInvoice(
args: GenerateInvoiceArgs, args: GenerateInvoiceArgs,
): Promise<GeneratedInvoice> { ): Promise<GeneratedInvoice> {
const { shopDomain, admin } = args; 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({ const settings = await db.shopSettings.upsert({
where: { shopDomain }, where: { shopDomain },
@@ -52,15 +63,17 @@ export async function generateInvoice(
create: { shopDomain }, 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({ 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" }], orderBy: [{ version: "desc" }, { createdAt: "desc" }],
}); });
if (latest && latest.sentAt && !args.forceRegenerate) { if (kind === "invoice" && latest && latest.sentAt && !args.forceRegenerate) {
throw new Error( throw new Error(
`Invoice ${latest.invoiceNumber} has already been sent. Use cancel-and-reissue to correct it.`, `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 const invoiceNumber = latest
? latest.invoiceNumber ? 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. // Compose view model and render PDF.
const viewModel = composeInvoice({ order, settings, invoiceNumber }); const viewModel = composeInvoice({ order, settings, invoiceNumber, offer: kind === "offer" });
// Logo (cached). // Logo (cached).
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl); 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). // Product images for each line (best-effort, parallel, in-process cache).
await attachLineItemImages(viewModel.lines); await attachLineItemImages(viewModel.lines);
// GiroCode (only for unpaid + IBAN configured + enabled). // GiroCode (only for invoices that are unpaid + IBAN configured + enabled).
if ( if (
kind === "invoice" &&
settings.giroCodeEnabled && settings.giroCodeEnabled &&
settings.iban && settings.iban &&
!viewModel.paid && !viewModel.paid &&
@@ -101,12 +117,13 @@ export async function generateInvoice(
const pdfBuffer = await renderInvoicePdf(viewModel); 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, { const upload = await uploadPdfToShopifyFiles(admin, {
bytes: pdfBuffer, bytes: pdfBuffer,
filename, filename,
alt: `Invoice ${invoiceNumber}`, alt: kind === "offer" ? `Offer ${invoiceNumber}` : `Invoice ${invoiceNumber}`,
}); });
const version = latest ? latest.version + 1 : 1; const version = latest ? latest.version + 1 : 1;
@@ -141,7 +158,7 @@ export async function generateInvoice(
orderNumber: order.orderNumber, orderNumber: order.orderNumber,
invoiceNumber, invoiceNumber,
language: viewModel.language, language: viewModel.language,
kind: "invoice", kind,
version: 1, version: 1,
pdfFileGid: upload.fileGid, pdfFileGid: upload.fileGid,
pdfUrl: upload.url, pdfUrl: upload.url,
@@ -152,15 +169,18 @@ export async function generateInvoice(
}); });
// Link the latest PDF on the order via metafields (best-effort; do not // Link the latest PDF on the order via metafields (best-effort; do not
// fail the whole operation if scopes are missing). // fail the whole operation if scopes are missing). Skip for offers since
try { // draft orders don't accept the same metafields.
await writeOrderMetafields(admin, orderGid, { if (kind === "invoice") {
pdfUrl: upload.url, try {
number: invoiceNumber, await writeOrderMetafields(admin, orderGid, {
version: invoice.version, pdfUrl: upload.url,
}); number: invoiceNumber,
} catch (err) { version: invoice.version,
console.warn("Order metafield write failed:", err); });
} catch (err) {
console.warn("Order metafield write failed:", err);
}
} }
return { return {
@@ -179,6 +199,12 @@ export function toOrderGid(input: string): string {
return `gid://shopify/Order/${input}`; 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 { function sanitiseForFilename(s: string): string {
return s.replace(/[^A-Za-z0-9._-]/g, "_"); return s.replace(/[^A-Za-z0-9._-]/g, "_");
} }
+12
View File
@@ -5,6 +5,10 @@ export type InvoiceLanguage = "de" | "en";
export interface InvoiceStrings { export interface InvoiceStrings {
invoice: string; invoice: string;
stornoInvoice: string; stornoInvoice: string;
offer: string;
offerNumber: string;
offerDate: string;
offerValidUntil: (until: string) => string;
stornoReference: (originalNumber: string) => string; stornoReference: (originalNumber: string) => string;
invoiceNumber: string; invoiceNumber: string;
invoiceDate: string; invoiceDate: string;
@@ -53,6 +57,10 @@ export interface InvoiceStrings {
const de: InvoiceStrings = { const de: InvoiceStrings = {
invoice: "Rechnung", invoice: "Rechnung",
stornoInvoice: "Stornorechnung", 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}`, stornoReference: (n) => `Storno zu Rechnung Nr. ${n}`,
invoiceNumber: "Rechnungs-Nr.", invoiceNumber: "Rechnungs-Nr.",
invoiceDate: "Rechnungsdatum", invoiceDate: "Rechnungsdatum",
@@ -106,6 +114,10 @@ const de: InvoiceStrings = {
const en: InvoiceStrings = { const en: InvoiceStrings = {
invoice: "Invoice", invoice: "Invoice",
stornoInvoice: "Cancellation 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}`, stornoReference: (n) => `Cancels invoice no. ${n}`,
invoiceNumber: "Invoice no.", invoiceNumber: "Invoice no.",
invoiceDate: "Invoice date", 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 },
};
}
+20 -9
View File
@@ -246,7 +246,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
return ( return (
<Document <Document
title={`${t.invoice} ${invoice.number}`} title={`${invoice.kind === "offer" ? t.offer : t.invoice} ${invoice.number}`}
author={invoice.issuer.companyName} author={invoice.issuer.companyName}
creator="LinumIQ Invoice" creator="LinumIQ Invoice"
> >
@@ -268,17 +268,19 @@ export function InvoiceDocument({ invoice }: DocProps) {
<View style={styles.metaBlock}> <View style={styles.metaBlock}>
<View style={styles.metaTable}> <View style={styles.metaTable}>
<View style={styles.metaRow}> <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> <Text style={styles.invoiceNumberBig}>{invoice.number}</Text>
</View> </View>
<View style={styles.metaRow}> <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> <Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
</View> </View>
<View style={styles.metaRow}> {invoice.kind !== "offer" && (
<Text style={styles.metaLabel}>{t.deliveryDate}</Text> <View style={styles.metaRow}>
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text> <Text style={styles.metaLabel}>{t.deliveryDate}</Text>
</View> <Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
</View>
)}
{invoice.recipientVatId ? ( {invoice.recipientVatId ? (
<View style={styles.metaRow}> <View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.customerVatId}</Text> <Text style={styles.metaLabel}>{t.customerVatId}</Text>
@@ -290,7 +292,12 @@ export function InvoiceDocument({ invoice }: DocProps) {
</View> </View>
<Text style={styles.title}> <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>
<Text style={styles.paragraph}>{t.salutationGeneric}</Text> <Text style={styles.paragraph}>{t.salutationGeneric}</Text>
@@ -342,7 +349,11 @@ export function InvoiceDocument({ invoice }: DocProps) {
</View> </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 }]}> <Text style={[styles.paragraph, { marginTop: 16 }]}>
{invoice.dueDate {invoice.dueDate
? t.paymentTerms( ? t.paymentTerms(
+1 -1
View File
@@ -10,7 +10,7 @@ export interface InvoiceViewModel {
currency: string; currency: string;
// Identity // Identity
kind: "invoice" | "storno"; kind: "invoice" | "storno" | "offer";
number: string; number: string;
/** Only set for storno: the original invoice number being cancelled. */ /** Only set for storno: the original invoice number being cancelled. */
cancelsNumber?: string; cancelsNumber?: string;