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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user