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.
193 lines
5.2 KiB
TypeScript
193 lines
5.2 KiB
TypeScript
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
|
|
|
|
import type {
|
|
RawAddress,
|
|
RawLineItem,
|
|
RawMoney,
|
|
RawOrderForInvoice,
|
|
RawTaxLine,
|
|
} from "./loadOrderForInvoice.server";
|
|
|
|
/**
|
|
* Loads a Shopify DraftOrder and adapts it to the same `RawOrderForInvoice`
|
|
* shape used for completed orders, so the rest of the pipeline (composer,
|
|
* PDF, etc.) doesn't need to know whether it's rendering an invoice or an
|
|
* offer.
|
|
*
|
|
* Drafts have no `processedAt` (we use createdAt) and no
|
|
* `displayFinancialStatus` (we treat them as not paid).
|
|
*/
|
|
const QUERY = `#graphql
|
|
query DraftOrderForOffer($id: ID!) {
|
|
draftOrder(id: $id) {
|
|
id
|
|
name
|
|
createdAt
|
|
currencyCode
|
|
taxesIncluded
|
|
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 } }
|
|
taxLines {
|
|
title
|
|
rate
|
|
ratePercentage
|
|
priceSet { shopMoney { amount currencyCode } }
|
|
}
|
|
lineItems(first: 250) {
|
|
edges {
|
|
node {
|
|
title
|
|
sku
|
|
quantity
|
|
originalUnitPriceSet { 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?: {
|
|
draftOrder?: {
|
|
id: string;
|
|
name: string;
|
|
createdAt: string;
|
|
currencyCode: string;
|
|
taxesIncluded: boolean;
|
|
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;
|
|
taxLines: RawTaxLine[];
|
|
lineItems: { edges: { node: RawLineItem & { image?: { url: string | null } | null } }[] };
|
|
purchasingEntity: {
|
|
company?: { name: string } | null;
|
|
location?: {
|
|
taxRegistrationId: string | null;
|
|
billingAddress: RawAddress | null;
|
|
} | null;
|
|
} | null;
|
|
} | null;
|
|
};
|
|
}
|
|
|
|
export async function loadDraftOrderForOffer(
|
|
admin: AdminApiContext,
|
|
draftOrderGid: string,
|
|
): Promise<RawOrderForInvoice> {
|
|
const response = await admin.graphql(QUERY, { variables: { id: draftOrderGid } });
|
|
const json = (await response.json()) as RawAdminResponse;
|
|
const draft = json.data?.draftOrder;
|
|
if (!draft) {
|
|
throw new Error(`Draft order ${draftOrderGid} not found.`);
|
|
}
|
|
|
|
const purchasingCompany = draft.purchasingEntity?.company
|
|
? {
|
|
name: draft.purchasingEntity.company.name,
|
|
vatId: draft.purchasingEntity.location?.taxRegistrationId ?? null,
|
|
address: draft.purchasingEntity.location?.billingAddress ?? null,
|
|
}
|
|
: null;
|
|
|
|
// Drafts don't have a numeric "order number" — use a hash of the GID as a
|
|
// numeric proxy for the invoice-counter signature (not actually used when
|
|
// generating offers, but kept non-zero to satisfy downstream code).
|
|
const orderNumber = parseInt(draft.id.replace(/[^0-9]/g, "").slice(-9), 10) || 0;
|
|
|
|
return {
|
|
id: draft.id,
|
|
name: draft.name,
|
|
orderNumber,
|
|
createdAt: draft.createdAt,
|
|
processedAt: null,
|
|
currencyCode: draft.currencyCode,
|
|
displayFinancialStatus: null,
|
|
paymentGatewayNames: [],
|
|
requiresShipping: false,
|
|
shippingLine: null,
|
|
fulfillments: [],
|
|
discountCodes: [],
|
|
taxesIncluded: draft.taxesIncluded,
|
|
customer: draft.customer,
|
|
billingAddress: draft.billingAddress,
|
|
shippingAddress: draft.shippingAddress,
|
|
subtotalSet: draft.subtotalPriceSet,
|
|
totalTaxSet: draft.totalTaxSet,
|
|
totalPriceSet: draft.totalPriceSet,
|
|
// Drafts have no concept of refunds.
|
|
totalRefundedSet: null,
|
|
taxLines: draft.taxLines || [],
|
|
lineItems: (draft.lineItems?.edges || []).map((e) => {
|
|
const node = e.node;
|
|
return {
|
|
title: node.title,
|
|
sku: node.sku,
|
|
quantity: node.quantity,
|
|
originalUnitPriceSet: node.originalUnitPriceSet,
|
|
discountedUnitPriceSet: null,
|
|
taxLines: node.taxLines,
|
|
imageUrl: node.image?.url ?? null,
|
|
};
|
|
}),
|
|
purchasingEntity: { company: purchasingCompany },
|
|
};
|
|
}
|