diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts
index 5df0815..d44df90 100644
--- a/app/services/invoice/composeInvoice.ts
+++ b/app/services/invoice/composeInvoice.ts
@@ -94,6 +94,12 @@ export function composeInvoice({
// just whether the money was kept (`paid`) or returned (`refunded`).
const requiresPayment =
!storno && !offer && paymentStatus !== "paid" && paymentStatus !== "refunded";
+ // Refunded gross amount, mirrored from Shopify's `totalRefundedSet`.
+ // Storno/offer documents don't carry a refund row — a storno *is*
+ // already the cancellation document, and offers have no payments yet.
+ const refundedAmount = storno || offer
+ ? 0
+ : Math.max(0, parseFloat(order.totalRefundedSet?.shopMoney.amount ?? "0") || 0);
const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter(
(n) => typeof n === "string" && n.trim().length > 0,
);
@@ -139,6 +145,7 @@ export function composeInvoice({
paid,
paymentStatus,
requiresPayment,
+ refundedAmount,
paymentGatewayNames,
orderName: order.name,
separateShippingAddress,
diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts
index 1a09b81..089d200 100644
--- a/app/services/invoice/i18n.ts
+++ b/app/services/invoice/i18n.ts
@@ -44,6 +44,14 @@ export interface InvoiceStrings {
recipientLabel: string;
amountLabel: string;
referenceLabel: string;
+ /** Label for the refund row that appears below `grossTotal` when the
+ * order has been (partially or fully) refunded. Mirrors what Shopify
+ * shows on the order page ("Zurückerstattet" / "Refunded"). */
+ refundedLabel: string;
+ /** Label for the final outstanding balance row (`grossTotal -
+ * refundedAmount`) shown when there has been a refund. "Offener
+ * Betrag" / "Outstanding amount". */
+ outstandingLabel: string;
addressHeading: string;
contactHeading: string;
legalHeading: string;
@@ -159,6 +167,8 @@ const de: InvoiceStrings = {
recipientLabel: "Empfänger",
amountLabel: "Betrag",
referenceLabel: "Referenz",
+ refundedLabel: "Zurückerstattet",
+ outstandingLabel: "Offener Betrag",
addressHeading: "Adresse",
contactHeading: "Kontakt",
legalHeading: "Rechtliches",
@@ -246,6 +256,8 @@ const en: InvoiceStrings = {
recipientLabel: "Recipient",
amountLabel: "Amount",
referenceLabel: "Reference",
+ refundedLabel: "Refunded",
+ outstandingLabel: "Outstanding amount",
addressHeading: "Address",
contactHeading: "Contact",
legalHeading: "Legal",
diff --git a/app/services/invoice/loadDraftOrderForOffer.server.ts b/app/services/invoice/loadDraftOrderForOffer.server.ts
index 4e9e75c..7467c1c 100644
--- a/app/services/invoice/loadDraftOrderForOffer.server.ts
+++ b/app/services/invoice/loadDraftOrderForOffer.server.ts
@@ -172,6 +172,8 @@ export async function loadDraftOrderForOffer(
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;
diff --git a/app/services/invoice/loadOrderForInvoice.server.ts b/app/services/invoice/loadOrderForInvoice.server.ts
index 7591966..a4049ab 100644
--- a/app/services/invoice/loadOrderForInvoice.server.ts
+++ b/app/services/invoice/loadOrderForInvoice.server.ts
@@ -38,6 +38,11 @@ export interface RawOrderForInvoice {
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;
@@ -153,6 +158,7 @@ const QUERY = `#graphql
subtotalPriceSet { shopMoney { amount currencyCode } }
totalTaxSet { shopMoney { amount currencyCode } }
totalPriceSet { shopMoney { amount currencyCode } }
+ totalRefundedSet { shopMoney { amount currencyCode } }
taxLines {
title
rate
@@ -247,6 +253,7 @@ interface RawAdminResponse {
subtotalPriceSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null;
+ totalRefundedSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
discountCode: string | null;
discountCodes: string[] | null;
@@ -300,6 +307,7 @@ export async function loadOrderForInvoice(
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
diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx
index c72095b..faad413 100644
--- a/app/services/invoice/pdf/InvoiceDocument.tsx
+++ b/app/services/invoice/pdf/InvoiceDocument.tsx
@@ -428,6 +428,22 @@ export function InvoiceDocument({ invoice }: DocProps) {
{formatMoney(invoice.totals.gross, cur, invoice.language)}
+ {invoice.refundedAmount > 0 && (
+ <>
+
+ {t.refundedLabel}
+
+ {formatMoney(-invoice.refundedAmount, cur, invoice.language)}
+
+
+
+ {t.outstandingLabel}
+
+ {formatMoney(invoice.totals.gross - invoice.refundedAmount, cur, invoice.language)}
+
+
+ >
+ )}
{invoice.notices.length > 0 && (
diff --git a/app/services/invoice/types.ts b/app/services/invoice/types.ts
index cf4dcef..3abc79a 100644
--- a/app/services/invoice/types.ts
+++ b/app/services/invoice/types.ts
@@ -57,6 +57,15 @@ export interface InvoiceViewModel {
* original total.
*/
requiresPayment: boolean;
+ /** Cumulative gross amount that has been refunded against the
+ * underlying Shopify order, in the same currency as `totals.gross`.
+ * 0 when there has been no refund (the common case) or when the
+ * document is structurally not subject to refunds (storno / offer).
+ * When > 0 the renderer adds two extra rows beneath the gross total:
+ * a negative "Zurückerstattet" row and a final "Offener Betrag"
+ * row showing `gross - refundedAmount` so the printed PDF mirrors
+ * what the merchant sees on the Shopify order page. */
+ refundedAmount: number;
/** Names of the payment gateways used (e.g. ["bogus"], ["manual",
* "shopify_payments"]). Empty when unknown / draft. */
paymentGatewayNames: string[];
diff --git a/scripts/render-sample.ts b/scripts/render-sample.ts
index b97b29b..388bfc6 100644
--- a/scripts/render-sample.ts
+++ b/scripts/render-sample.ts
@@ -218,6 +218,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } },
totalTaxSet: { shopMoney: { amount: (lineTax + 1.0).toFixed(2), currencyCode: "EUR" } },
totalPriceSet: { shopMoney: { amount: (lineGross + 6.0).toFixed(2), currencyCode: "EUR" } },
+ totalRefundedSet: null,
purchasingEntity: {
company: {
name: "Schmidhofer Dienstleistungen",
@@ -512,18 +513,24 @@ async function main() {
// ----------------------------------------------------------------
console.log("• Refunded order (REFUNDED) suppresses GiroCode + payment terms");
{
- const refundedOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "REFUNDED" };
+ const baseRefunded = buildAtB2BOrder();
+ const refundedOrder = {
+ ...baseRefunded,
+ displayFinancialStatus: "REFUNDED",
+ // Mirror the full gross as refunded so the new "Offener Betrag"
+ // row should print 0,00 \u20ac.
+ totalRefundedSet: baseRefunded.totalPriceSet,
+ };
const refundedVm = composeInvoice({
order: refundedOrder, settings: settings as never, invoiceNumber: "RE-1014",
});
assertEq("paymentStatus=refunded", refundedVm.paymentStatus, "refunded");
assert("requiresPayment=false for refunded", refundedVm.requiresPayment === false);
+ assertNear("refundedAmount mirrors totalRefundedSet", refundedVm.refundedAmount, refundedVm.totals.gross);
refundedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
- // The orchestrator gates GiroCode generation on requiresPayment too —
- // simulate that here by NOT attaching giroCodePngDataUrl. The PDF
- // render-gate must independently refuse to render even if a stale
- // QR data URL were attached, so set one anyway and verify both
- // suppression layers.
+ // The orchestrator gates GiroCode generation on requiresPayment too \u2014
+ // simulate a stale QR data URL anyway and verify the PDF render-gate
+ // independently refuses to render it.
refundedVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName,
iban: settings.iban,
@@ -535,9 +542,15 @@ async function main() {
assert("Refunded PDF does NOT show GiroCode caption",
!refundedText.includes("GiroCode"));
assert("Refunded PDF does NOT show DE payment terms",
- !refundedText.includes("Bitte überweise"));
+ !refundedText.includes("Bitte \u00fcberweise"));
assert("Refunded PDF still shows the 'Erstattet' status row",
refundedText.includes("Erstattet"));
+ assert("Refunded PDF shows the 'Zur\u00fcckerstattet' totals row",
+ refundedText.includes("Zur\u00fcckerstattet"));
+ assert("Refunded PDF shows the 'Offener Betrag' final row",
+ refundedText.includes("Offener Betrag"));
+ assert("Refunded PDF shows 0,00 EUR as outstanding",
+ refundedText.includes("0,00 EUR"));
}
// Same gating must apply to PAID orders.