feat(offers): generate Angebot/Offer PDFs for draft orders
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,7 +60,11 @@ 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
|
||||||
|
// days from issue). The PDF renderer renders a different label.
|
||||||
|
const dueDate = offer
|
||||||
|
? addDays(invoiceDate, 30)
|
||||||
|
: !storno && settings.paymentTermDays > 0
|
||||||
? addDays(invoiceDate, settings.paymentTermDays)
|
? addDays(invoiceDate, settings.paymentTermDays)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
: kind === "offer"
|
||||||
|
? order.name // e.g. "D1" — Shopify's draft order name is the offer number.
|
||||||
: await allocateInvoiceNumber(settings, order.orderNumber);
|
: 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,7 +169,9 @@ 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
|
||||||
|
// draft orders don't accept the same metafields.
|
||||||
|
if (kind === "invoice") {
|
||||||
try {
|
try {
|
||||||
await writeOrderMetafields(admin, orderGid, {
|
await writeOrderMetafields(admin, orderGid, {
|
||||||
pdfUrl: upload.url,
|
pdfUrl: upload.url,
|
||||||
@@ -162,6 +181,7 @@ export async function generateInvoice(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Order metafield write failed:", err);
|
console.warn("Order metafield write failed:", err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
@@ -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, "_");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
{invoice.kind !== "offer" && (
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
<Text style={styles.metaLabel}>{t.deliveryDate}</Text>
|
<Text style={styles.metaLabel}>{t.deliveryDate}</Text>
|
||||||
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
|
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
|
||||||
</View>
|
</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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user