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.
User reported that fully refunded orders still rendered a SEPA GiroCode
QR asking the customer to wire the original total. The existing gate
("!viewModel.paid") only excluded literally-PAID orders; REFUNDED,
PARTIALLY_REFUNDED, VOIDED, AUTHORIZED, etc. all sneaked through and
produced a confusing payment request for an order whose outstanding
balance is in fact 0.
Root cause: the view model exposed two related but ambiguous flags
("paid" and "paymentStatus") and the renderer mixed them
inconsistently. Both the GiroCode generation step in
generateInvoice.server.tsx AND the GiroCode/payment-terms render gates
in InvoiceDocument.tsx checked the wrong one.
Fix: introduce a single derived "requiresPayment" flag on the view
model (composeInvoice.ts) that is true only when:
- the document is a regular invoice (not a storno or an offer), AND
- paymentStatus is neither "paid" nor "refunded".
That single flag now drives:
- GiroCode QR generation (skip QR fetch for paid/refunded)
- GiroCode block render in the PDF
- payment-terms paragraph render in the PDF
The existing "Zahlstatus: Erstattet" / "Payment status: Refunded"
meta-row continues to communicate the refund visually — the change
just removes the contradictory call-to-pay.
Side benefits:
- Storno (cancellation invoice) PDFs no longer emit the German
"Bitte überweise …" payment-terms paragraph (kind=storno was
falling through the same not-paid branch).
- Same suppression for fully PAID orders (covered by a second smoke
fixture) so the QR doesn't suggest re-payment after the fact.
Verification: new smoke fixtures (REFUNDED + PAID) build a real
GiroCode data URL and verify the PDF text neither contains the
"GiroCode" caption nor the "Bitte überweise" German payment terms,
while still showing the corresponding "Erstattet" / "Bezahlt" status
row. tsc / smoke / tests / build all green.
Reproduced against real dev order #1032: the built-in 'Shop location'
shipping rate sets neither a pickup keyword nor deliveryCategory:
shippingLine: { title: 'Shop location', code: 'Shop location',
source: 'shopify', deliveryCategory: null }
shippingAddress: null
requiresShipping: true
So neither v2 (string regex on title/code) nor v3 (deliveryCategory)
caught it. The robust signal is 'requiresShipping && shippingAddress
== null': Shopify rejects checkout for a normal shipping order without
an address, so this combination is conclusive proof of pickup.
- Query Order.requiresShipping (only needs read_orders).
- detectPickup() now treats missing-address-but-requires-shipping as the
primary signal; deliveryCategory + title/code regex remain as
fallbacks for Local-Pickup-app installs and custom rates.
- New fixture buildShopLocationPickupOrder() in render-sample.ts
mirrors order #1032 exactly so we never regress on this shape.
Order 1032 on dev still rendered as 'Versandart: Lager Graz' because the
shipping line's title/code/source contained no 'pickup' keyword — only
`shippingLine.deliveryCategory == "pickup"` flagged it as a pickup.
`shippingLine.deliveryCategory` only requires `read_orders` (already
granted), so query and use it as the primary signal. Keep the regex on
title/code/source/carrier as a fallback for custom rates without a proper
pickup category.
Querying Order.fulfillmentOrders.deliveryMethod requires the
read_merchant_managed_fulfillment_orders scope (not read_orders, despite
what shopify.dev claims) and was failing with 'Access denied for
fulfillmentOrders field' against real stores.
Adding that scope would force every install to re-grant permissions, so
instead we rely on shippingLine alone:
- Shopify Local Pickup app: shippingLine.code = 'Pickup' (caught by the
regex) and shippingLine.title is the chosen location name itself (e.g. 'Lager Graz') \u2014 perfect as pickupLocationName.
- Custom-rate pickup ('Abholung im Lager'): regex matches title/code,
title is used as the location hint.
Removes RawDeliveryMethod, the deliveryMethods field on RawOrderForInvoice,
and the fulfillmentOrders edges from RawAdminResponse.
- Use Order.fulfillmentOrders.deliveryMethod.methodType === 'PICK_UP' as the
primary signal (Shopify Local Pickup app exposes this reliably; the
shippingLine title is just the location name with no 'pickup' keyword).
Keep the legacy shippingLine string heuristic as a fallback for custom
shipping rates merchants name 'Abholung'/'Pickup'.
- Surface assignedLocation.name as pickupLocationName on the view model.
- Replace the 'Versandart: <location name>' row with 'Abholort: <location>'
(DE) / 'Pick-up location: <location>' (EN); falls back to plain
'Abholung'/'Pick-up' when the location name is unavailable.
- discounts: read discountedUnitPriceSet (per-line) and discountCode/discountCodes
(order-level) from Shopify; render discounted unit price with strikethrough
original on the invoice line and add a 'Rabattcode'/'Discount code' meta row
when codes were used.
- delivery date: pick the latest fulfillment.createdAt for §11 UStG instead of
hard-coding processedAt; fall back to invoice date when unfulfilled.
- pickup: detect Shopify Local Pickup (and 'Abholung'/'Pickup' custom rates) via
shippingLine.source/code/title; suppress the pickup-location 'shipping address'
block and render localized 'Abholung'/'Pick-up' as the shipping method.
- layout: move the company logo to the top-left and the meta block to the
top-right, putting recipient (and any separate delivery address) on its own
row below; drop the standalone invoice-/order-number meta rows and surface
them inside the title (e.g. 'Rechnung Nr. RE-1004 · Bestellnummer: #1004') to
recover vertical space.
- tests: smoke fixtures cover discount, pickup, and fulfillment-date variants
without disturbing the AT B2B totals.
- Query Order.shippingLine and Order.fulfillments.trackingInfo from Admin GraphQL.
- Surface orderName (#1004) so customers recognise their order alongside the sequential invoice number.
- Render shipping cost as a synthetic line item (folds into the VAT breakdown).
- Show shipping method (Versandart / Shipping method) and tracking numbers (clickable when URL present) in the meta block.
- Render a separate delivery-address block when the shipping address differs from billing.
- DE strings stay informal (Versandart / Sendungsnummer / Lieferadresse / Versand).
- i18n.de: switch Sie/Ihren to du/dein for salutation, thank-you line,
customer-VAT label and payment-terms paragraph. Closing line was
already informal.
- i18n: add paymentMethodLabel/paymentStatusLabel + per-status labels
(paid/unpaid/partial/refunded) for both DE and EN, plus
derivePaymentStatus helper that condenses Shopify's
displayFinancialStatus (PAID, PARTIALLY_PAID, REFUNDED, …) into a
4-value enum.
- loadOrderForInvoice: query Order.paymentGatewayNames and propagate it
on the raw view-model.
- composeInvoice + types: expose paymentStatus + paymentGatewayNames on
InvoiceViewModel (filtered/trimmed). loadDraftOrderForOffer keeps
paymentGatewayNames empty (drafts have no gateway yet).
- InvoiceDocument: render two new meta rows on real invoices —
'Zahlart / Payment method' (joined, prettified gateway names) and
'Zahlstatus / Payment status' (translated label). Storno + offer kinds
intentionally omit them.
- scripts/render-sample.ts: extend smoke checks to assert the informal
DE wording, the new payment-method/status rows and the
paymentStatus/paymentGatewayNames composer outputs.