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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
<View style={styles.recipientBlock}>
|
||||
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
|
||||
<Recipient recipient={invoice.recipient} />
|
||||
{invoice.separateShippingAddress ? (
|
||||
<View style={styles.shippingAddressBlock}>
|
||||
<Text style={styles.shippingAddressHeading}>{t.shippingAddressHeading}</Text>
|
||||
<Recipient recipient={invoice.separateShippingAddress} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.metaBlock}>
|
||||
<View style={styles.metaTable}>
|
||||
@@ -271,6 +289,12 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerNumber : t.invoiceNumber}</Text>
|
||||
<Text style={styles.invoiceNumberBig}>{invoice.number}</Text>
|
||||
</View>
|
||||
{invoice.kind === "invoice" && invoice.orderName ? (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.orderNumberLabel}</Text>
|
||||
<Text style={styles.metaValue}>{invoice.orderName}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
|
||||
@@ -303,6 +327,25 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{invoice.kind === "invoice" && invoice.shippingMethod ? (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
|
||||
<Text style={styles.metaValue}>{invoice.shippingMethod}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{invoice.kind === "invoice" && invoice.tracking.map((tr) => (
|
||||
<View key={tr.number} style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>
|
||||
{t.trackingLabel}
|
||||
{tr.company ? ` (${tr.company})` : ""}
|
||||
</Text>
|
||||
{tr.url ? (
|
||||
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
|
||||
) : (
|
||||
<Text style={styles.metaValue}>{tr.number}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user