feat(invoice): add Shopify order #, shipping address/method/cost and tracking
- Query Order.shippingLine and Order.fulfillments.trackingInfo from Admin GraphQL. - Surface orderName (#1004) so customers recognise their order alongside the sequential invoice number. - Render shipping cost as a synthetic line item (folds into the VAT breakdown). - Show shipping method (Versandart / Shipping method) and tracking numbers (clickable when URL present) in the meta block. - Render a separate delivery-address block when the shipping address differs from billing. - DE strings stay informal (Versandart / Sendungsnummer / Lieferadresse / Versand).
This commit is contained in:
@@ -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<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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user