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 });
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user