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.
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.