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;