feat(invoice): show refund + outstanding-amount rows on the PDF

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.
This commit is contained in:
Gerhard Scheikl
2026-05-15 16:45:09 +02:00
parent 9c732618e1
commit 40ee895719
7 changed files with 74 additions and 7 deletions
+7
View File
@@ -94,6 +94,12 @@ export function composeInvoice({
// just whether the money was kept (`paid`) or returned (`refunded`). // just whether the money was kept (`paid`) or returned (`refunded`).
const requiresPayment = const requiresPayment =
!storno && !offer && paymentStatus !== "paid" && paymentStatus !== "refunded"; !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( const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter(
(n) => typeof n === "string" && n.trim().length > 0, (n) => typeof n === "string" && n.trim().length > 0,
); );
@@ -139,6 +145,7 @@ export function composeInvoice({
paid, paid,
paymentStatus, paymentStatus,
requiresPayment, requiresPayment,
refundedAmount,
paymentGatewayNames, paymentGatewayNames,
orderName: order.name, orderName: order.name,
separateShippingAddress, separateShippingAddress,
+12
View File
@@ -44,6 +44,14 @@ export interface InvoiceStrings {
recipientLabel: string; recipientLabel: string;
amountLabel: string; amountLabel: string;
referenceLabel: 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; addressHeading: string;
contactHeading: string; contactHeading: string;
legalHeading: string; legalHeading: string;
@@ -159,6 +167,8 @@ const de: InvoiceStrings = {
recipientLabel: "Empfänger", recipientLabel: "Empfänger",
amountLabel: "Betrag", amountLabel: "Betrag",
referenceLabel: "Referenz", referenceLabel: "Referenz",
refundedLabel: "Zurückerstattet",
outstandingLabel: "Offener Betrag",
addressHeading: "Adresse", addressHeading: "Adresse",
contactHeading: "Kontakt", contactHeading: "Kontakt",
legalHeading: "Rechtliches", legalHeading: "Rechtliches",
@@ -246,6 +256,8 @@ const en: InvoiceStrings = {
recipientLabel: "Recipient", recipientLabel: "Recipient",
amountLabel: "Amount", amountLabel: "Amount",
referenceLabel: "Reference", referenceLabel: "Reference",
refundedLabel: "Refunded",
outstandingLabel: "Outstanding amount",
addressHeading: "Address", addressHeading: "Address",
contactHeading: "Contact", contactHeading: "Contact",
legalHeading: "Legal", legalHeading: "Legal",
@@ -172,6 +172,8 @@ export async function loadDraftOrderForOffer(
subtotalSet: draft.subtotalPriceSet, subtotalSet: draft.subtotalPriceSet,
totalTaxSet: draft.totalTaxSet, totalTaxSet: draft.totalTaxSet,
totalPriceSet: draft.totalPriceSet, totalPriceSet: draft.totalPriceSet,
// Drafts have no concept of refunds.
totalRefundedSet: null,
taxLines: draft.taxLines || [], taxLines: draft.taxLines || [],
lineItems: (draft.lineItems?.edges || []).map((e) => { lineItems: (draft.lineItems?.edges || []).map((e) => {
const node = e.node; const node = e.node;
@@ -38,6 +38,11 @@ export interface RawOrderForInvoice {
subtotalSet: { shopMoney: RawMoney } | null; subtotalSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null; totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { 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: { purchasingEntity: {
company?: { company?: {
name: string; name: string;
@@ -153,6 +158,7 @@ const QUERY = `#graphql
subtotalPriceSet { shopMoney { amount currencyCode } } subtotalPriceSet { shopMoney { amount currencyCode } }
totalTaxSet { shopMoney { amount currencyCode } } totalTaxSet { shopMoney { amount currencyCode } }
totalPriceSet { shopMoney { amount currencyCode } } totalPriceSet { shopMoney { amount currencyCode } }
totalRefundedSet { shopMoney { amount currencyCode } }
taxLines { taxLines {
title title
rate rate
@@ -247,6 +253,7 @@ interface RawAdminResponse {
subtotalPriceSet: { shopMoney: RawMoney } | null; subtotalPriceSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null; totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null; totalPriceSet: { shopMoney: RawMoney } | null;
totalRefundedSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[]; taxLines: RawTaxLine[];
discountCode: string | null; discountCode: string | null;
discountCodes: string[] | null; discountCodes: string[] | null;
@@ -300,6 +307,7 @@ export async function loadOrderForInvoice(
subtotalSet: order.subtotalPriceSet, subtotalSet: order.subtotalPriceSet,
totalTaxSet: order.totalTaxSet, totalTaxSet: order.totalTaxSet,
totalPriceSet: order.totalPriceSet, totalPriceSet: order.totalPriceSet,
totalRefundedSet: order.totalRefundedSet ?? null,
taxLines: order.taxLines || [], taxLines: order.taxLines || [],
discountCodes: order.discountCodes && order.discountCodes.length > 0 discountCodes: order.discountCodes && order.discountCodes.length > 0
? order.discountCodes ? order.discountCodes
@@ -428,6 +428,22 @@ export function InvoiceDocument({ invoice }: DocProps) {
{formatMoney(invoice.totals.gross, cur, invoice.language)} {formatMoney(invoice.totals.gross, cur, invoice.language)}
</Text> </Text>
</View> </View>
{invoice.refundedAmount > 0 && (
<>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{t.refundedLabel}</Text>
<Text style={styles.totalValue}>
{formatMoney(-invoice.refundedAmount, cur, invoice.language)}
</Text>
</View>
<View style={[styles.totalRow, { borderTopWidth: 0.5, borderTopColor: TABLE_BORDER, marginTop: 4, paddingTop: 4 }]}>
<Text style={styles.totalLabelBlue}>{t.outstandingLabel}</Text>
<Text style={styles.totalValueBoldBlue}>
{formatMoney(invoice.totals.gross - invoice.refundedAmount, cur, invoice.language)}
</Text>
</View>
</>
)}
</View> </View>
{invoice.notices.length > 0 && ( {invoice.notices.length > 0 && (
+9
View File
@@ -57,6 +57,15 @@ export interface InvoiceViewModel {
* original total. * original total.
*/ */
requiresPayment: boolean; 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", /** 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[];
+20 -7
View File
@@ -218,6 +218,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } }, subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } },
totalTaxSet: { shopMoney: { amount: (lineTax + 1.0).toFixed(2), currencyCode: "EUR" } }, totalTaxSet: { shopMoney: { amount: (lineTax + 1.0).toFixed(2), currencyCode: "EUR" } },
totalPriceSet: { shopMoney: { amount: (lineGross + 6.0).toFixed(2), currencyCode: "EUR" } }, totalPriceSet: { shopMoney: { amount: (lineGross + 6.0).toFixed(2), currencyCode: "EUR" } },
totalRefundedSet: null,
purchasingEntity: { purchasingEntity: {
company: { company: {
name: "Schmidhofer Dienstleistungen", name: "Schmidhofer Dienstleistungen",
@@ -512,18 +513,24 @@ async function main() {
// ---------------------------------------------------------------- // ----------------------------------------------------------------
console.log("• Refunded order (REFUNDED) suppresses GiroCode + payment terms"); 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({ const refundedVm = composeInvoice({
order: refundedOrder, settings: settings as never, invoiceNumber: "RE-1014", order: refundedOrder, settings: settings as never, invoiceNumber: "RE-1014",
}); });
assertEq("paymentStatus=refunded", refundedVm.paymentStatus, "refunded"); assertEq("paymentStatus=refunded", refundedVm.paymentStatus, "refunded");
assert("requiresPayment=false for refunded", refundedVm.requiresPayment === false); assert("requiresPayment=false for refunded", refundedVm.requiresPayment === false);
assertNear("refundedAmount mirrors totalRefundedSet", refundedVm.refundedAmount, refundedVm.totals.gross);
refundedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl; refundedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
// The orchestrator gates GiroCode generation on requiresPayment too // The orchestrator gates GiroCode generation on requiresPayment too \u2014
// simulate that here by NOT attaching giroCodePngDataUrl. The PDF // simulate a stale QR data URL anyway and verify the PDF render-gate
// render-gate must independently refuse to render even if a stale // independently refuses to render it.
// QR data URL were attached, so set one anyway and verify both
// suppression layers.
refundedVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({ refundedVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName, beneficiaryName: settings.companyName,
iban: settings.iban, iban: settings.iban,
@@ -535,9 +542,15 @@ async function main() {
assert("Refunded PDF does NOT show GiroCode caption", assert("Refunded PDF does NOT show GiroCode caption",
!refundedText.includes("GiroCode")); !refundedText.includes("GiroCode"));
assert("Refunded PDF does NOT show DE payment terms", 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", assert("Refunded PDF still shows the 'Erstattet' status row",
refundedText.includes("Erstattet")); 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. // Same gating must apply to PAID orders.