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 { ShopSettings } from "@prisma/client";
|
||||||
|
|
||||||
import type { RawOrderForInvoice, RawTaxLine } from "./loadOrderForInvoice.server";
|
import type { RawOrderForInvoice, RawShippingLine, RawTaxLine } from "./loadOrderForInvoice.server";
|
||||||
import type {
|
import type {
|
||||||
InvoiceLine,
|
InvoiceLine,
|
||||||
InvoiceNotice,
|
InvoiceNotice,
|
||||||
@@ -8,10 +8,11 @@ import type {
|
|||||||
InvoiceViewModel,
|
InvoiceViewModel,
|
||||||
IssuerData,
|
IssuerData,
|
||||||
RecipientData,
|
RecipientData,
|
||||||
|
TrackingInfo,
|
||||||
VatBreakdownEntry,
|
VatBreakdownEntry,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { addDays } from "./format";
|
import { addDays } from "./format";
|
||||||
import { derivePaymentStatus, pickLanguage, type InvoiceLanguage } from "./i18n";
|
import { derivePaymentStatus, getStrings, pickLanguage, type InvoiceLanguage } from "./i18n";
|
||||||
|
|
||||||
interface ComposeArgs {
|
interface ComposeArgs {
|
||||||
order: RawOrderForInvoice;
|
order: RawOrderForInvoice;
|
||||||
@@ -55,9 +56,16 @@ export function composeInvoice({
|
|||||||
const isB2B = !!order.purchasingEntity?.company;
|
const isB2B = !!order.purchasingEntity?.company;
|
||||||
const recipientVatId = order.purchasingEntity?.company?.vatId ?? undefined;
|
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 });
|
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 invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt);
|
||||||
const deliveryDate = invoiceDate;
|
const deliveryDate = invoiceDate;
|
||||||
// For offers we treat `dueDate` as the offer's validity expiry (default 30
|
// For offers we treat `dueDate` as the offer's validity expiry (default 30
|
||||||
@@ -113,6 +121,10 @@ export function composeInvoice({
|
|||||||
paid,
|
paid,
|
||||||
paymentStatus,
|
paymentStatus,
|
||||||
paymentGatewayNames,
|
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[];
|
lines: InvoiceLine[];
|
||||||
totals: InvoiceTotals;
|
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())
|
const vatBreakdown = Array.from(vatMap.values())
|
||||||
.map((e) => ({ ratePct: e.ratePct, net: round2(e.net), tax: round2(e.tax) }))
|
.map((e) => ({ ratePct: e.ratePct, net: round2(e.net), tax: round2(e.tax) }))
|
||||||
.filter((e) => e.tax > 0)
|
.filter((e) => e.tax > 0)
|
||||||
@@ -318,3 +345,110 @@ const EU_COUNTRIES = new Set([
|
|||||||
function isEuCountry(code: string): boolean {
|
function isEuCountry(code: string): boolean {
|
||||||
return EU_COUNTRIES.has(code.toUpperCase());
|
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;
|
paymentStatusUnpaid: string;
|
||||||
paymentStatusPartial: string;
|
paymentStatusPartial: string;
|
||||||
paymentStatusRefunded: string;
|
paymentStatusRefunded: string;
|
||||||
|
orderNumberLabel: string;
|
||||||
|
shippingAddressHeading: string;
|
||||||
|
shippingMethodLabel: string;
|
||||||
|
trackingLabel: string;
|
||||||
|
shippingItemPrefix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Status displayed for the order's payment, derived from Shopify's
|
/** Status displayed for the order's payment, derived from Shopify's
|
||||||
@@ -153,6 +158,11 @@ const de: InvoiceStrings = {
|
|||||||
paymentStatusUnpaid: "Offen",
|
paymentStatusUnpaid: "Offen",
|
||||||
paymentStatusPartial: "Teilweise bezahlt",
|
paymentStatusPartial: "Teilweise bezahlt",
|
||||||
paymentStatusRefunded: "Erstattet",
|
paymentStatusRefunded: "Erstattet",
|
||||||
|
orderNumberLabel: "Bestellnummer",
|
||||||
|
shippingAddressHeading: "Lieferadresse",
|
||||||
|
shippingMethodLabel: "Versandart",
|
||||||
|
trackingLabel: "Sendungsnummer",
|
||||||
|
shippingItemPrefix: "Versand",
|
||||||
};
|
};
|
||||||
|
|
||||||
const en: InvoiceStrings = {
|
const en: InvoiceStrings = {
|
||||||
@@ -215,6 +225,11 @@ const en: InvoiceStrings = {
|
|||||||
paymentStatusUnpaid: "Outstanding",
|
paymentStatusUnpaid: "Outstanding",
|
||||||
paymentStatusPartial: "Partially paid",
|
paymentStatusPartial: "Partially paid",
|
||||||
paymentStatusRefunded: "Refunded",
|
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
|
// Locale → invoice language. We only render in German (`de`) when the
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ export async function loadDraftOrderForOffer(
|
|||||||
currencyCode: draft.currencyCode,
|
currencyCode: draft.currencyCode,
|
||||||
displayFinancialStatus: null,
|
displayFinancialStatus: null,
|
||||||
paymentGatewayNames: [],
|
paymentGatewayNames: [],
|
||||||
|
shippingLine: null,
|
||||||
|
fulfillments: [],
|
||||||
taxesIncluded: draft.taxesIncluded,
|
taxesIncluded: draft.taxesIncluded,
|
||||||
customer: draft.customer,
|
customer: draft.customer,
|
||||||
billingAddress: draft.billingAddress,
|
billingAddress: draft.billingAddress,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface RawOrderForInvoice {
|
|||||||
shippingAddress: RawAddress | null;
|
shippingAddress: RawAddress | null;
|
||||||
lineItems: RawLineItem[];
|
lineItems: RawLineItem[];
|
||||||
taxLines: RawTaxLine[];
|
taxLines: RawTaxLine[];
|
||||||
|
shippingLine: RawShippingLine | null;
|
||||||
|
fulfillments: RawFulfillment[];
|
||||||
taxesIncluded: boolean;
|
taxesIncluded: boolean;
|
||||||
subtotalSet: { shopMoney: RawMoney } | null;
|
subtotalSet: { shopMoney: RawMoney } | null;
|
||||||
totalTaxSet: { shopMoney: RawMoney } | null;
|
totalTaxSet: { shopMoney: RawMoney } | null;
|
||||||
@@ -68,6 +70,26 @@ export interface RawTaxLine {
|
|||||||
priceSet: { shopMoney: RawMoney };
|
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
|
const QUERY = `#graphql
|
||||||
query OrderForInvoice($id: ID!) {
|
query OrderForInvoice($id: ID!) {
|
||||||
order(id: $id) {
|
order(id: $id) {
|
||||||
@@ -115,6 +137,27 @@ const QUERY = `#graphql
|
|||||||
ratePercentage
|
ratePercentage
|
||||||
priceSet { shopMoney { amount currencyCode } }
|
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) {
|
lineItems(first: 250) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -177,6 +220,8 @@ interface RawAdminResponse {
|
|||||||
totalTaxSet: { shopMoney: RawMoney } | null;
|
totalTaxSet: { shopMoney: RawMoney } | null;
|
||||||
totalPriceSet: { shopMoney: RawMoney } | null;
|
totalPriceSet: { shopMoney: RawMoney } | null;
|
||||||
taxLines: RawTaxLine[];
|
taxLines: RawTaxLine[];
|
||||||
|
shippingLine: RawShippingLine | null;
|
||||||
|
fulfillments: RawFulfillment[] | null;
|
||||||
lineItems: { edges: { node: RawLineItem }[] };
|
lineItems: { edges: { node: RawLineItem }[] };
|
||||||
purchasingEntity: {
|
purchasingEntity: {
|
||||||
company?: { name: string } | null;
|
company?: { name: string } | null;
|
||||||
@@ -225,6 +270,8 @@ export async function loadOrderForInvoice(
|
|||||||
totalTaxSet: order.totalTaxSet,
|
totalTaxSet: order.totalTaxSet,
|
||||||
totalPriceSet: order.totalPriceSet,
|
totalPriceSet: order.totalPriceSet,
|
||||||
taxLines: order.taxLines || [],
|
taxLines: order.taxLines || [],
|
||||||
|
shippingLine: order.shippingLine ?? null,
|
||||||
|
fulfillments: order.fulfillments ?? [],
|
||||||
lineItems: (order.lineItems?.edges || []).map((e) => {
|
lineItems: (order.lineItems?.edges || []).map((e) => {
|
||||||
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
|
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ const styles = StyleSheet.create({
|
|||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
fontSize: 10,
|
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: {
|
metaBlock: {
|
||||||
width: "40%",
|
width: "40%",
|
||||||
},
|
},
|
||||||
@@ -264,6 +276,12 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
|||||||
<View style={styles.recipientBlock}>
|
<View style={styles.recipientBlock}>
|
||||||
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
|
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
|
||||||
<Recipient recipient={invoice.recipient} />
|
<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>
|
||||||
<View style={styles.metaBlock}>
|
<View style={styles.metaBlock}>
|
||||||
<View style={styles.metaTable}>
|
<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.metaLabel}>{invoice.kind === "offer" ? t.offerNumber : t.invoiceNumber}</Text>
|
||||||
<Text style={styles.invoiceNumberBig}>{invoice.number}</Text>
|
<Text style={styles.invoiceNumberBig}>{invoice.number}</Text>
|
||||||
</View>
|
</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}>
|
<View style={styles.metaRow}>
|
||||||
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}</Text>
|
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}</Text>
|
||||||
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
|
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
|
||||||
@@ -303,6 +327,25 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -46,6 +46,28 @@ export interface InvoiceViewModel {
|
|||||||
/** Names of the payment gateways used (e.g. ["bogus"], ["manual",
|
/** Names of the payment gateways used (e.g. ["bogus"], ["manual",
|
||||||
* "shopify_payments"]). Empty when unknown / draft. */
|
* "shopify_payments"]). Empty when unknown / draft. */
|
||||||
paymentGatewayNames: string[];
|
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 {
|
export interface IssuerData {
|
||||||
|
|||||||
+78
-12
@@ -152,7 +152,39 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
|||||||
province: null,
|
province: null,
|
||||||
countryCode: "AT",
|
countryCode: "AT",
|
||||||
},
|
},
|
||||||
shippingAddress: null,
|
shippingAddress: {
|
||||||
|
name: "Lukas Schmidhofer",
|
||||||
|
company: "Schmidhofer Dienstleistungen",
|
||||||
|
address1: "Lagerweg 4",
|
||||||
|
address2: null,
|
||||||
|
zip: "8020",
|
||||||
|
city: "Graz",
|
||||||
|
province: null,
|
||||||
|
countryCode: "AT",
|
||||||
|
},
|
||||||
|
shippingLine: {
|
||||||
|
title: "Standardversand",
|
||||||
|
code: "STD",
|
||||||
|
source: "shopify",
|
||||||
|
carrierIdentifier: null,
|
||||||
|
originalPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } },
|
||||||
|
discountedPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } },
|
||||||
|
taxLines: [
|
||||||
|
{
|
||||||
|
title: "USt 20%",
|
||||||
|
rate: 0.2,
|
||||||
|
ratePercentage: 20,
|
||||||
|
priceSet: { shopMoney: { amount: "1.00", currencyCode: "EUR" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fulfillments: [
|
||||||
|
{
|
||||||
|
trackingInfo: [
|
||||||
|
{ number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
lineItems: [
|
lineItems: [
|
||||||
{
|
{
|
||||||
title: "Bluetooth Tracker",
|
title: "Bluetooth Tracker",
|
||||||
@@ -179,8 +211,8 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } },
|
subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } },
|
||||||
totalTaxSet: { shopMoney: { amount: lineTax.toFixed(2), currencyCode: "EUR" } },
|
totalTaxSet: { shopMoney: { amount: (lineTax + 1.0).toFixed(2), currencyCode: "EUR" } },
|
||||||
totalPriceSet: { shopMoney: { amount: lineGross.toFixed(2), currencyCode: "EUR" } },
|
totalPriceSet: { shopMoney: { amount: (lineGross + 6.0).toFixed(2), currencyCode: "EUR" } },
|
||||||
purchasingEntity: {
|
purchasingEntity: {
|
||||||
company: {
|
company: {
|
||||||
name: "Schmidhofer Dienstleistungen",
|
name: "Schmidhofer Dienstleistungen",
|
||||||
@@ -203,6 +235,10 @@ function buildEuB2BReverseChargeOrder(): RawOrderForInvoice {
|
|||||||
o.purchasingEntity!.company!.vatId = "DE123456789";
|
o.purchasingEntity!.company!.vatId = "DE123456789";
|
||||||
o.lineItems[0].taxLines = [];
|
o.lineItems[0].taxLines = [];
|
||||||
o.taxLines = [];
|
o.taxLines = [];
|
||||||
|
// No VAT for reverse-charge; clear shipping VAT too.
|
||||||
|
o.shippingLine = null;
|
||||||
|
o.fulfillments = [];
|
||||||
|
o.shippingAddress = null;
|
||||||
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
|
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
|
||||||
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
|
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
|
||||||
return o;
|
return o;
|
||||||
@@ -217,6 +253,9 @@ function buildExportOrder(): RawOrderForInvoice {
|
|||||||
o.billingAddress!.city = "New York";
|
o.billingAddress!.city = "New York";
|
||||||
o.lineItems[0].taxLines = [];
|
o.lineItems[0].taxLines = [];
|
||||||
o.taxLines = [];
|
o.taxLines = [];
|
||||||
|
o.shippingLine = null;
|
||||||
|
o.fulfillments = [];
|
||||||
|
o.shippingAddress = null;
|
||||||
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
|
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
|
||||||
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
|
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
|
||||||
o.customer!.locale = "en";
|
o.customer!.locale = "en";
|
||||||
@@ -265,22 +304,33 @@ async function main() {
|
|||||||
assertEq("currency", vm.currency, "EUR");
|
assertEq("currency", vm.currency, "EUR");
|
||||||
assert("isB2B detected", vm.isB2B);
|
assert("isB2B detected", vm.isB2B);
|
||||||
assertEq("recipientVatId", vm.recipientVatId, "ATU57680511");
|
assertEq("recipientVatId", vm.recipientVatId, "ATU57680511");
|
||||||
assertEq("line count", vm.lines.length, 1);
|
assertEq("line count (1 product + 1 shipping)", vm.lines.length, 2);
|
||||||
const ln = vm.lines[0];
|
const ln = vm.lines[0];
|
||||||
assertEq("line title", ln.title, "Bluetooth Tracker");
|
assertEq("line title", ln.title, "Bluetooth Tracker");
|
||||||
assertEq("line qty", ln.quantity, 6);
|
assertEq("line qty", ln.quantity, 6);
|
||||||
assertNear("line unit net", ln.unitPriceNet, 5.99);
|
assertNear("line unit net", ln.unitPriceNet, 5.99);
|
||||||
assertNear("line total net", ln.totalNet, 35.94);
|
assertNear("line total net", ln.totalNet, 35.94);
|
||||||
assertNear("net total", vm.totals.net, 35.94);
|
const shipLine = vm.lines[1];
|
||||||
|
assert("shipping line title prefixed", shipLine.title.startsWith("Versand"),
|
||||||
|
`got "${shipLine.title}"`);
|
||||||
|
assertNear("shipping line net", shipLine.totalNet, 5.0);
|
||||||
|
assertNear("net total (incl. shipping)", vm.totals.net, 40.94);
|
||||||
assertEq("vat breakdown rows", vm.totals.vatBreakdown.length, 1);
|
assertEq("vat breakdown rows", vm.totals.vatBreakdown.length, 1);
|
||||||
assertNear("vat amount", vm.totals.vatBreakdown[0].tax, 7.19);
|
assertNear("vat amount (incl. shipping VAT)", vm.totals.vatBreakdown[0].tax, 8.19);
|
||||||
assertEq("vat rate %", vm.totals.vatBreakdown[0].ratePct, 20);
|
assertEq("vat rate %", vm.totals.vatBreakdown[0].ratePct, 20);
|
||||||
assertNear("gross", vm.totals.gross, 43.13);
|
assertNear("gross (incl. shipping)", vm.totals.gross, 49.13);
|
||||||
assertEq("no notices for AT B2B with VAT charged", vm.notices.length, 0);
|
assertEq("no notices for AT B2B with VAT charged", vm.notices.length, 0);
|
||||||
assert("due date 14 days after invoice date", !!vm.dueDate
|
assert("due date 14 days after invoice date", !!vm.dueDate
|
||||||
&& Math.round((vm.dueDate.getTime() - vm.invoiceDate.getTime()) / 86400000) === 14);
|
&& Math.round((vm.dueDate.getTime() - vm.invoiceDate.getTime()) / 86400000) === 14);
|
||||||
assertEq("paymentGatewayNames propagated", vm.paymentGatewayNames.join(","), "manual");
|
assertEq("paymentGatewayNames propagated", vm.paymentGatewayNames.join(","), "manual");
|
||||||
assertEq("paymentStatus derived from displayFinancialStatus=PENDING", vm.paymentStatus, "unpaid");
|
assertEq("paymentStatus derived from displayFinancialStatus=PENDING", vm.paymentStatus, "unpaid");
|
||||||
|
assertEq("orderName propagated", vm.orderName, "#1004");
|
||||||
|
assertEq("shippingMethod propagated", vm.shippingMethod, "Standardversand");
|
||||||
|
assertEq("tracking entries", vm.tracking.length, 1);
|
||||||
|
assertEq("tracking number", vm.tracking[0].number, "JJD0099887766");
|
||||||
|
assertEq("tracking carrier", vm.tracking[0].company, "DHL");
|
||||||
|
assert("separateShippingAddress detected (differs from billing)",
|
||||||
|
vm.separateShippingAddress?.addressLine1 === "Lagerweg 4");
|
||||||
|
|
||||||
console.log("• EU B2B reverse-charge notice");
|
console.log("• EU B2B reverse-charge notice");
|
||||||
const euOrder = buildEuB2BReverseChargeOrder();
|
const euOrder = buildEuB2BReverseChargeOrder();
|
||||||
@@ -346,15 +396,16 @@ async function main() {
|
|||||||
assertEq("kind = storno", storno.kind, "storno");
|
assertEq("kind = storno", storno.kind, "storno");
|
||||||
assertEq("cancelsNumber populated", storno.cancelsNumber, "RE-1004");
|
assertEq("cancelsNumber populated", storno.cancelsNumber, "RE-1004");
|
||||||
assert("dueDate suppressed for storno", storno.dueDate == null);
|
assert("dueDate suppressed for storno", storno.dueDate == null);
|
||||||
assertEq("line count preserved", storno.lines.length, 1);
|
assertEq("line count preserved", storno.lines.length, 2);
|
||||||
assertNear("line qty preserved (only money negated)", storno.lines[0].quantity, 6);
|
assertNear("line qty preserved (only money negated)", storno.lines[0].quantity, 6);
|
||||||
assertNear("line unit price negated", storno.lines[0].unitPriceNet, -5.99);
|
assertNear("line unit price negated", storno.lines[0].unitPriceNet, -5.99);
|
||||||
assertNear("line totalNet negated", storno.lines[0].totalNet, -35.94);
|
assertNear("line totalNet negated", storno.lines[0].totalNet, -35.94);
|
||||||
assertNear("totals.net negated", storno.totals.net, -35.94);
|
assertNear("shipping line totalNet negated", storno.lines[1].totalNet, -5.0);
|
||||||
assertNear("totals.totalVat negated", storno.totals.totalVat, -7.19);
|
assertNear("totals.net negated", storno.totals.net, -40.94);
|
||||||
assertNear("totals.gross negated", storno.totals.gross, -43.13);
|
assertNear("totals.totalVat negated", storno.totals.totalVat, -8.19);
|
||||||
|
assertNear("totals.gross negated", storno.totals.gross, -49.13);
|
||||||
assertEq("vat breakdown row count preserved", storno.totals.vatBreakdown.length, 1);
|
assertEq("vat breakdown row count preserved", storno.totals.vatBreakdown.length, 1);
|
||||||
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -7.19);
|
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -8.19);
|
||||||
|
|
||||||
console.log("• Render storno PDF");
|
console.log("• Render storno PDF");
|
||||||
storno.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
storno.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||||
@@ -416,6 +467,21 @@ async function main() {
|
|||||||
assert("EN PDF shows payment status row", enText.includes("Payment status"));
|
assert("EN PDF shows payment status row", enText.includes("Payment status"));
|
||||||
assert("EN PDF shows payment status value 'Outstanding' for PENDING", enText.includes("Outstanding"));
|
assert("EN PDF shows payment status value 'Outstanding' for PENDING", enText.includes("Outstanding"));
|
||||||
|
|
||||||
|
// Shipment + order-number block.
|
||||||
|
assert("DE PDF shows order number row 'Bestellnummer'", deText.includes("Bestellnummer"));
|
||||||
|
assert("DE PDF shows Shopify order name '#1004'", deText.includes("#1004"));
|
||||||
|
assert("DE PDF shows shipping method row 'Versandart'", deText.includes("Versandart"));
|
||||||
|
assert("DE PDF shows shipping method value 'Standardversand'", deText.includes("Standardversand"));
|
||||||
|
assert("DE PDF shows tracking row 'Sendungsnummer'", deText.includes("Sendungsnummer"));
|
||||||
|
assert("DE PDF shows tracking number", deText.includes("JJD0099887766"));
|
||||||
|
assert("DE PDF shows shipping line item with prefix", deText.includes("Versand"));
|
||||||
|
assert("DE PDF shows separate delivery address heading", deText.includes("Lieferadresse"));
|
||||||
|
assert("DE PDF shows shipping address line", deText.includes("Lagerweg 4"));
|
||||||
|
assert("EN PDF shows order number row 'Order no.'", enText.includes("Order no."));
|
||||||
|
assert("EN PDF shows shipping method row 'Shipping method'", enText.includes("Shipping method"));
|
||||||
|
assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no."));
|
||||||
|
assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address"));
|
||||||
|
|
||||||
// Fallback: when footerNoteEn is empty, English uses the German note.
|
// Fallback: when footerNoteEn is empty, English uses the German note.
|
||||||
console.log("• Footer note fallback (en → de when EN empty)");
|
console.log("• Footer note fallback (en → de when EN empty)");
|
||||||
const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;
|
const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;
|
||||||
|
|||||||
Reference in New Issue
Block a user