diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts index d44df90..b231aba 100644 --- a/app/services/invoice/composeInvoice.ts +++ b/app/services/invoice/composeInvoice.ts @@ -87,19 +87,34 @@ export function composeInvoice({ : undefined; const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID"; - const paymentStatus = derivePaymentStatus(order.displayFinancialStatus); - // A document only requires payment when it's a regular invoice (not a - // storno or an offer) AND money is still actually owed. Refunded and - // paid orders both have a 0 outstanding balance — the difference is - // 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); + let paymentStatus = derivePaymentStatus(order.displayFinancialStatus); + // Reclassification: Shopify flips `displayFinancialStatus` to + // PARTIALLY_REFUNDED as soon as *any* refund is posted against a + // paid order, even when the customer only got back a small fraction. + // For our purposes such an order is still "paid" — the merchant kept + // the difference — and showing "Erstattet" / "Refunded" in the + // status row would falsely imply the customer got everything back. + // Only when the refund equals (or, defensively, exceeds) the gross + // do we keep the "refunded" status. + if ( + paymentStatus === "refunded" && + refundedAmount > 0 && + refundedAmount < totals.gross + ) { + paymentStatus = "paid"; + } + // A document only requires payment when it's a regular invoice (not a + // storno or an offer) AND money is still actually owed. Refunded and + // paid orders both have a 0 outstanding balance — the difference is + // just whether the money was kept (`paid`) or returned (`refunded`). + const requiresPayment = + !storno && !offer && paymentStatus !== "paid" && paymentStatus !== "refunded"; const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter( (n) => typeof n === "string" && n.trim().length > 0, ); diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts index 089d200..735e39e 100644 --- a/app/services/invoice/i18n.ts +++ b/app/services/invoice/i18n.ts @@ -52,6 +52,13 @@ export interface InvoiceStrings { * refundedAmount`) shown when there has been a refund. "Offener * Betrag" / "Outstanding amount". */ outstandingLabel: string; + /** Label used in place of `outstandingLabel` when the order has been + * refunded but nothing is actually owed any more (i.e. the customer + * paid in full and got back only part — or all — of the gross via + * refunds). "Endbetrag" / "Total". The distinction matters for + * PARTIALLY_REFUNDED orders, where calling the kept portion + * "outstanding" would falsely suggest the customer still owes it. */ + finalAmountLabel: string; addressHeading: string; contactHeading: string; legalHeading: string; @@ -169,6 +176,7 @@ const de: InvoiceStrings = { referenceLabel: "Referenz", refundedLabel: "Zurückerstattet", outstandingLabel: "Offener Betrag", + finalAmountLabel: "Endbetrag", addressHeading: "Adresse", contactHeading: "Kontakt", legalHeading: "Rechtliches", @@ -258,6 +266,7 @@ const en: InvoiceStrings = { referenceLabel: "Reference", refundedLabel: "Refunded", outstandingLabel: "Outstanding amount", + finalAmountLabel: "Total", addressHeading: "Address", contactHeading: "Contact", legalHeading: "Legal", diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx index faad413..4f4115a 100644 --- a/app/services/invoice/pdf/InvoiceDocument.tsx +++ b/app/services/invoice/pdf/InvoiceDocument.tsx @@ -437,7 +437,9 @@ export function InvoiceDocument({ invoice }: DocProps) { - {t.outstandingLabel} + + {invoice.requiresPayment ? t.outstandingLabel : t.finalAmountLabel} + {formatMoney(invoice.totals.gross - invoice.refundedAmount, cur, invoice.language)} diff --git a/scripts/render-sample.ts b/scripts/render-sample.ts index 388bfc6..45655b9 100644 --- a/scripts/render-sample.ts +++ b/scripts/render-sample.ts @@ -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 // ----------------------------------------------------------------