import type { ShopSettings } from "@prisma/client"; import type { RawOrderForInvoice, RawShippingLine, RawTaxLine } from "./loadOrderForInvoice.server"; import type { InvoiceLine, InvoiceNotice, InvoiceTotals, InvoiceViewModel, IssuerData, RecipientData, TrackingInfo, VatBreakdownEntry, } from "./types"; import { addDays } from "./format"; import { derivePaymentStatus, getStrings, pickLanguage, type InvoiceLanguage } from "./i18n"; interface ComposeArgs { order: RawOrderForInvoice; settings: ShopSettings; invoiceNumber: string; /** Language override (e.g. for Storno copies). */ forceLanguage?: InvoiceLanguage; /** * When set, produces a Stornorechnung view model: line and total amounts * are negated, `kind` is `"storno"`, and `cancelsNumber` references the * original invoice number. Notices, GiroCode and payment-due date are * suppressed (a storno is informational, not a request for payment). */ storno?: { cancelsNumber: string }; /** Optional override for invoice/delivery date (defaults to order date). */ issueDate?: Date; /** * When true, render as an Angebot/Offer instead of an invoice: * - `kind = "offer"` * - no payment-due date (the dueDate field is repurposed by the renderer * as the offer's validity expiry). * - GiroCode and payment-terms text are suppressed. */ offer?: boolean; } export function composeInvoice({ order, settings, invoiceNumber, forceLanguage, storno, issueDate, offer, }: ComposeArgs): InvoiceViewModel { const language = forceLanguage ?? pickLanguage(order.customer?.locale ?? settings.defaultLanguage); const issuer = mapIssuer(settings); const recipient = mapRecipient(order); const isB2B = !!order.purchasingEntity?.company; const recipientVatId = order.purchasingEntity?.company?.vatId ?? undefined; const strings = getStrings(language); let { lines, totals } = mapLinesAndTotals(order, { shippingItemPrefix: strings.shippingItemPrefix, }); let notices = deriveNotices({ order, settings, isB2B }); const pickupInfo = detectPickup(order); const isPickup = pickupInfo != null; const separateShippingAddress = isPickup ? undefined : mapSeparateShippingAddress(order); // For shipping orders we surface the carrier label (e.g. "Standardversand"). // For pickup orders the meta row uses a different label entirely // ("Abholort: ") — see the renderer. const shippingMethod = isPickup ? undefined : order.shippingLine?.title?.trim() || undefined; const tracking = mapTracking(order); const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt); // §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 ? addDays(invoiceDate, 30) : !storno && settings.paymentTermDays > 0 ? addDays(invoiceDate, settings.paymentTermDays) : undefined; const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID"; // Refunded gross amount, mirrored from Shopify's `totalRefundedSet`. // Storno/offer documents don't carry a refund row — a storno *is* // already the cancellation document, and offers have no payments yet. const refundedAmount = storno || offer ? 0 : Math.max(0, parseFloat(order.totalRefundedSet?.shopMoney.amount ?? "0") || 0); let paymentStatus = derivePaymentStatus(order.displayFinancialStatus); // Reclassification: Shopify flips `displayFinancialStatus` to // PARTIALLY_REFUNDED as soon as *any* refund is posted against a // paid order, even when the customer only got back a small fraction. // For our purposes such an order is still "paid" — the merchant kept // the difference — and showing "Erstattet" / "Refunded" in the // status row would falsely imply the customer got everything back. // Only when the refund equals (or, defensively, exceeds) the gross // do we keep the "refunded" status. if ( paymentStatus === "refunded" && refundedAmount > 0 && refundedAmount < totals.gross ) { paymentStatus = "paid"; } // A document only requires payment when it's a regular invoice (not a // storno or an offer) AND money is still actually owed. Refunded and // paid orders both have a 0 outstanding balance — the difference is // just whether the money was kept (`paid`) or returned (`refunded`). const requiresPayment = !storno && !offer && paymentStatus !== "paid" && paymentStatus !== "refunded"; const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter( (n) => typeof n === "string" && n.trim().length > 0, ); if (storno) { lines = lines.map((l) => ({ ...l, unitPriceNet: -l.unitPriceNet, originalUnitPriceNet: l.originalUnitPriceNet != null ? -l.originalUnitPriceNet : undefined, totalNet: -l.totalNet, })); totals = { net: -totals.net, vatBreakdown: totals.vatBreakdown.map((v) => ({ ratePct: v.ratePct, net: -v.net, tax: -v.tax, })), totalVat: -totals.totalVat, gross: -totals.gross, }; // Notices are still relevant (e.g. reverse-charge), but the storno is not // a payment request — leave them in place for legal symmetry. } return { language, currency: order.currencyCode, kind: storno ? "storno" : offer ? "offer" : "invoice", number: invoiceNumber, cancelsNumber: storno?.cancelsNumber, invoiceDate, deliveryDate, dueDate, issuer, recipient, isB2B, recipientVatId, lines, totals, notices, paid, paymentStatus, requiresPayment, refundedAmount, paymentGatewayNames, orderName: order.name, separateShippingAddress, shippingMethod, tracking, discountCodes: order.discountCodes ?? [], isPickup, pickupLocationName: pickupInfo?.locationName ?? undefined, }; } function mapIssuer(s: ShopSettings): IssuerData { return { companyName: s.companyName, legalForm: s.legalForm, ownerName: s.ownerName, addressLine1: s.addressLine1, addressLine2: s.addressLine2, postalCode: s.postalCode, city: s.city, countryCode: s.countryCode, phone: s.phone, email: s.email, website: s.website, vatId: s.vatId, taxNumber: s.taxNumber, registrationNo: s.registrationNo, registrationCourt: s.registrationCourt, bankName: s.bankName, iban: s.iban, bic: s.bic, footerNote: s.footerNote, footerNoteEn: s.footerNoteEn, }; } function mapRecipient(order: RawOrderForInvoice): RecipientData { // Prefer billingAddress; fall back to shippingAddress; fall back to customer name only. const a = order.billingAddress ?? order.shippingAddress ?? null; const customerFullName = [order.customer?.firstName, order.customer?.lastName] .filter(Boolean) .join(" ") .trim(); if (!a) { return { name: customerFullName, company: order.purchasingEntity?.company?.name ?? "", addressLine1: "", addressLine2: "", postalCode: "", city: "", countryCode: "", }; } return { name: a.name ?? customerFullName, company: a.company ?? order.purchasingEntity?.company?.name ?? "", addressLine1: a.address1 ?? "", addressLine2: a.address2 ?? "", postalCode: a.zip ?? "", city: a.city ?? "", countryCode: a.countryCode ?? "", }; } function mapLinesAndTotals( order: RawOrderForInvoice, opts: { shippingItemPrefix: string }, ): { lines: InvoiceLine[]; totals: InvoiceTotals; } { const taxesIncluded = order.taxesIncluded; const linesOut: InvoiceLine[] = []; const vatMap = new Map(); let netSum = 0; order.lineItems.forEach((li, idx) => { const qty = li.quantity; // 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), 0, ); // If taxes are included in the unit price, subtract them to get net. 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, title: li.title, sku: li.sku ?? undefined, quantity: qty, unitPriceNet: round2(unitNet), originalUnitPriceNet: hasDiscount ? round2(originalUnitNet) : undefined, totalNet: round2(lineNet), imageUrl: li.imageUrl ?? undefined, }); netSum += lineNet; li.taxLines.forEach((t) => accumulateVat(vatMap, t, parseFloat(t.priceSet.shopMoney.amount), lineNet)); void lineGross; }); // Prefer order-level taxLines for the breakdown grouping if line-level is missing. if (vatMap.size === 0 && order.taxLines.length > 0) { order.taxLines.forEach((t) => { const tax = parseFloat(t.priceSet.shopMoney.amount); // We don't have per-rate net from the order level; approximate by inferring from rate. const rate = normaliseRate(t); const net = rate > 0 ? tax / (rate / 100) : 0; const entry = vatMap.get(rate) || { ratePct: rate, net: 0, tax: 0 }; entry.net += net; entry.tax += tax; vatMap.set(rate, entry); }); } // 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) .sort((a, b) => a.ratePct - b.ratePct); const totalVat = vatBreakdown.reduce((acc, e) => acc + e.tax, 0); const grossFromOrder = order.totalPriceSet ? parseFloat(order.totalPriceSet.shopMoney.amount) : netSum + totalVat; return { lines: linesOut, totals: { net: round2(netSum), vatBreakdown, totalVat: round2(totalVat), gross: round2(grossFromOrder), }, }; } function accumulateVat( vatMap: Map, t: RawTaxLine, taxAmount: number, lineNet: number, ) { if (taxAmount <= 0) return; const rate = normaliseRate(t); const entry = vatMap.get(rate) || { ratePct: rate, net: 0, tax: 0 }; entry.net += lineNet; entry.tax += taxAmount; vatMap.set(rate, entry); } function normaliseRate(t: RawTaxLine): number { if (t.ratePercentage != null) return Number(t.ratePercentage); if (t.rate != null) { const r = Number(t.rate); return r <= 1 ? r * 100 : r; } return 0; } function round2(n: number): number { return Math.round(n * 100) / 100; } function deriveNotices({ order, settings, isB2B, }: { order: RawOrderForInvoice; settings: ShopSettings; isB2B: boolean; }): InvoiceNotice[] { const notices: InvoiceNotice[] = []; const totalTax = order.totalTaxSet ? parseFloat(order.totalTaxSet.shopMoney.amount) : 0; const recipientCountry = order.billingAddress?.countryCode || order.shippingAddress?.countryCode || ""; const issuerCountry = settings.countryCode || "AT"; if (settings.kleinunternehmer) { notices.push({ kind: "kleinunternehmer" }); return notices; // exclusive of the others } if (totalTax === 0) { if ( isB2B && recipientCountry && recipientCountry !== issuerCountry && isEuCountry(recipientCountry) ) { notices.push({ kind: "reverseCharge" }); } else if (recipientCountry && !isEuCountry(recipientCountry)) { notices.push({ kind: "export" }); } } return notices; } const EU_COUNTRIES = new Set([ "AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE", "IT","LT","LU","LV","MT","NL","PL","PT","RO","SE","SI","SK", ]); 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; } /** * Detects whether the order is a "local pickup" order using three signals * (any one is enough). All rely only on the `read_orders` scope. * * 1. **No shipping address** despite `requiresShipping == true`. Shopify * never lets a regular ship-to-customer order check out without one, * so this combination is a textbook pickup. This is the *only* signal * for the built-in "Shop location" rate, which leaves * `deliveryCategory` null and the title/code as the bare location * name (e.g. "Shop location" / "Lager Graz"). * 2. `shippingLine.deliveryCategory` contains "pickup"/"local_pickup". * Set by some Local Pickup integrations. * 3. Regex on `shippingLine.{source,code,title,carrierIdentifier}` for * custom rates titled "Abholung"/"Pickup". * * (We deliberately do NOT query `Order.fulfillmentOrders.deliveryMethod`: * that field requires the `read_merchant_managed_fulfillment_orders` scope, * which would force every install to re-grant permissions.) * * Location name is taken from `shippingLine.title` — for the Shopify * Local Pickup app and the built-in "Shop location" rate, the title IS * the chosen location name. * * Returns the pickup descriptor or `null` when the order is a normal * shipping order. Callers should not render the pickup-location address * as a separate "delivery address". */ function detectPickup( order: RawOrderForInvoice, ): { locationName: string | null } | null { const sl = order.shippingLine; // Strongest signal: shipping is required but there's no shipping address. // Shopify rejects checkout otherwise, so this is conclusive. const noShipAddrButRequired = order.requiresShipping && order.shippingAddress == null; // Secondary: explicit pickup category from Local Pickup apps. const dc = (sl?.deliveryCategory ?? "").toLowerCase(); const isPickupCategory = dc.includes("pickup") || dc.includes("pick_up") || dc.includes("pick-up"); // Tertiary: regex on title/code/source/carrier — covers merchants who // model pickup as a custom shipping rate. const haystack = [sl?.source, sl?.code, sl?.title, sl?.carrierIdentifier] .filter(Boolean) .join(" ") .toLowerCase(); const isPickupString = /pick[\s-]?up|abholung|abhol\b/.test(haystack); if (!noShipAddrButRequired && !isPickupCategory && !isPickupString) return null; return { locationName: sl?.title?.trim() || null }; } /** * 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)); }