fix(invoice): partial refund stays "Bezahlt" + use "Endbetrag" final-row label

Two related bugs surfaced when a paid order was partially refunded
(Shopify flips `displayFinancialStatus` to PARTIALLY_REFUNDED as soon
as *any* refund is posted, even a small one):

1. The status row showed "Erstattet" / "Refunded" even though the
   customer paid in full and the merchant kept the difference. The
   correct status is "Bezahlt" / "Paid" — only when the refund equals
   (or, defensively, exceeds) the gross is the order genuinely
   refunded.

2. The final row beneath the new refund block was labelled "Offener
   Betrag" / "Outstanding amount", falsely suggesting the customer
   still owes the kept portion. For an order that has been refunded
   but is no longer owing anything, that row is just the final amount
   the merchant kept — "Endbetrag" / "Total".

Truth table now implemented:

  displayFinancialStatus | refunded     | paymentStatus | final-row label
  -----------------------+--------------+---------------+-----------------
  PAID                   | 0            | paid          | (no refund rows)
  PAID                   | >0           | paid          | Endbetrag
  PARTIALLY_REFUNDED     | < gross      | paid (NEW)    | Endbetrag (NEW)
  PARTIALLY_REFUNDED     | == gross     | refunded      | Endbetrag
  REFUNDED               | == gross     | refunded      | Endbetrag
  PARTIALLY_PAID         | 0            | partial       | (no refund rows)
  PARTIALLY_PAID         | >0 (exotic)  | partial       | Offener Betrag
  PENDING/AUTHORIZED/etc | 0            | unpaid        | (no refund rows)
  storno / offer         | 0 (forced)   | n/a           | n/a

Implementation:

  - composeInvoice.ts: after computing refundedAmount, reclassify
    paymentStatus="refunded" → "paid" when 0 < refundedAmount <
    totals.gross. requiresPayment is derived from paymentStatus, so
    it correctly stays false for partial-refund-on-paid (no GiroCode,
    no payment terms — nothing is owed).
  - i18n.ts: new `finalAmountLabel` ("Endbetrag" / "Total") in both
    languages.
  - InvoiceDocument.tsx: the final-row label now picks
    outstandingLabel vs. finalAmountLabel based on requiresPayment,
    so PARTIALLY_PAID with a refund still says "Offener Betrag"
    while PARTIALLY_REFUNDED says "Endbetrag".

Verification: render-sample now runs four refund scenarios — paid +
no refund (regression guard), full refund (status=Erstattet, final
row=Endbetrag 0,00 EUR), partial refund on a paid order (status=
Bezahlt, final row=Endbetrag, no Erstattet), and PARTIALLY_REFUNDED
with refund==gross (status stays refunded). tsc / smoke / tests /
build all green.
This commit is contained in:
Gerhard Scheikl
2026-05-15 16:56:45 +02:00
parent 40ee895719
commit 91c1a74c1b
4 changed files with 96 additions and 10 deletions
+62 -2
View File
@@ -547,8 +547,8 @@ async function main() {
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 labels the final row 'Endbetrag' (nothing is outstanding)",
refundedText.includes("Endbetrag") && !refundedText.includes("Offener Betrag"));
assert("Refunded PDF shows 0,00 EUR as outstanding",
refundedText.includes("0,00 EUR"));
}
@@ -575,6 +575,66 @@ async function main() {
assert("Paid PDF shows the 'Bezahlt' status row", paidText.includes("Bezahlt"));
}
// ----------------------------------------------------------------
// Partial refund on a paid order: status stays "Bezahlt", the final
// row is labelled "Endbetrag" (not "Offener Betrag"), and the kept
// amount is shown.
// ----------------------------------------------------------------
console.log("• Partial refund on a paid order (PARTIALLY_REFUNDED)");
{
const basePartial = buildAtB2BOrder();
const grossStr = basePartial.totalPriceSet?.shopMoney.amount ?? "0";
const grossNum = parseFloat(grossStr);
const partialRefund = +(grossNum * 0.25).toFixed(2);
const partialOrder = {
...basePartial,
displayFinancialStatus: "PARTIALLY_REFUNDED",
totalRefundedSet: { shopMoney: { amount: partialRefund.toFixed(2), currencyCode: "EUR" } },
};
const partialVm = composeInvoice({
order: partialOrder, settings: settings as never, invoiceNumber: "RE-1016",
});
assertEq("paymentStatus reclassified to paid (partial refund < gross)",
partialVm.paymentStatus, "paid");
assert("requiresPayment=false for partially refunded paid order",
partialVm.requiresPayment === false);
assertNear("refundedAmount mirrors partial refund",
partialVm.refundedAmount, partialRefund);
partialVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const partialText = await pdfToText(await renderInvoicePdf(partialVm));
assert("Partial-refund PDF shows 'Bezahlt' status row (not Erstattet)",
partialText.includes("Bezahlt") && !partialText.includes("Erstattet"));
assert("Partial-refund PDF shows 'Zurückerstattet' totals row",
partialText.includes("Zurückerstattet"));
assert("Partial-refund PDF labels the final row 'Endbetrag' (not 'Offener Betrag')",
partialText.includes("Endbetrag") && !partialText.includes("Offener Betrag"));
assert("Partial-refund PDF does NOT show GiroCode caption",
!partialText.includes("GiroCode"));
assert("Partial-refund PDF does NOT show DE payment terms",
!partialText.includes("Bitte überweise"));
}
// ----------------------------------------------------------------
// Defensive: PARTIALLY_REFUNDED where the refund equals the gross
// (Shopify hasn't flipped to REFUNDED yet) must still classify as
// refunded.
// ----------------------------------------------------------------
console.log("• PARTIALLY_REFUNDED with refund==gross stays 'refunded'");
{
const baseFull = buildAtB2BOrder();
const fullRefundOrder = {
...baseFull,
displayFinancialStatus: "PARTIALLY_REFUNDED",
totalRefundedSet: baseFull.totalPriceSet,
};
const vmFull = composeInvoice({
order: fullRefundOrder, settings: settings as never, invoiceNumber: "RE-1017",
});
assertEq("paymentStatus stays refunded when refund==gross",
vmFull.paymentStatus, "refunded");
assert("requiresPayment still false", vmFull.requiresPayment === false);
}
// ----------------------------------------------------------------
// Footer note translation
// ----------------------------------------------------------------