f16ef4e103
Querying Order.fulfillmentOrders.deliveryMethod requires the
read_merchant_managed_fulfillment_orders scope (not read_orders, despite
what shopify.dev claims) and was failing with 'Access denied for
fulfillmentOrders field' against real stores.
Adding that scope would force every install to re-grant permissions, so
instead we rely on shippingLine alone:
- Shopify Local Pickup app: shippingLine.code = 'Pickup' (caught by the
regex) and shippingLine.title is the chosen location name itself (e.g. 'Lager Graz') \u2014 perfect as pickupLocationName.
- Custom-rate pickup ('Abholung im Lager'): regex matches title/code,
title is used as the location hint.
Removes RawDeliveryMethod, the deliveryMethods field on RawOrderForInvoice,
and the fulfillmentOrders edges from RawAdminResponse.
535 lines
17 KiB
TypeScript
535 lines
17 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. Pickup is detected
|
|
* heuristically from `shippingLine.{source,code,title,carrierIdentifier}`:
|
|
*
|
|
* - The Shopify Local Pickup app sets `shippingLine.code = "Pickup"` and
|
|
* uses the chosen pickup-location name as the shipping-line title — so
|
|
* the title doubles as the location name.
|
|
* - Merchants who model pickup as a custom shipping rate typically include
|
|
* "Abholung"/"Pickup" in the title or code.
|
|
*
|
|
* (We deliberately do NOT query `Order.fulfillmentOrders.deliveryMethod`
|
|
* here: that field requires the `read_merchant_managed_fulfillment_orders`
|
|
* scope, which would force every install to re-grant permissions.)
|
|
*
|
|
* Returns the pickup descriptor (with location name when known) 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;
|
|
if (!sl) return null;
|
|
const haystack = [sl.source, sl.code, sl.title, sl.carrierIdentifier]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
if (!/pick[\s-]?up|abholung|abhol\b/.test(haystack)) return null;
|
|
// For Shopify Local Pickup, `title` is the location name itself
|
|
// (e.g. "Lager Graz"). For custom-rate pickup ("Abholung im Lager"),
|
|
// it's a generic description — still better than nothing as a hint.
|
|
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));
|
|
}
|