diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts
index 65f77ed..eb06dc0 100644
--- a/app/services/invoice/composeInvoice.ts
+++ b/app/services/invoice/composeInvoice.ts
@@ -62,12 +62,18 @@ export function composeInvoice({
});
let notices = deriveNotices({ order, settings, isB2B });
- const separateShippingAddress = mapSeparateShippingAddress(order);
- const shippingMethod = order.shippingLine?.title?.trim() || undefined;
+ const isPickup = detectPickup(order.shippingLine);
+ const separateShippingAddress = isPickup ? undefined : mapSeparateShippingAddress(order);
+ const shippingMethod = isPickup
+ ? strings.pickupLabel
+ : order.shippingLine?.title?.trim() || undefined;
const tracking = mapTracking(order);
const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt);
- const deliveryDate = invoiceDate;
+ // §11 UStG: deliveryDate is the date goods/services were rendered. Prefer
+ // the latest fulfillment timestamp; fall back to invoice date when the
+ // order is unfulfilled (e.g. immediate-render services or digital orders).
+ const deliveryDate = pickDeliveryDate(order, invoiceDate);
// 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
@@ -86,6 +92,8 @@ export function composeInvoice({
lines = lines.map((l) => ({
...l,
unitPriceNet: -l.unitPriceNet,
+ originalUnitPriceNet:
+ l.originalUnitPriceNet != null ? -l.originalUnitPriceNet : undefined,
totalNet: -l.totalNet,
}));
totals = {
@@ -125,6 +133,8 @@ export function composeInvoice({
separateShippingAddress,
shippingMethod,
tracking,
+ discountCodes: order.discountCodes ?? [],
+ isPickup,
};
}
@@ -198,7 +208,13 @@ function mapLinesAndTotals(
order.lineItems.forEach((li, idx) => {
const qty = li.quantity;
- const grossOrNetUnit = parseFloat(li.originalUnitPriceSet.shopMoney.amount);
+ // Prefer the post-discount unit price when Shopify provides one (it
+ // reflects both line-level and cart-level discount allocations). Fall
+ // back to original price when no discount applied.
+ const grossOrNetUnit = parseFloat(
+ (li.discountedUnitPriceSet ?? li.originalUnitPriceSet).shopMoney.amount,
+ );
+ const originalGrossOrNetUnit = parseFloat(li.originalUnitPriceSet.shopMoney.amount);
// Total tax for this line summed across its tax lines.
const lineTax = li.taxLines.reduce(
(acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount),
@@ -208,6 +224,17 @@ function mapLinesAndTotals(
const lineGross = grossOrNetUnit * qty + (taxesIncluded ? 0 : lineTax);
const lineNet = taxesIncluded ? grossOrNetUnit * qty - lineTax : grossOrNetUnit * qty;
const unitNet = qty > 0 ? lineNet / qty : 0;
+ // For the strikethrough original, compute net the same way the line is
+ // computed: when taxesIncluded, derive an equivalent net per unit using
+ // the line's effective tax rate; when not, the original IS the net.
+ const effectiveRate = qty > 0 && grossOrNetUnit > 0
+ ? lineTax / (grossOrNetUnit * qty)
+ : 0;
+ const originalUnitNet = taxesIncluded
+ ? originalGrossOrNetUnit / (1 + effectiveRate)
+ : originalGrossOrNetUnit;
+ const hasDiscount =
+ Math.round(originalGrossOrNetUnit * 100) !== Math.round(grossOrNetUnit * 100);
linesOut.push({
position: idx + 1,
@@ -215,6 +242,7 @@ function mapLinesAndTotals(
sku: li.sku ?? undefined,
quantity: qty,
unitPriceNet: round2(unitNet),
+ originalUnitPriceNet: hasDiscount ? round2(originalUnitNet) : undefined,
totalNet: round2(lineNet),
imageUrl: li.imageUrl ?? undefined,
});
@@ -452,3 +480,37 @@ function mapTracking(order: RawOrderForInvoice): TrackingInfo[] {
}
return out;
}
+
+/**
+ * Heuristically detects whether the order's shipping line is a "local
+ * pickup" line. Shopify exposes pickup either via the dedicated Local
+ * Pickup app (source contains "pickup") or via a custom rate the merchant
+ * named "Abholung"/"Pickup". When detected, callers should NOT render the
+ * pickup-location address as a separate "delivery address".
+ */
+function detectPickup(shippingLine: RawShippingLine | null): boolean {
+ if (!shippingLine) return false;
+ const haystack = [
+ shippingLine.source,
+ shippingLine.code,
+ shippingLine.title,
+ shippingLine.carrierIdentifier,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return /pick[\s-]?up|abholung|abhol\b/.test(haystack);
+}
+
+/**
+ * Picks the delivery date for §11 UStG: the latest fulfillment timestamp
+ * when the order is fulfilled, otherwise the invoice date itself (best
+ * approximation for unfulfilled / digital orders).
+ */
+function pickDeliveryDate(order: RawOrderForInvoice, invoiceDate: Date): Date {
+ const stamps = (order.fulfillments ?? [])
+ .map((f) => (f.createdAt ? new Date(f.createdAt).getTime() : NaN))
+ .filter((n) => Number.isFinite(n));
+ if (stamps.length === 0) return invoiceDate;
+ return new Date(Math.max(...stamps));
+}
diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts
index 473a416..2e0e79d 100644
--- a/app/services/invoice/i18n.ts
+++ b/app/services/invoice/i18n.ts
@@ -63,6 +63,8 @@ export interface InvoiceStrings {
shippingMethodLabel: string;
trackingLabel: string;
shippingItemPrefix: string;
+ discountCodeLabel: string;
+ pickupLabel: string;
}
/** Status displayed for the order's payment, derived from Shopify's
@@ -163,6 +165,8 @@ const de: InvoiceStrings = {
shippingMethodLabel: "Versandart",
trackingLabel: "Sendungsnummer",
shippingItemPrefix: "Versand",
+ discountCodeLabel: "Rabattcode",
+ pickupLabel: "Abholung",
};
const en: InvoiceStrings = {
@@ -230,6 +234,8 @@ const en: InvoiceStrings = {
shippingMethodLabel: "Shipping method",
trackingLabel: "Tracking no.",
shippingItemPrefix: "Shipping",
+ discountCodeLabel: "Discount code",
+ pickupLabel: "Pick-up",
};
// Locale → invoice language. We only render in German (`de`) when the
diff --git a/app/services/invoice/loadDraftOrderForOffer.server.ts b/app/services/invoice/loadDraftOrderForOffer.server.ts
index 882db6b..e01f00f 100644
--- a/app/services/invoice/loadDraftOrderForOffer.server.ts
+++ b/app/services/invoice/loadDraftOrderForOffer.server.ts
@@ -163,6 +163,7 @@ export async function loadDraftOrderForOffer(
paymentGatewayNames: [],
shippingLine: null,
fulfillments: [],
+ discountCodes: [],
taxesIncluded: draft.taxesIncluded,
customer: draft.customer,
billingAddress: draft.billingAddress,
@@ -178,6 +179,7 @@ export async function loadDraftOrderForOffer(
sku: node.sku,
quantity: node.quantity,
originalUnitPriceSet: node.originalUnitPriceSet,
+ discountedUnitPriceSet: null,
taxLines: node.taxLines,
imageUrl: node.image?.url ?? null,
};
diff --git a/app/services/invoice/loadOrderForInvoice.server.ts b/app/services/invoice/loadOrderForInvoice.server.ts
index 2958f0b..c3bc7a5 100644
--- a/app/services/invoice/loadOrderForInvoice.server.ts
+++ b/app/services/invoice/loadOrderForInvoice.server.ts
@@ -25,6 +25,10 @@ export interface RawOrderForInvoice {
taxLines: RawTaxLine[];
shippingLine: RawShippingLine | null;
fulfillments: RawFulfillment[];
+ /** Discount codes applied to the cart (e.g. `["SUMMER10"]`). Empty when
+ * no codes were used. Manual / automatic discounts without a code are
+ * not exposed here. */
+ discountCodes: string[];
taxesIncluded: boolean;
subtotalSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null;
@@ -59,6 +63,10 @@ export interface RawLineItem {
sku: string | null;
quantity: number;
originalUnitPriceSet: { shopMoney: RawMoney };
+ /** Per-unit price after Shopify has allocated cart-level discounts to this
+ * line. May be null when no discount applied (in which case use the
+ * original price). */
+ discountedUnitPriceSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
imageUrl: string | null;
}
@@ -87,6 +95,10 @@ export interface RawTrackingInfo {
}
export interface RawFulfillment {
+ /** ISO timestamp of when the fulfillment was created (i.e. when the goods
+ * were dispatched / handed over). Used for the legally-required delivery
+ * date on the invoice when present. */
+ createdAt: string | null;
trackingInfo: RawTrackingInfo[];
}
@@ -137,6 +149,8 @@ const QUERY = `#graphql
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
+ discountCode
+ discountCodes
shippingLine {
title
code
@@ -152,6 +166,7 @@ const QUERY = `#graphql
}
}
fulfillments(first: 10) {
+ createdAt
trackingInfo {
number
url
@@ -165,6 +180,7 @@ const QUERY = `#graphql
sku
quantity
originalUnitPriceSet { shopMoney { amount currencyCode } }
+ discountedUnitPriceSet { shopMoney { amount currencyCode } }
image { url altText }
taxLines {
title
@@ -220,6 +236,8 @@ interface RawAdminResponse {
totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
+ discountCode: string | null;
+ discountCodes: string[] | null;
shippingLine: RawShippingLine | null;
fulfillments: RawFulfillment[] | null;
lineItems: { edges: { node: RawLineItem }[] };
@@ -270,6 +288,9 @@ export async function loadOrderForInvoice(
totalTaxSet: order.totalTaxSet,
totalPriceSet: order.totalPriceSet,
taxLines: order.taxLines || [],
+ discountCodes: order.discountCodes && order.discountCodes.length > 0
+ ? order.discountCodes
+ : (order.discountCode ? [order.discountCode] : []),
shippingLine: order.shippingLine ?? null,
fulfillments: order.fulfillments ?? [],
lineItems: (order.lineItems?.edges || []).map((e) => {
@@ -279,6 +300,7 @@ export async function loadOrderForInvoice(
sku: node.sku,
quantity: node.quantity,
originalUnitPriceSet: node.originalUnitPriceSet,
+ discountedUnitPriceSet: node.discountedUnitPriceSet ?? null,
taxLines: node.taxLines,
imageUrl: node.image?.url ?? null,
};
diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx
index 35b3b7c..f009157 100644
--- a/app/services/invoice/pdf/InvoiceDocument.tsx
+++ b/app/services/invoice/pdf/InvoiceDocument.tsx
@@ -52,6 +52,11 @@ const styles = StyleSheet.create({
recipientBlock: {
width: "55%",
},
+ recipientBlockFull: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ marginBottom: 20,
+ },
recipientName: {
fontFamily: "Helvetica-Bold",
fontSize: 10,
@@ -69,7 +74,10 @@ const styles = StyleSheet.create({
marginBottom: 2,
},
metaBlock: {
- width: "40%",
+ width: "45%",
+ },
+ metaBlockHeader: {
+ width: "50%",
},
metaTable: {
flexDirection: "column",
@@ -85,6 +93,11 @@ const styles = StyleSheet.create({
metaValue: {
fontFamily: "Helvetica-Bold",
},
+ unitOriginalStrike: {
+ color: TEXT_MUTED,
+ textDecoration: "line-through",
+ fontSize: 7,
+ },
invoiceNumberBig: {
color: BRAND_BLUE,
fontFamily: "Helvetica-Bold",
@@ -270,31 +283,14 @@ export function InvoiceDocument({ invoice }: DocProps) {
)}
-
-
-
- {senderInline(invoice.issuer)}
-
- {invoice.separateShippingAddress ? (
-
- {t.shippingAddressHeading}
-
-
- ) : null}
-
-
+ {invoice.issuer.logoDataUrl ? (
+
+ ) : (
+
+ )}
+
-
- {invoice.kind === "offer" ? t.offerNumber : t.invoiceNumber}
- {invoice.number}
-
- {invoice.kind === "invoice" && invoice.orderName ? (
-
- {t.orderNumberLabel}
- {invoice.orderName}
-
- ) : null}
{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}
{formatDate(invoice.invoiceDate, invoice.language)}
@@ -327,6 +323,12 @@ export function InvoiceDocument({ invoice }: DocProps) {
)}
+ {invoice.kind === "invoice" && invoice.discountCodes.length > 0 ? (
+
+ {t.discountCodeLabel}
+ {invoice.discountCodes.join(", ")}
+
+ ) : null}
{invoice.kind === "invoice" && invoice.shippingMethod ? (
{t.shippingMethodLabel}
@@ -350,6 +352,19 @@ export function InvoiceDocument({ invoice }: DocProps) {
+
+
+ {senderInline(invoice.issuer)}
+
+
+ {invoice.separateShippingAddress ? (
+
+ {t.shippingAddressHeading}
+
+
+ ) : null}
+
+
{invoice.kind === "storno"
? t.stornoInvoice
@@ -357,6 +372,9 @@ export function InvoiceDocument({ invoice }: DocProps) {
? t.offer
: t.invoice}{" "}
Nr. {invoice.number}
+ {invoice.kind === "invoice" && invoice.orderName
+ ? ` · ${t.orderNumberLabel}: ${invoice.orderName}`
+ : ""}
{t.salutationGeneric}
@@ -474,14 +492,12 @@ function senderInline(issuer: IssuerData): string {
.join(" - ");
}
-function Header({ issuer }: { issuer: IssuerData }) {
- return (
-
- {/* spacer; logo is right-aligned */}
- {issuer.logoDataUrl ? : }
-
- );
+function Header(_args: { issuer: IssuerData }) {
+ // Deprecated: header rendering is now inlined in InvoiceDocument so the
+ // logo and meta block can share a single row at the top of the page.
+ return null;
}
+void Header;
function Recipient({ recipient }: { recipient: RecipientData }) {
const lines: string[] = [];
@@ -529,7 +545,14 @@ function LineRow({
{formatQuantity(line.quantity, t.pieceUnit, language)}
- {formatMoney(line.unitPriceNet, currency, language)}
+
+ {line.originalUnitPriceNet != null ? (
+
+ {formatMoney(line.originalUnitPriceNet, currency, language)}
+
+ ) : null}
+ {formatMoney(line.unitPriceNet, currency, language)}
+
{formatMoney(line.totalNet, currency, language)}
);
diff --git a/app/services/invoice/types.ts b/app/services/invoice/types.ts
index b65bd3e..809a132 100644
--- a/app/services/invoice/types.ts
+++ b/app/services/invoice/types.ts
@@ -62,6 +62,14 @@ export interface InvoiceViewModel {
/** Tracking entries collected from order fulfillments. Empty when the
* order is unfulfilled or has no tracking. */
tracking: TrackingInfo[];
+
+ /** Discount codes applied to the cart (e.g. `["SUMMER10"]`). Empty when
+ * none. */
+ discountCodes: string[];
+
+ /** True when the customer chose local pickup (so we shouldn't render the
+ * pickup-location address as a "delivery address"). */
+ isPickup: boolean;
}
export interface TrackingInfo {
@@ -110,8 +118,13 @@ export interface InvoiceLine {
title: string;
/** Raw quantity (e.g. 6). */
quantity: number;
- /** Net unit price (excluding tax). */
+ /** Net unit price (excluding tax). When a discount applies, this is the
+ * effective discounted price actually charged. */
unitPriceNet: number;
+ /** Original net unit price BEFORE any discount allocation. Only set when
+ * it differs from `unitPriceNet`. The renderer uses this to display a
+ * strikethrough original next to the discounted price. */
+ originalUnitPriceNet?: number;
/** Net total = quantity * unitPriceNet. */
totalNet: number;
/** Optional SKU for display under the title. */
diff --git a/scripts/render-sample.ts b/scripts/render-sample.ts
index ab937f9..ea95f26 100644
--- a/scripts/render-sample.ts
+++ b/scripts/render-sample.ts
@@ -136,6 +136,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
displayFinancialStatus: "PENDING",
paymentGatewayNames: ["manual"],
taxesIncluded: false,
+ discountCodes: [],
customer: {
firstName: "Lukas",
lastName: "Schmidhofer",
@@ -180,6 +181,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
},
fulfillments: [
{
+ createdAt: "2026-05-13T10:30:00.000Z",
trackingInfo: [
{ number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" },
],
@@ -191,6 +193,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
sku: "BT-TRK-001",
quantity: qty,
originalUnitPriceSet: { shopMoney: { amount: unitNet.toFixed(2), currencyCode: "EUR" } },
+ discountedUnitPriceSet: null,
imageUrl: "file://product-image", // placeholder; the smoke script inlines a real data: URL on the composed line below.
taxLines: [
{
@@ -262,6 +265,50 @@ function buildExportOrder(): RawOrderForInvoice {
return o;
}
+/**
+ * Variant of the AT B2B order with a per-line discount: unit price stays
+ * 7.19 EUR gross (5.99 net) but Shopify allocated a 1.00 EUR/unit discount,
+ * so the discounted unit price is 6.19 gross (5.16 net). Also adds an
+ * order-level discount code ("SUMMER10") for the meta-block render.
+ */
+function buildDiscountedOrder(): RawOrderForInvoice {
+ const o = buildAtB2BOrder();
+ o.discountCodes = ["SUMMER10"];
+ // Discount of 1.00 EUR/unit applied: net unit drops from 5.99 to 4.99,
+ // qty 6 → 29.94 net, tax (20%) = 5.99.
+ o.lineItems[0].discountedUnitPriceSet = {
+ shopMoney: { amount: "4.99", currencyCode: "EUR" },
+ };
+ o.lineItems[0].taxLines = [
+ {
+ title: "USt 20%",
+ rate: 0.2,
+ ratePercentage: 20,
+ priceSet: { shopMoney: { amount: "5.99", currencyCode: "EUR" } },
+ },
+ ];
+ return o;
+}
+
+/**
+ * Variant of the AT B2B order whose shipping line is local pickup. The
+ * "shipping address" still carries the pickup-location address (as Shopify
+ * does), but the composer should detect the pickup and suppress it.
+ */
+function buildPickupOrder(): RawOrderForInvoice {
+ const o = buildAtB2BOrder();
+ o.shippingLine = {
+ title: "Local Pickup — Lager Graz",
+ code: "PICKUP",
+ source: "shopify-local-pickup",
+ carrierIdentifier: null,
+ originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
+ discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
+ taxLines: [],
+ };
+ return o;
+}
+
// ------------------------------------------------------------------
// Run assertions
// ------------------------------------------------------------------
@@ -482,6 +529,91 @@ async function main() {
assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no."));
assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address"));
+ // ----------------------------------------------------------------
+ // Delivery date follows latest fulfillment, not processedAt
+ // ----------------------------------------------------------------
+ console.log("• Delivery date is taken from latest fulfillment");
+ // The AT B2B fixture has processedAt 2026-04-15 and fulfillment.createdAt
+ // 2026-05-13 — the composer must pick the fulfillment.
+ assertEq(
+ "vm.deliveryDate matches fulfillment.createdAt",
+ vm.deliveryDate.toISOString().slice(0, 10),
+ "2026-05-13",
+ );
+ // EU/Export variants have no fulfillments, so delivery date == invoice date.
+ assertEq(
+ "EU vm.deliveryDate falls back to invoiceDate when unfulfilled",
+ euVm.deliveryDate.toISOString().slice(0, 10),
+ euVm.invoiceDate.toISOString().slice(0, 10),
+ );
+
+ // ----------------------------------------------------------------
+ // Discount: per-line strikethrough + cart code
+ // ----------------------------------------------------------------
+ console.log("• Discount (per-line + cart-level)");
+ const discOrder = buildDiscountedOrder();
+ const discVm = composeInvoice({
+ order: discOrder,
+ settings: settings as never,
+ invoiceNumber: "RE-1020",
+ });
+ assertEq("discountCodes propagated", discVm.discountCodes.join(","), "SUMMER10");
+ assertNear("discounted unit net (~4.99)", discVm.lines[0].unitPriceNet, 4.99);
+ assert(
+ "originalUnitPriceNet populated when discounted differs",
+ discVm.lines[0].originalUnitPriceNet != null,
+ );
+ assertNear(
+ "originalUnitPriceNet matches pre-discount net (~5.99)",
+ discVm.lines[0].originalUnitPriceNet ?? 0,
+ 5.99,
+ );
+ discVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
+ const discPdf = await renderInvoicePdf(discVm);
+ const discText = await pdfToText(discPdf);
+ assert("DE PDF shows discount-code label", discText.includes("Rabattcode"));
+ assert("DE PDF shows discount code value", discText.includes("SUMMER10"));
+ const discEnVm = composeInvoice({
+ order: discOrder,
+ settings: settings as never,
+ invoiceNumber: "RE-1021",
+ forceLanguage: "en",
+ });
+ discEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
+ const discEnText = await pdfToText(await renderInvoicePdf(discEnVm));
+ assert("EN PDF shows discount-code label", discEnText.includes("Discount code"));
+
+ // ----------------------------------------------------------------
+ // Pickup: separate shipping address suppressed; method localized
+ // ----------------------------------------------------------------
+ console.log("• Local pickup");
+ const pickupOrder = buildPickupOrder();
+ const pickupVm = composeInvoice({
+ order: pickupOrder,
+ settings: settings as never,
+ invoiceNumber: "RE-1030",
+ });
+ assert("isPickup detected", pickupVm.isPickup);
+ assertEq("shippingMethod replaced with localized label", pickupVm.shippingMethod, "Abholung");
+ assert(
+ "separateShippingAddress suppressed for pickup",
+ pickupVm.separateShippingAddress == null,
+ );
+ const pickupEnVm = composeInvoice({
+ order: pickupOrder,
+ settings: settings as never,
+ invoiceNumber: "RE-1031",
+ forceLanguage: "en",
+ });
+ assertEq("pickup label EN", pickupEnVm.shippingMethod, "Pick-up");
+ pickupVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
+ const pickupText = await pdfToText(await renderInvoicePdf(pickupVm));
+ assert("DE pickup PDF shows 'Abholung'", pickupText.includes("Abholung"));
+ assert(
+ "DE pickup PDF does NOT render pickup-location address as delivery address",
+ !pickupText.includes("Lieferadresse"),
+ );
+
// Fallback: when footerNoteEn is empty, English uses the German note.
console.log("• Footer note fallback (en → de when EN empty)");
const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;