diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts index 939b5eb..65f77ed 100644 --- a/app/services/invoice/composeInvoice.ts +++ b/app/services/invoice/composeInvoice.ts @@ -1,6 +1,6 @@ import type { ShopSettings } from "@prisma/client"; -import type { RawOrderForInvoice, RawTaxLine } from "./loadOrderForInvoice.server"; +import type { RawOrderForInvoice, RawShippingLine, RawTaxLine } from "./loadOrderForInvoice.server"; import type { InvoiceLine, InvoiceNotice, @@ -8,10 +8,11 @@ import type { InvoiceViewModel, IssuerData, RecipientData, + TrackingInfo, VatBreakdownEntry, } from "./types"; import { addDays } from "./format"; -import { derivePaymentStatus, pickLanguage, type InvoiceLanguage } from "./i18n"; +import { derivePaymentStatus, getStrings, pickLanguage, type InvoiceLanguage } from "./i18n"; interface ComposeArgs { order: RawOrderForInvoice; @@ -55,9 +56,16 @@ export function composeInvoice({ const isB2B = !!order.purchasingEntity?.company; const recipientVatId = order.purchasingEntity?.company?.vatId ?? undefined; - let { lines, totals } = mapLinesAndTotals(order); + const strings = getStrings(language); + let { lines, totals } = mapLinesAndTotals(order, { + shippingItemPrefix: strings.shippingItemPrefix, + }); let notices = deriveNotices({ order, settings, isB2B }); + const separateShippingAddress = mapSeparateShippingAddress(order); + const shippingMethod = order.shippingLine?.title?.trim() || undefined; + const tracking = mapTracking(order); + const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt); const deliveryDate = invoiceDate; // For offers we treat `dueDate` as the offer's validity expiry (default 30 @@ -113,6 +121,10 @@ export function composeInvoice({ paid, paymentStatus, paymentGatewayNames, + orderName: order.name, + separateShippingAddress, + shippingMethod, + tracking, }; } @@ -172,7 +184,10 @@ function mapRecipient(order: RawOrderForInvoice): RecipientData { }; } -function mapLinesAndTotals(order: RawOrderForInvoice): { +function mapLinesAndTotals( + order: RawOrderForInvoice, + opts: { shippingItemPrefix: string }, +): { lines: InvoiceLine[]; totals: InvoiceTotals; } { @@ -224,6 +239,18 @@ function mapLinesAndTotals(order: RawOrderForInvoice): { }); } + // Append the shipping line as a synthetic invoice row when the order has + // a shipping cost > 0. This makes shipping appear in the items table + // (visible to the customer) and folds its tax into the VAT breakdown. + const shippingLineNet = appendShippingLine( + order.shippingLine, + taxesIncluded, + linesOut, + vatMap, + opts.shippingItemPrefix, + ); + netSum += shippingLineNet; + const vatBreakdown = Array.from(vatMap.values()) .map((e) => ({ ratePct: e.ratePct, net: round2(e.net), tax: round2(e.tax) })) .filter((e) => e.tax > 0) @@ -318,3 +345,110 @@ const EU_COUNTRIES = new Set([ function isEuCountry(code: string): boolean { return EU_COUNTRIES.has(code.toUpperCase()); } + +/** + * Add a synthetic line item for the order's shipping cost. Returns the net + * amount added (used to keep the running net subtotal in sync). Returns 0 + * when there's no shipping line or the shipping price is zero (e.g. free + * shipping or digital orders). + */ +function appendShippingLine( + shippingLine: RawShippingLine | null, + taxesIncluded: boolean, + linesOut: InvoiceLine[], + vatMap: Map, + prefix: string, +): number { + if (!shippingLine) return 0; + const priceSet = shippingLine.discountedPriceSet ?? shippingLine.originalPriceSet; + const grossOrNet = priceSet ? parseFloat(priceSet.shopMoney.amount) : 0; + if (!Number.isFinite(grossOrNet) || grossOrNet === 0) return 0; + + const tax = shippingLine.taxLines.reduce( + (acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount), + 0, + ); + const net = taxesIncluded ? grossOrNet - tax : grossOrNet; + + const title = shippingLine.title?.trim() + ? `${prefix}: ${shippingLine.title.trim()}` + : prefix; + + linesOut.push({ + position: linesOut.length + 1, + title, + quantity: 1, + unitPriceNet: round2(net), + totalNet: round2(net), + }); + + shippingLine.taxLines.forEach((t) => + accumulateVat(vatMap, t, parseFloat(t.priceSet.shopMoney.amount), net), + ); + + return net; +} + +/** + * Returns the shipping address as a recipient block when it differs in any + * meaningful way from the billing address. Returns undefined when both are + * the same (so the renderer doesn't show a redundant block) or when there + * is no shipping address at all. + */ +function mapSeparateShippingAddress( + order: RawOrderForInvoice, +): RecipientData | undefined { + const ship = order.shippingAddress; + const bill = order.billingAddress; + if (!ship) return undefined; + // No billing address → just use the existing recipient block, no need to + // duplicate. + if (!bill) return undefined; + + const sameAddress = + (ship.name ?? "") === (bill.name ?? "") && + (ship.company ?? "") === (bill.company ?? "") && + (ship.address1 ?? "") === (bill.address1 ?? "") && + (ship.address2 ?? "") === (bill.address2 ?? "") && + (ship.zip ?? "") === (bill.zip ?? "") && + (ship.city ?? "") === (bill.city ?? "") && + (ship.countryCode ?? "") === (bill.countryCode ?? ""); + if (sameAddress) return undefined; + + const customerFullName = [order.customer?.firstName, order.customer?.lastName] + .filter(Boolean) + .join(" ") + .trim(); + + return { + name: ship.name ?? customerFullName, + company: ship.company ?? "", + addressLine1: ship.address1 ?? "", + addressLine2: ship.address2 ?? "", + postalCode: ship.zip ?? "", + city: ship.city ?? "", + countryCode: ship.countryCode ?? "", + }; +} + +/** + * Flatten tracking info from all fulfillments. Skips entries without a + * tracking number. Deduplicates on `number`. + */ +function mapTracking(order: RawOrderForInvoice): TrackingInfo[] { + const out: TrackingInfo[] = []; + const seen = new Set(); + for (const f of order.fulfillments ?? []) { + for (const t of f.trackingInfo ?? []) { + const number = (t.number ?? "").trim(); + if (!number || seen.has(number)) continue; + seen.add(number); + out.push({ + number, + url: t.url?.trim() || undefined, + company: t.company?.trim() || undefined, + }); + } + } + return out; +} diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts index f601cbb..473a416 100644 --- a/app/services/invoice/i18n.ts +++ b/app/services/invoice/i18n.ts @@ -58,6 +58,11 @@ export interface InvoiceStrings { paymentStatusUnpaid: string; paymentStatusPartial: string; paymentStatusRefunded: string; + orderNumberLabel: string; + shippingAddressHeading: string; + shippingMethodLabel: string; + trackingLabel: string; + shippingItemPrefix: string; } /** Status displayed for the order's payment, derived from Shopify's @@ -153,6 +158,11 @@ const de: InvoiceStrings = { paymentStatusUnpaid: "Offen", paymentStatusPartial: "Teilweise bezahlt", paymentStatusRefunded: "Erstattet", + orderNumberLabel: "Bestellnummer", + shippingAddressHeading: "Lieferadresse", + shippingMethodLabel: "Versandart", + trackingLabel: "Sendungsnummer", + shippingItemPrefix: "Versand", }; const en: InvoiceStrings = { @@ -215,6 +225,11 @@ const en: InvoiceStrings = { paymentStatusUnpaid: "Outstanding", paymentStatusPartial: "Partially paid", paymentStatusRefunded: "Refunded", + orderNumberLabel: "Order no.", + shippingAddressHeading: "Shipping address", + shippingMethodLabel: "Shipping method", + trackingLabel: "Tracking no.", + shippingItemPrefix: "Shipping", }; // 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 b460b0f..882db6b 100644 --- a/app/services/invoice/loadDraftOrderForOffer.server.ts +++ b/app/services/invoice/loadDraftOrderForOffer.server.ts @@ -161,6 +161,8 @@ export async function loadDraftOrderForOffer( currencyCode: draft.currencyCode, displayFinancialStatus: null, paymentGatewayNames: [], + shippingLine: null, + fulfillments: [], taxesIncluded: draft.taxesIncluded, customer: draft.customer, billingAddress: draft.billingAddress, diff --git a/app/services/invoice/loadOrderForInvoice.server.ts b/app/services/invoice/loadOrderForInvoice.server.ts index 0039b84..2958f0b 100644 --- a/app/services/invoice/loadOrderForInvoice.server.ts +++ b/app/services/invoice/loadOrderForInvoice.server.ts @@ -23,6 +23,8 @@ export interface RawOrderForInvoice { shippingAddress: RawAddress | null; lineItems: RawLineItem[]; taxLines: RawTaxLine[]; + shippingLine: RawShippingLine | null; + fulfillments: RawFulfillment[]; taxesIncluded: boolean; subtotalSet: { shopMoney: RawMoney } | null; totalTaxSet: { shopMoney: RawMoney } | null; @@ -68,6 +70,26 @@ export interface RawTaxLine { priceSet: { shopMoney: RawMoney }; } +export interface RawShippingLine { + title: string | null; + code: string | null; + source: string | null; + carrierIdentifier: string | null; + originalPriceSet: { shopMoney: RawMoney } | null; + discountedPriceSet: { shopMoney: RawMoney } | null; + taxLines: RawTaxLine[]; +} + +export interface RawTrackingInfo { + number: string | null; + url: string | null; + company: string | null; +} + +export interface RawFulfillment { + trackingInfo: RawTrackingInfo[]; +} + const QUERY = `#graphql query OrderForInvoice($id: ID!) { order(id: $id) { @@ -115,6 +137,27 @@ const QUERY = `#graphql ratePercentage priceSet { shopMoney { amount currencyCode } } } + shippingLine { + title + code + source + carrierIdentifier + originalPriceSet { shopMoney { amount currencyCode } } + discountedPriceSet { shopMoney { amount currencyCode } } + taxLines { + title + rate + ratePercentage + priceSet { shopMoney { amount currencyCode } } + } + } + fulfillments(first: 10) { + trackingInfo { + number + url + company + } + } lineItems(first: 250) { edges { node { @@ -177,6 +220,8 @@ interface RawAdminResponse { totalTaxSet: { shopMoney: RawMoney } | null; totalPriceSet: { shopMoney: RawMoney } | null; taxLines: RawTaxLine[]; + shippingLine: RawShippingLine | null; + fulfillments: RawFulfillment[] | null; lineItems: { edges: { node: RawLineItem }[] }; purchasingEntity: { company?: { name: string } | null; @@ -225,6 +270,8 @@ export async function loadOrderForInvoice( totalTaxSet: order.totalTaxSet, totalPriceSet: order.totalPriceSet, taxLines: order.taxLines || [], + shippingLine: order.shippingLine ?? null, + fulfillments: order.fulfillments ?? [], lineItems: (order.lineItems?.edges || []).map((e) => { const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null }; return { diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx index c34008b..35b3b7c 100644 --- a/app/services/invoice/pdf/InvoiceDocument.tsx +++ b/app/services/invoice/pdf/InvoiceDocument.tsx @@ -56,6 +56,18 @@ const styles = StyleSheet.create({ fontFamily: "Helvetica-Bold", fontSize: 10, }, + shippingAddressBlock: { + marginTop: 10, + paddingTop: 6, + borderTopWidth: 0.5, + borderTopColor: TABLE_BORDER, + }, + shippingAddressHeading: { + fontFamily: "Helvetica-Bold", + color: BRAND_BLUE, + fontSize: 8, + marginBottom: 2, + }, metaBlock: { width: "40%", }, @@ -264,6 +276,12 @@ export function InvoiceDocument({ invoice }: DocProps) { {senderInline(invoice.issuer)} + {invoice.separateShippingAddress ? ( + + {t.shippingAddressHeading} + + + ) : null} @@ -271,6 +289,12 @@ export function InvoiceDocument({ invoice }: DocProps) { {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)} @@ -303,6 +327,25 @@ export function InvoiceDocument({ invoice }: DocProps) { )} + {invoice.kind === "invoice" && invoice.shippingMethod ? ( + + {t.shippingMethodLabel} + {invoice.shippingMethod} + + ) : null} + {invoice.kind === "invoice" && invoice.tracking.map((tr) => ( + + + {t.trackingLabel} + {tr.company ? ` (${tr.company})` : ""} + + {tr.url ? ( + {tr.number} + ) : ( + {tr.number} + )} + + ))} diff --git a/app/services/invoice/types.ts b/app/services/invoice/types.ts index c9e884f..b65bd3e 100644 --- a/app/services/invoice/types.ts +++ b/app/services/invoice/types.ts @@ -46,6 +46,28 @@ export interface InvoiceViewModel { /** Names of the payment gateways used (e.g. ["bogus"], ["manual", * "shopify_payments"]). Empty when unknown / draft. */ paymentGatewayNames: string[]; + + /** Shopify's human-friendly order identifier (e.g. "#1004"). Distinct from + * the sequential `number` used as the invoice number. */ + orderName: string; + + /** Shipping address — only set when it differs from the billing address. + * Renderer uses this to show a separate delivery-address block. */ + separateShippingAddress?: RecipientData; + + /** Human-readable shipping method title (e.g. "Standard", "DHL Express"). + * Empty / undefined when there is no shipping line (digital orders). */ + shippingMethod?: string; + + /** Tracking entries collected from order fulfillments. Empty when the + * order is unfulfilled or has no tracking. */ + tracking: TrackingInfo[]; +} + +export interface TrackingInfo { + number: string; + url?: string; + company?: string; } export interface IssuerData { diff --git a/scripts/render-sample.ts b/scripts/render-sample.ts index 9ae9293..ab937f9 100644 --- a/scripts/render-sample.ts +++ b/scripts/render-sample.ts @@ -152,7 +152,39 @@ function buildAtB2BOrder(): RawOrderForInvoice { province: null, countryCode: "AT", }, - shippingAddress: null, + shippingAddress: { + name: "Lukas Schmidhofer", + company: "Schmidhofer Dienstleistungen", + address1: "Lagerweg 4", + address2: null, + zip: "8020", + city: "Graz", + province: null, + countryCode: "AT", + }, + shippingLine: { + title: "Standardversand", + code: "STD", + source: "shopify", + carrierIdentifier: null, + originalPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } }, + discountedPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } }, + taxLines: [ + { + title: "USt 20%", + rate: 0.2, + ratePercentage: 20, + priceSet: { shopMoney: { amount: "1.00", currencyCode: "EUR" } }, + }, + ], + }, + fulfillments: [ + { + trackingInfo: [ + { number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" }, + ], + }, + ], lineItems: [ { title: "Bluetooth Tracker", @@ -179,8 +211,8 @@ function buildAtB2BOrder(): RawOrderForInvoice { }, ], subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } }, - totalTaxSet: { shopMoney: { amount: lineTax.toFixed(2), currencyCode: "EUR" } }, - totalPriceSet: { shopMoney: { amount: lineGross.toFixed(2), currencyCode: "EUR" } }, + totalTaxSet: { shopMoney: { amount: (lineTax + 1.0).toFixed(2), currencyCode: "EUR" } }, + totalPriceSet: { shopMoney: { amount: (lineGross + 6.0).toFixed(2), currencyCode: "EUR" } }, purchasingEntity: { company: { name: "Schmidhofer Dienstleistungen", @@ -203,6 +235,10 @@ function buildEuB2BReverseChargeOrder(): RawOrderForInvoice { o.purchasingEntity!.company!.vatId = "DE123456789"; o.lineItems[0].taxLines = []; o.taxLines = []; + // No VAT for reverse-charge; clear shipping VAT too. + o.shippingLine = null; + o.fulfillments = []; + o.shippingAddress = null; o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } }; o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } }; return o; @@ -217,6 +253,9 @@ function buildExportOrder(): RawOrderForInvoice { o.billingAddress!.city = "New York"; o.lineItems[0].taxLines = []; o.taxLines = []; + o.shippingLine = null; + o.fulfillments = []; + o.shippingAddress = null; o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } }; o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } }; o.customer!.locale = "en"; @@ -265,22 +304,33 @@ async function main() { assertEq("currency", vm.currency, "EUR"); assert("isB2B detected", vm.isB2B); assertEq("recipientVatId", vm.recipientVatId, "ATU57680511"); - assertEq("line count", vm.lines.length, 1); + assertEq("line count (1 product + 1 shipping)", vm.lines.length, 2); const ln = vm.lines[0]; assertEq("line title", ln.title, "Bluetooth Tracker"); assertEq("line qty", ln.quantity, 6); assertNear("line unit net", ln.unitPriceNet, 5.99); assertNear("line total net", ln.totalNet, 35.94); - assertNear("net total", vm.totals.net, 35.94); + const shipLine = vm.lines[1]; + assert("shipping line title prefixed", shipLine.title.startsWith("Versand"), + `got "${shipLine.title}"`); + assertNear("shipping line net", shipLine.totalNet, 5.0); + assertNear("net total (incl. shipping)", vm.totals.net, 40.94); assertEq("vat breakdown rows", vm.totals.vatBreakdown.length, 1); - assertNear("vat amount", vm.totals.vatBreakdown[0].tax, 7.19); + assertNear("vat amount (incl. shipping VAT)", vm.totals.vatBreakdown[0].tax, 8.19); assertEq("vat rate %", vm.totals.vatBreakdown[0].ratePct, 20); - assertNear("gross", vm.totals.gross, 43.13); + assertNear("gross (incl. shipping)", vm.totals.gross, 49.13); assertEq("no notices for AT B2B with VAT charged", vm.notices.length, 0); assert("due date 14 days after invoice date", !!vm.dueDate && Math.round((vm.dueDate.getTime() - vm.invoiceDate.getTime()) / 86400000) === 14); assertEq("paymentGatewayNames propagated", vm.paymentGatewayNames.join(","), "manual"); assertEq("paymentStatus derived from displayFinancialStatus=PENDING", vm.paymentStatus, "unpaid"); + assertEq("orderName propagated", vm.orderName, "#1004"); + assertEq("shippingMethod propagated", vm.shippingMethod, "Standardversand"); + assertEq("tracking entries", vm.tracking.length, 1); + assertEq("tracking number", vm.tracking[0].number, "JJD0099887766"); + assertEq("tracking carrier", vm.tracking[0].company, "DHL"); + assert("separateShippingAddress detected (differs from billing)", + vm.separateShippingAddress?.addressLine1 === "Lagerweg 4"); console.log("• EU B2B reverse-charge notice"); const euOrder = buildEuB2BReverseChargeOrder(); @@ -346,15 +396,16 @@ async function main() { assertEq("kind = storno", storno.kind, "storno"); assertEq("cancelsNumber populated", storno.cancelsNumber, "RE-1004"); assert("dueDate suppressed for storno", storno.dueDate == null); - assertEq("line count preserved", storno.lines.length, 1); + assertEq("line count preserved", storno.lines.length, 2); assertNear("line qty preserved (only money negated)", storno.lines[0].quantity, 6); assertNear("line unit price negated", storno.lines[0].unitPriceNet, -5.99); assertNear("line totalNet negated", storno.lines[0].totalNet, -35.94); - assertNear("totals.net negated", storno.totals.net, -35.94); - assertNear("totals.totalVat negated", storno.totals.totalVat, -7.19); - assertNear("totals.gross negated", storno.totals.gross, -43.13); + assertNear("shipping line totalNet negated", storno.lines[1].totalNet, -5.0); + assertNear("totals.net negated", storno.totals.net, -40.94); + assertNear("totals.totalVat negated", storno.totals.totalVat, -8.19); + assertNear("totals.gross negated", storno.totals.gross, -49.13); assertEq("vat breakdown row count preserved", storno.totals.vatBreakdown.length, 1); - assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -7.19); + assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -8.19); console.log("• Render storno PDF"); storno.issuer.logoDataUrl = vm.issuer.logoDataUrl; @@ -416,6 +467,21 @@ async function main() { assert("EN PDF shows payment status row", enText.includes("Payment status")); assert("EN PDF shows payment status value 'Outstanding' for PENDING", enText.includes("Outstanding")); + // Shipment + order-number block. + assert("DE PDF shows order number row 'Bestellnummer'", deText.includes("Bestellnummer")); + assert("DE PDF shows Shopify order name '#1004'", deText.includes("#1004")); + assert("DE PDF shows shipping method row 'Versandart'", deText.includes("Versandart")); + assert("DE PDF shows shipping method value 'Standardversand'", deText.includes("Standardversand")); + assert("DE PDF shows tracking row 'Sendungsnummer'", deText.includes("Sendungsnummer")); + assert("DE PDF shows tracking number", deText.includes("JJD0099887766")); + assert("DE PDF shows shipping line item with prefix", deText.includes("Versand")); + assert("DE PDF shows separate delivery address heading", deText.includes("Lieferadresse")); + assert("DE PDF shows shipping address line", deText.includes("Lagerweg 4")); + assert("EN PDF shows order number row 'Order no.'", enText.includes("Order no.")); + assert("EN PDF shows shipping method row 'Shipping method'", enText.includes("Shipping method")); + assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no.")); + assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address")); + // 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;