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:
@@ -62,12 +62,18 @@ export function composeInvoice({
|
|||||||
});
|
});
|
||||||
let notices = deriveNotices({ order, settings, isB2B });
|
let notices = deriveNotices({ order, settings, isB2B });
|
||||||
|
|
||||||
const separateShippingAddress = mapSeparateShippingAddress(order);
|
const isPickup = detectPickup(order.shippingLine);
|
||||||
const shippingMethod = order.shippingLine?.title?.trim() || undefined;
|
const separateShippingAddress = isPickup ? undefined : mapSeparateShippingAddress(order);
|
||||||
|
const shippingMethod = isPickup
|
||||||
|
? strings.pickupLabel
|
||||||
|
: order.shippingLine?.title?.trim() || undefined;
|
||||||
const tracking = mapTracking(order);
|
const tracking = mapTracking(order);
|
||||||
|
|
||||||
const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt);
|
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
|
// For offers we treat `dueDate` as the offer's validity expiry (default 30
|
||||||
// days from issue). The PDF renderer renders a different label.
|
// days from issue). The PDF renderer renders a different label.
|
||||||
const dueDate = offer
|
const dueDate = offer
|
||||||
@@ -86,6 +92,8 @@ export function composeInvoice({
|
|||||||
lines = lines.map((l) => ({
|
lines = lines.map((l) => ({
|
||||||
...l,
|
...l,
|
||||||
unitPriceNet: -l.unitPriceNet,
|
unitPriceNet: -l.unitPriceNet,
|
||||||
|
originalUnitPriceNet:
|
||||||
|
l.originalUnitPriceNet != null ? -l.originalUnitPriceNet : undefined,
|
||||||
totalNet: -l.totalNet,
|
totalNet: -l.totalNet,
|
||||||
}));
|
}));
|
||||||
totals = {
|
totals = {
|
||||||
@@ -125,6 +133,8 @@ export function composeInvoice({
|
|||||||
separateShippingAddress,
|
separateShippingAddress,
|
||||||
shippingMethod,
|
shippingMethod,
|
||||||
tracking,
|
tracking,
|
||||||
|
discountCodes: order.discountCodes ?? [],
|
||||||
|
isPickup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +208,13 @@ function mapLinesAndTotals(
|
|||||||
|
|
||||||
order.lineItems.forEach((li, idx) => {
|
order.lineItems.forEach((li, idx) => {
|
||||||
const qty = li.quantity;
|
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.
|
// Total tax for this line summed across its tax lines.
|
||||||
const lineTax = li.taxLines.reduce(
|
const lineTax = li.taxLines.reduce(
|
||||||
(acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount),
|
(acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount),
|
||||||
@@ -208,6 +224,17 @@ function mapLinesAndTotals(
|
|||||||
const lineGross = grossOrNetUnit * qty + (taxesIncluded ? 0 : lineTax);
|
const lineGross = grossOrNetUnit * qty + (taxesIncluded ? 0 : lineTax);
|
||||||
const lineNet = taxesIncluded ? grossOrNetUnit * qty - lineTax : grossOrNetUnit * qty;
|
const lineNet = taxesIncluded ? grossOrNetUnit * qty - lineTax : grossOrNetUnit * qty;
|
||||||
const unitNet = qty > 0 ? lineNet / qty : 0;
|
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({
|
linesOut.push({
|
||||||
position: idx + 1,
|
position: idx + 1,
|
||||||
@@ -215,6 +242,7 @@ function mapLinesAndTotals(
|
|||||||
sku: li.sku ?? undefined,
|
sku: li.sku ?? undefined,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unitPriceNet: round2(unitNet),
|
unitPriceNet: round2(unitNet),
|
||||||
|
originalUnitPriceNet: hasDiscount ? round2(originalUnitNet) : undefined,
|
||||||
totalNet: round2(lineNet),
|
totalNet: round2(lineNet),
|
||||||
imageUrl: li.imageUrl ?? undefined,
|
imageUrl: li.imageUrl ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -452,3 +480,37 @@ function mapTracking(order: RawOrderForInvoice): TrackingInfo[] {
|
|||||||
}
|
}
|
||||||
return out;
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export interface InvoiceStrings {
|
|||||||
shippingMethodLabel: string;
|
shippingMethodLabel: string;
|
||||||
trackingLabel: string;
|
trackingLabel: string;
|
||||||
shippingItemPrefix: string;
|
shippingItemPrefix: string;
|
||||||
|
discountCodeLabel: string;
|
||||||
|
pickupLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Status displayed for the order's payment, derived from Shopify's
|
/** Status displayed for the order's payment, derived from Shopify's
|
||||||
@@ -163,6 +165,8 @@ const de: InvoiceStrings = {
|
|||||||
shippingMethodLabel: "Versandart",
|
shippingMethodLabel: "Versandart",
|
||||||
trackingLabel: "Sendungsnummer",
|
trackingLabel: "Sendungsnummer",
|
||||||
shippingItemPrefix: "Versand",
|
shippingItemPrefix: "Versand",
|
||||||
|
discountCodeLabel: "Rabattcode",
|
||||||
|
pickupLabel: "Abholung",
|
||||||
};
|
};
|
||||||
|
|
||||||
const en: InvoiceStrings = {
|
const en: InvoiceStrings = {
|
||||||
@@ -230,6 +234,8 @@ const en: InvoiceStrings = {
|
|||||||
shippingMethodLabel: "Shipping method",
|
shippingMethodLabel: "Shipping method",
|
||||||
trackingLabel: "Tracking no.",
|
trackingLabel: "Tracking no.",
|
||||||
shippingItemPrefix: "Shipping",
|
shippingItemPrefix: "Shipping",
|
||||||
|
discountCodeLabel: "Discount code",
|
||||||
|
pickupLabel: "Pick-up",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Locale → invoice language. We only render in German (`de`) when the
|
// Locale → invoice language. We only render in German (`de`) when the
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export async function loadDraftOrderForOffer(
|
|||||||
paymentGatewayNames: [],
|
paymentGatewayNames: [],
|
||||||
shippingLine: null,
|
shippingLine: null,
|
||||||
fulfillments: [],
|
fulfillments: [],
|
||||||
|
discountCodes: [],
|
||||||
taxesIncluded: draft.taxesIncluded,
|
taxesIncluded: draft.taxesIncluded,
|
||||||
customer: draft.customer,
|
customer: draft.customer,
|
||||||
billingAddress: draft.billingAddress,
|
billingAddress: draft.billingAddress,
|
||||||
@@ -178,6 +179,7 @@ export async function loadDraftOrderForOffer(
|
|||||||
sku: node.sku,
|
sku: node.sku,
|
||||||
quantity: node.quantity,
|
quantity: node.quantity,
|
||||||
originalUnitPriceSet: node.originalUnitPriceSet,
|
originalUnitPriceSet: node.originalUnitPriceSet,
|
||||||
|
discountedUnitPriceSet: null,
|
||||||
taxLines: node.taxLines,
|
taxLines: node.taxLines,
|
||||||
imageUrl: node.image?.url ?? null,
|
imageUrl: node.image?.url ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export interface RawOrderForInvoice {
|
|||||||
taxLines: RawTaxLine[];
|
taxLines: RawTaxLine[];
|
||||||
shippingLine: RawShippingLine | null;
|
shippingLine: RawShippingLine | null;
|
||||||
fulfillments: RawFulfillment[];
|
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;
|
taxesIncluded: boolean;
|
||||||
subtotalSet: { shopMoney: RawMoney } | null;
|
subtotalSet: { shopMoney: RawMoney } | null;
|
||||||
totalTaxSet: { shopMoney: RawMoney } | null;
|
totalTaxSet: { shopMoney: RawMoney } | null;
|
||||||
@@ -59,6 +63,10 @@ export interface RawLineItem {
|
|||||||
sku: string | null;
|
sku: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
originalUnitPriceSet: { shopMoney: RawMoney };
|
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[];
|
taxLines: RawTaxLine[];
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
}
|
}
|
||||||
@@ -87,6 +95,10 @@ export interface RawTrackingInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RawFulfillment {
|
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[];
|
trackingInfo: RawTrackingInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +149,8 @@ const QUERY = `#graphql
|
|||||||
ratePercentage
|
ratePercentage
|
||||||
priceSet { shopMoney { amount currencyCode } }
|
priceSet { shopMoney { amount currencyCode } }
|
||||||
}
|
}
|
||||||
|
discountCode
|
||||||
|
discountCodes
|
||||||
shippingLine {
|
shippingLine {
|
||||||
title
|
title
|
||||||
code
|
code
|
||||||
@@ -152,6 +166,7 @@ const QUERY = `#graphql
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fulfillments(first: 10) {
|
fulfillments(first: 10) {
|
||||||
|
createdAt
|
||||||
trackingInfo {
|
trackingInfo {
|
||||||
number
|
number
|
||||||
url
|
url
|
||||||
@@ -165,6 +180,7 @@ const QUERY = `#graphql
|
|||||||
sku
|
sku
|
||||||
quantity
|
quantity
|
||||||
originalUnitPriceSet { shopMoney { amount currencyCode } }
|
originalUnitPriceSet { shopMoney { amount currencyCode } }
|
||||||
|
discountedUnitPriceSet { shopMoney { amount currencyCode } }
|
||||||
image { url altText }
|
image { url altText }
|
||||||
taxLines {
|
taxLines {
|
||||||
title
|
title
|
||||||
@@ -220,6 +236,8 @@ interface RawAdminResponse {
|
|||||||
totalTaxSet: { shopMoney: RawMoney } | null;
|
totalTaxSet: { shopMoney: RawMoney } | null;
|
||||||
totalPriceSet: { shopMoney: RawMoney } | null;
|
totalPriceSet: { shopMoney: RawMoney } | null;
|
||||||
taxLines: RawTaxLine[];
|
taxLines: RawTaxLine[];
|
||||||
|
discountCode: string | null;
|
||||||
|
discountCodes: string[] | null;
|
||||||
shippingLine: RawShippingLine | null;
|
shippingLine: RawShippingLine | null;
|
||||||
fulfillments: RawFulfillment[] | null;
|
fulfillments: RawFulfillment[] | null;
|
||||||
lineItems: { edges: { node: RawLineItem }[] };
|
lineItems: { edges: { node: RawLineItem }[] };
|
||||||
@@ -270,6 +288,9 @@ export async function loadOrderForInvoice(
|
|||||||
totalTaxSet: order.totalTaxSet,
|
totalTaxSet: order.totalTaxSet,
|
||||||
totalPriceSet: order.totalPriceSet,
|
totalPriceSet: order.totalPriceSet,
|
||||||
taxLines: order.taxLines || [],
|
taxLines: order.taxLines || [],
|
||||||
|
discountCodes: order.discountCodes && order.discountCodes.length > 0
|
||||||
|
? order.discountCodes
|
||||||
|
: (order.discountCode ? [order.discountCode] : []),
|
||||||
shippingLine: order.shippingLine ?? null,
|
shippingLine: order.shippingLine ?? null,
|
||||||
fulfillments: order.fulfillments ?? [],
|
fulfillments: order.fulfillments ?? [],
|
||||||
lineItems: (order.lineItems?.edges || []).map((e) => {
|
lineItems: (order.lineItems?.edges || []).map((e) => {
|
||||||
@@ -279,6 +300,7 @@ export async function loadOrderForInvoice(
|
|||||||
sku: node.sku,
|
sku: node.sku,
|
||||||
quantity: node.quantity,
|
quantity: node.quantity,
|
||||||
originalUnitPriceSet: node.originalUnitPriceSet,
|
originalUnitPriceSet: node.originalUnitPriceSet,
|
||||||
|
discountedUnitPriceSet: node.discountedUnitPriceSet ?? null,
|
||||||
taxLines: node.taxLines,
|
taxLines: node.taxLines,
|
||||||
imageUrl: node.image?.url ?? null,
|
imageUrl: node.image?.url ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ const styles = StyleSheet.create({
|
|||||||
recipientBlock: {
|
recipientBlock: {
|
||||||
width: "55%",
|
width: "55%",
|
||||||
},
|
},
|
||||||
|
recipientBlockFull: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
recipientName: {
|
recipientName: {
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -69,7 +74,10 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
metaBlock: {
|
metaBlock: {
|
||||||
width: "40%",
|
width: "45%",
|
||||||
|
},
|
||||||
|
metaBlockHeader: {
|
||||||
|
width: "50%",
|
||||||
},
|
},
|
||||||
metaTable: {
|
metaTable: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -85,6 +93,11 @@ const styles = StyleSheet.create({
|
|||||||
metaValue: {
|
metaValue: {
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
},
|
},
|
||||||
|
unitOriginalStrike: {
|
||||||
|
color: TEXT_MUTED,
|
||||||
|
textDecoration: "line-through",
|
||||||
|
fontSize: 7,
|
||||||
|
},
|
||||||
invoiceNumberBig: {
|
invoiceNumberBig: {
|
||||||
color: BRAND_BLUE,
|
color: BRAND_BLUE,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
@@ -270,31 +283,14 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Header issuer={invoice.issuer} />
|
|
||||||
|
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<View style={styles.recipientBlock}>
|
{invoice.issuer.logoDataUrl ? (
|
||||||
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
|
<Image src={invoice.issuer.logoDataUrl} style={styles.logo} />
|
||||||
<Recipient recipient={invoice.recipient} />
|
) : (
|
||||||
{invoice.separateShippingAddress ? (
|
<View />
|
||||||
<View style={styles.shippingAddressBlock}>
|
)}
|
||||||
<Text style={styles.shippingAddressHeading}>{t.shippingAddressHeading}</Text>
|
<View style={styles.metaBlockHeader}>
|
||||||
<Recipient recipient={invoice.separateShippingAddress} />
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<View style={styles.metaBlock}>
|
|
||||||
<View style={styles.metaTable}>
|
<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}>
|
<View style={styles.metaRow}>
|
||||||
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerDate : 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>
|
||||||
@@ -327,6 +323,12 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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 ? (
|
{invoice.kind === "invoice" && invoice.shippingMethod ? (
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
|
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
|
||||||
@@ -350,6 +352,19 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
|||||||
</View>
|
</View>
|
||||||
</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}>
|
<Text style={styles.title}>
|
||||||
{invoice.kind === "storno"
|
{invoice.kind === "storno"
|
||||||
? t.stornoInvoice
|
? t.stornoInvoice
|
||||||
@@ -357,6 +372,9 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
|||||||
? t.offer
|
? t.offer
|
||||||
: t.invoice}{" "}
|
: t.invoice}{" "}
|
||||||
Nr. {invoice.number}
|
Nr. {invoice.number}
|
||||||
|
{invoice.kind === "invoice" && invoice.orderName
|
||||||
|
? ` · ${t.orderNumberLabel}: ${invoice.orderName}`
|
||||||
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={styles.paragraph}>{t.salutationGeneric}</Text>
|
<Text style={styles.paragraph}>{t.salutationGeneric}</Text>
|
||||||
@@ -474,14 +492,12 @@ function senderInline(issuer: IssuerData): string {
|
|||||||
.join(" - ");
|
.join(" - ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function Header({ issuer }: { issuer: IssuerData }) {
|
function Header(_args: { issuer: IssuerData }) {
|
||||||
return (
|
// Deprecated: header rendering is now inlined in InvoiceDocument so the
|
||||||
<View style={styles.headerRow}>
|
// logo and meta block can share a single row at the top of the page.
|
||||||
<View>{/* spacer; logo is right-aligned */}</View>
|
return null;
|
||||||
{issuer.logoDataUrl ? <Image src={issuer.logoDataUrl} style={styles.logo} /> : <View />}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
void Header;
|
||||||
|
|
||||||
function Recipient({ recipient }: { recipient: RecipientData }) {
|
function Recipient({ recipient }: { recipient: RecipientData }) {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
@@ -529,7 +545,14 @@ function LineRow({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.colQty}>{formatQuantity(line.quantity, t.pieceUnit, language)}</Text>
|
<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>
|
<Text style={styles.colTotal}>{formatMoney(line.totalNet, currency, language)}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ export interface InvoiceViewModel {
|
|||||||
/** Tracking entries collected from order fulfillments. Empty when the
|
/** Tracking entries collected from order fulfillments. Empty when the
|
||||||
* order is unfulfilled or has no tracking. */
|
* order is unfulfilled or has no tracking. */
|
||||||
tracking: TrackingInfo[];
|
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 {
|
export interface TrackingInfo {
|
||||||
@@ -110,8 +118,13 @@ export interface InvoiceLine {
|
|||||||
title: string;
|
title: string;
|
||||||
/** Raw quantity (e.g. 6). */
|
/** Raw quantity (e.g. 6). */
|
||||||
quantity: number;
|
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;
|
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. */
|
/** Net total = quantity * unitPriceNet. */
|
||||||
totalNet: number;
|
totalNet: number;
|
||||||
/** Optional SKU for display under the title. */
|
/** Optional SKU for display under the title. */
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
|||||||
displayFinancialStatus: "PENDING",
|
displayFinancialStatus: "PENDING",
|
||||||
paymentGatewayNames: ["manual"],
|
paymentGatewayNames: ["manual"],
|
||||||
taxesIncluded: false,
|
taxesIncluded: false,
|
||||||
|
discountCodes: [],
|
||||||
customer: {
|
customer: {
|
||||||
firstName: "Lukas",
|
firstName: "Lukas",
|
||||||
lastName: "Schmidhofer",
|
lastName: "Schmidhofer",
|
||||||
@@ -180,6 +181,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
|||||||
},
|
},
|
||||||
fulfillments: [
|
fulfillments: [
|
||||||
{
|
{
|
||||||
|
createdAt: "2026-05-13T10:30:00.000Z",
|
||||||
trackingInfo: [
|
trackingInfo: [
|
||||||
{ number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" },
|
{ number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" },
|
||||||
],
|
],
|
||||||
@@ -191,6 +193,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
|||||||
sku: "BT-TRK-001",
|
sku: "BT-TRK-001",
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
originalUnitPriceSet: { shopMoney: { amount: unitNet.toFixed(2), currencyCode: "EUR" } },
|
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.
|
imageUrl: "file://product-image", // placeholder; the smoke script inlines a real data: URL on the composed line below.
|
||||||
taxLines: [
|
taxLines: [
|
||||||
{
|
{
|
||||||
@@ -262,6 +265,50 @@ function buildExportOrder(): RawOrderForInvoice {
|
|||||||
return o;
|
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
|
// 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 tracking row 'Tracking no.'", enText.includes("Tracking no."));
|
||||||
assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address"));
|
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.
|
// Fallback: when footerNoteEn is empty, English uses the German note.
|
||||||
console.log("• Footer note fallback (en → de when EN empty)");
|
console.log("• Footer note fallback (en → de when EN empty)");
|
||||||
const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;
|
const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;
|
||||||
|
|||||||
Reference in New Issue
Block a user