feat(invoice): per-line + cart discounts, fulfillment delivery date, pickup label, header layout refresh

- discounts: read discountedUnitPriceSet (per-line) and discountCode/discountCodes
  (order-level) from Shopify; render discounted unit price with strikethrough
  original on the invoice line and add a 'Rabattcode'/'Discount code' meta row
  when codes were used.
- delivery date: pick the latest fulfillment.createdAt for §11 UStG instead of
  hard-coding processedAt; fall back to invoice date when unfulfilled.
- pickup: detect Shopify Local Pickup (and 'Abholung'/'Pickup' custom rates) via
  shippingLine.source/code/title; suppress the pickup-location 'shipping address'
  block and render localized 'Abholung'/'Pick-up' as the shipping method.
- layout: move the company logo to the top-left and the meta block to the
  top-right, putting recipient (and any separate delivery address) on its own
  row below; drop the standalone invoice-/order-number meta rows and surface
  them inside the title (e.g. 'Rechnung Nr. RE-1004 · Bestellnummer: #1004') to
  recover vertical space.
- tests: smoke fixtures cover discount, pickup, and fulfillment-date variants
  without disturbing the AT B2B totals.
This commit is contained in:
Gerhard Scheikl
2026-05-15 13:59:08 +02:00
parent 8780b4a68a
commit 415a9dd462
7 changed files with 297 additions and 37 deletions
+66 -4
View File
@@ -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));
}
+6
View File
@@ -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
@@ -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,
};
@@ -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,
};
+55 -32
View File
@@ -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) {
</Text>
)}
<Header issuer={invoice.issuer} />
<View style={styles.headerRow}>
<View style={styles.recipientBlock}>
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
<Recipient recipient={invoice.recipient} />
{invoice.separateShippingAddress ? (
<View style={styles.shippingAddressBlock}>
<Text style={styles.shippingAddressHeading}>{t.shippingAddressHeading}</Text>
<Recipient recipient={invoice.separateShippingAddress} />
</View>
) : null}
</View>
<View style={styles.metaBlock}>
{invoice.issuer.logoDataUrl ? (
<Image src={invoice.issuer.logoDataUrl} style={styles.logo} />
) : (
<View />
)}
<View style={styles.metaBlockHeader}>
<View style={styles.metaTable}>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerNumber : t.invoiceNumber}</Text>
<Text style={styles.invoiceNumberBig}>{invoice.number}</Text>
</View>
{invoice.kind === "invoice" && invoice.orderName ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.orderNumberLabel}</Text>
<Text style={styles.metaValue}>{invoice.orderName}</Text>
</View>
) : null}
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}</Text>
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
@@ -327,6 +323,12 @@ export function InvoiceDocument({ invoice }: DocProps) {
</Text>
</View>
)}
{invoice.kind === "invoice" && invoice.discountCodes.length > 0 ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.discountCodeLabel}</Text>
<Text style={styles.metaValue}>{invoice.discountCodes.join(", ")}</Text>
</View>
) : null}
{invoice.kind === "invoice" && invoice.shippingMethod ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
@@ -350,6 +352,19 @@ export function InvoiceDocument({ invoice }: DocProps) {
</View>
</View>
<View style={styles.recipientBlockFull}>
<View style={styles.recipientBlock}>
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
<Recipient recipient={invoice.recipient} />
</View>
{invoice.separateShippingAddress ? (
<View style={styles.recipientBlock}>
<Text style={styles.shippingAddressHeading}>{t.shippingAddressHeading}</Text>
<Recipient recipient={invoice.separateShippingAddress} />
</View>
) : null}
</View>
<Text style={styles.title}>
{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}`
: ""}
</Text>
<Text style={styles.paragraph}>{t.salutationGeneric}</Text>
@@ -474,14 +492,12 @@ function senderInline(issuer: IssuerData): string {
.join(" - ");
}
function Header({ issuer }: { issuer: IssuerData }) {
return (
<View style={styles.headerRow}>
<View>{/* spacer; logo is right-aligned */}</View>
{issuer.logoDataUrl ? <Image src={issuer.logoDataUrl} style={styles.logo} /> : <View />}
</View>
);
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({
</View>
</View>
<Text style={styles.colQty}>{formatQuantity(line.quantity, t.pieceUnit, language)}</Text>
<Text style={styles.colUnit}>{formatMoney(line.unitPriceNet, currency, language)}</Text>
<View style={styles.colUnit}>
{line.originalUnitPriceNet != null ? (
<Text style={styles.unitOriginalStrike}>
{formatMoney(line.originalUnitPriceNet, currency, language)}
</Text>
) : null}
<Text>{formatMoney(line.unitPriceNet, currency, language)}</Text>
</View>
<Text style={styles.colTotal}>{formatMoney(line.totalNet, currency, language)}</Text>
</View>
);
+14 -1
View File
@@ -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. */