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.