40ee895719
When a Shopify order has been (partially or fully) refunded the PDF
now mirrors the order-page totals block:
Gesamtbetrag brutto 629,95 EUR
Zurückerstattet -629,95 EUR
Offener Betrag 0,00 EUR
So the customer immediately sees that nothing is owed any more, even
though the original invoice gross stays unchanged for tax-document
correctness (the refund is itemised as a separate row, not subtracted
from the line totals).
Plumbing:
- GraphQL: added `totalRefundedSet` to OrderForInvoice query.
- RawOrderForInvoice: new optional `totalRefundedSet` field
(null for drafts/offers — they never have refunds).
- InvoiceViewModel: new `refundedAmount: number` (gross, in the
same currency as `totals.gross`). Always present, 0 for storno
and offer documents and for non-refunded invoices.
- composeInvoice parses the gross refund out of `totalRefundedSet`
(defensive parseFloat, clamped to >= 0).
- InvoiceDocument renders the two extra rows under `grossTotal`
only when `refundedAmount > 0`. Uses the existing total-row
styles for visual consistency.
- i18n: added `refundedLabel` ("Zurückerstattet" / "Refunded") and
`outstandingLabel` ("Offener Betrag" / "Outstanding amount") to
both languages.
Verification: render-sample fixture now mirrors the full gross as
refunded and asserts the PDF text contains "Zurückerstattet",
"Offener Betrag", and "0,00 EUR" as the final outstanding row, on
top of the previous suppressions (no GiroCode, no payment terms).
tsc / smoke / tests / build all green.
332 lines
9.5 KiB
TypeScript
332 lines
9.5 KiB
TypeScript
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
|
|
|
|
/**
|
|
* Raw shape of the data we need from the Shopify Admin GraphQL API to
|
|
* compose an invoice. Kept narrow so the composer is testable with fixtures.
|
|
*/
|
|
export interface RawOrderForInvoice {
|
|
id: string;
|
|
name: string;
|
|
orderNumber: number;
|
|
createdAt: string;
|
|
processedAt: string | null;
|
|
currencyCode: string;
|
|
displayFinancialStatus: string | null;
|
|
paymentGatewayNames: string[];
|
|
/** True when the order contains at least one shippable line item. For
|
|
* pickup orders this is `true` but `shippingAddress` is `null` — that
|
|
* combination is the most reliable pickup signal we have without
|
|
* hitting `read_merchant_managed_fulfillment_orders`. */
|
|
requiresShipping: boolean;
|
|
customer: {
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
email: string | null;
|
|
locale: string | null;
|
|
} | null;
|
|
billingAddress: RawAddress | null;
|
|
shippingAddress: RawAddress | null;
|
|
lineItems: RawLineItem[];
|
|
taxLines: RawTaxLine[];
|
|
shippingLine: RawShippingLine | null;
|
|
fulfillments: RawFulfillment[];
|
|
/** Discount codes applied to the cart (e.g. `["SUMMER10"]`). Empty when
|
|
* no codes were used. Manual / automatic discounts without a code are
|
|
* not exposed here. */
|
|
discountCodes: string[];
|
|
taxesIncluded: boolean;
|
|
subtotalSet: { shopMoney: RawMoney } | null;
|
|
totalTaxSet: { shopMoney: RawMoney } | null;
|
|
totalPriceSet: { shopMoney: RawMoney } | null;
|
|
/** Cumulative gross amount that has been refunded against this order
|
|
* via Shopify (sum of all refund transactions). Always present on
|
|
* real orders — may be `null` for synthetic / draft fixtures, in
|
|
* which case the composer treats it as 0. */
|
|
totalRefundedSet: { shopMoney: RawMoney } | null;
|
|
purchasingEntity: {
|
|
company?: {
|
|
name: string;
|
|
vatId: string | null;
|
|
address: RawAddress | null;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
export interface RawAddress {
|
|
name: string | null;
|
|
company: string | null;
|
|
address1: string | null;
|
|
address2: string | null;
|
|
zip: string | null;
|
|
city: string | null;
|
|
province: string | null;
|
|
countryCode: string | null;
|
|
}
|
|
|
|
export interface RawMoney {
|
|
amount: string;
|
|
currencyCode: string;
|
|
}
|
|
|
|
export interface RawLineItem {
|
|
title: string;
|
|
sku: string | null;
|
|
quantity: number;
|
|
originalUnitPriceSet: { shopMoney: RawMoney };
|
|
/** Per-unit price after Shopify has allocated cart-level discounts to this
|
|
* line. May be null when no discount applied (in which case use the
|
|
* original price). */
|
|
discountedUnitPriceSet: { shopMoney: RawMoney } | null;
|
|
taxLines: RawTaxLine[];
|
|
imageUrl: string | null;
|
|
}
|
|
|
|
export interface RawTaxLine {
|
|
title: string | null;
|
|
rate: number | null;
|
|
ratePercentage: number | null;
|
|
priceSet: { shopMoney: RawMoney };
|
|
}
|
|
|
|
export interface RawShippingLine {
|
|
title: string | null;
|
|
code: string | null;
|
|
source: string | null;
|
|
carrierIdentifier: string | null;
|
|
/** Lowercase string like "shipping", "pickup", "local_pickup". Used as
|
|
* the primary pickup signal because it doesn't require the
|
|
* fulfillment-orders scope. */
|
|
deliveryCategory: 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 {
|
|
/** ISO timestamp of when the fulfillment was created (i.e. when the goods
|
|
* were dispatched / handed over). Used for the legally-required delivery
|
|
* date on the invoice when present. */
|
|
createdAt: string | null;
|
|
trackingInfo: RawTrackingInfo[];
|
|
}
|
|
|
|
const QUERY = `#graphql
|
|
query OrderForInvoice($id: ID!) {
|
|
order(id: $id) {
|
|
id
|
|
name
|
|
number
|
|
createdAt
|
|
processedAt
|
|
currencyCode
|
|
displayFinancialStatus
|
|
paymentGatewayNames
|
|
taxesIncluded
|
|
requiresShipping
|
|
customer {
|
|
firstName
|
|
lastName
|
|
email
|
|
locale
|
|
}
|
|
billingAddress {
|
|
name
|
|
company
|
|
address1
|
|
address2
|
|
zip
|
|
city
|
|
province
|
|
countryCode: countryCodeV2
|
|
}
|
|
shippingAddress {
|
|
name
|
|
company
|
|
address1
|
|
address2
|
|
zip
|
|
city
|
|
province
|
|
countryCode: countryCodeV2
|
|
}
|
|
subtotalPriceSet { shopMoney { amount currencyCode } }
|
|
totalTaxSet { shopMoney { amount currencyCode } }
|
|
totalPriceSet { shopMoney { amount currencyCode } }
|
|
totalRefundedSet { shopMoney { amount currencyCode } }
|
|
taxLines {
|
|
title
|
|
rate
|
|
ratePercentage
|
|
priceSet { shopMoney { amount currencyCode } }
|
|
}
|
|
discountCode
|
|
discountCodes
|
|
shippingLine {
|
|
title
|
|
code
|
|
source
|
|
carrierIdentifier
|
|
deliveryCategory
|
|
originalPriceSet { shopMoney { amount currencyCode } }
|
|
discountedPriceSet { shopMoney { amount currencyCode } }
|
|
taxLines {
|
|
title
|
|
rate
|
|
ratePercentage
|
|
priceSet { shopMoney { amount currencyCode } }
|
|
}
|
|
}
|
|
fulfillments(first: 10) {
|
|
createdAt
|
|
trackingInfo {
|
|
number
|
|
url
|
|
company
|
|
}
|
|
}
|
|
lineItems(first: 250) {
|
|
edges {
|
|
node {
|
|
title
|
|
sku
|
|
quantity
|
|
originalUnitPriceSet { shopMoney { amount currencyCode } }
|
|
discountedUnitPriceSet { shopMoney { amount currencyCode } }
|
|
image { url altText }
|
|
taxLines {
|
|
title
|
|
rate
|
|
ratePercentage
|
|
priceSet { shopMoney { amount currencyCode } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
purchasingEntity {
|
|
... on PurchasingCompany {
|
|
company {
|
|
name
|
|
}
|
|
location {
|
|
taxRegistrationId
|
|
billingAddress {
|
|
address1
|
|
address2
|
|
zip
|
|
city
|
|
countryCode
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
interface RawAdminResponse {
|
|
data?: {
|
|
order?: {
|
|
id: string;
|
|
name: string;
|
|
number: number;
|
|
createdAt: string;
|
|
processedAt: string | null;
|
|
currencyCode: string;
|
|
displayFinancialStatus: string | null;
|
|
paymentGatewayNames: string[] | null;
|
|
taxesIncluded: boolean;
|
|
requiresShipping: boolean | null;
|
|
customer: {
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
email: string | null;
|
|
locale: string | null;
|
|
} | null;
|
|
billingAddress: RawAddress | null;
|
|
shippingAddress: RawAddress | null;
|
|
subtotalPriceSet: { shopMoney: RawMoney } | null;
|
|
totalTaxSet: { shopMoney: RawMoney } | null;
|
|
totalPriceSet: { shopMoney: RawMoney } | null;
|
|
totalRefundedSet: { shopMoney: RawMoney } | null;
|
|
taxLines: RawTaxLine[];
|
|
discountCode: string | null;
|
|
discountCodes: string[] | null;
|
|
shippingLine: RawShippingLine | null;
|
|
fulfillments: RawFulfillment[] | null;
|
|
lineItems: { edges: { node: RawLineItem }[] };
|
|
purchasingEntity: {
|
|
company?: { name: string } | null;
|
|
location?: {
|
|
taxRegistrationId: string | null;
|
|
billingAddress: RawAddress | null;
|
|
} | null;
|
|
} | null;
|
|
} | null;
|
|
};
|
|
}
|
|
|
|
export async function loadOrderForInvoice(
|
|
admin: AdminApiContext,
|
|
orderGid: string,
|
|
): Promise<RawOrderForInvoice> {
|
|
const response = await admin.graphql(QUERY, { variables: { id: orderGid } });
|
|
const json = (await response.json()) as RawAdminResponse;
|
|
const order = json.data?.order;
|
|
if (!order) {
|
|
throw new Error(`Order ${orderGid} not found.`);
|
|
}
|
|
|
|
const purchasingCompany = order.purchasingEntity?.company
|
|
? {
|
|
name: order.purchasingEntity.company.name,
|
|
vatId: order.purchasingEntity.location?.taxRegistrationId ?? null,
|
|
address: order.purchasingEntity.location?.billingAddress ?? null,
|
|
}
|
|
: null;
|
|
|
|
return {
|
|
id: order.id,
|
|
name: order.name,
|
|
orderNumber: order.number,
|
|
createdAt: order.createdAt,
|
|
processedAt: order.processedAt,
|
|
currencyCode: order.currencyCode,
|
|
displayFinancialStatus: order.displayFinancialStatus,
|
|
paymentGatewayNames: order.paymentGatewayNames ?? [],
|
|
taxesIncluded: order.taxesIncluded,
|
|
requiresShipping: order.requiresShipping ?? false,
|
|
customer: order.customer,
|
|
billingAddress: order.billingAddress,
|
|
shippingAddress: order.shippingAddress,
|
|
subtotalSet: order.subtotalPriceSet,
|
|
totalTaxSet: order.totalTaxSet,
|
|
totalPriceSet: order.totalPriceSet,
|
|
totalRefundedSet: order.totalRefundedSet ?? null,
|
|
taxLines: order.taxLines || [],
|
|
discountCodes: order.discountCodes && order.discountCodes.length > 0
|
|
? order.discountCodes
|
|
: (order.discountCode ? [order.discountCode] : []),
|
|
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 {
|
|
title: node.title,
|
|
sku: node.sku,
|
|
quantity: node.quantity,
|
|
originalUnitPriceSet: node.originalUnitPriceSet,
|
|
discountedUnitPriceSet: node.discountedUnitPriceSet ?? null,
|
|
taxLines: node.taxLines,
|
|
imageUrl: node.image?.url ?? null,
|
|
};
|
|
}),
|
|
purchasingEntity: { company: purchasingCompany },
|
|
};
|
|
}
|