a2b3c14022
Reproduced against real dev order #1032: the built-in 'Shop location' shipping rate sets neither a pickup keyword nor deliveryCategory: shippingLine: { title: 'Shop location', code: 'Shop location', source: 'shopify', deliveryCategory: null } shippingAddress: null requiresShipping: true So neither v2 (string regex on title/code) nor v3 (deliveryCategory) caught it. The robust signal is 'requiresShipping && shippingAddress == null': Shopify rejects checkout for a normal shipping order without an address, so this combination is conclusive proof of pickup. - Query Order.requiresShipping (only needs read_orders). - detectPickup() now treats missing-address-but-requires-shipping as the primary signal; deliveryCategory + title/code regex remain as fallbacks for Local-Pickup-app installs and custom rates. - New fixture buildShopLocationPickupOrder() in render-sample.ts mirrors order #1032 exactly so we never regress on this shape.
550 lines
18 KiB
TypeScript
550 lines
18 KiB
TypeScript
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: <location>") — 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";
|
|
const paymentStatus = derivePaymentStatus(order.displayFinancialStatus);
|
|
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,
|
|
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<number, VatBreakdownEntry>();
|
|
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<number, VatBreakdownEntry>,
|
|
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<number, VatBreakdownEntry>,
|
|
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<string>();
|
|
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));
|
|
}
|