Audit cleanup of payment-status code paths uncovered while shipping
the partial-refund fix:
#1 Drop `viewModel.paid` (boolean). It was set from
`displayFinancialStatus === "PAID"` and never read anywhere. With
refunds in the picture it had become a footgun: a fully refunded
order that started PAID would still satisfy `paid === true`, but
`paymentStatus === "refunded"`. Callers should use `paymentStatus`
/ `requiresPayment` exclusively.
#2 Remove the unused `paidStamp` translation ("BEZAHLT" / "PAID").
Defined in both locales but never rendered.
#3 Classify VOIDED orders as a distinct `"voided"` payment status
(rendered "Annulliert" / "Voided") instead of "unpaid". A voided
order had its authorisation cancelled before capture — no money
was received and none is owed. The previous "Offen" / "Outstanding"
label combined with a GiroCode would have invited the customer to
pay an order that's already been called off. `requiresPayment`
now also excludes `"voided"`, so GiroCode + payment-terms
paragraph are suppressed (mirrors the `"refunded"` treatment).
"Annulliert" is used in German rather than "Storniert" to avoid
confusion with our storno cancellation document concept.
#6 `derivePaymentStatus` now logs a `console.warn` when it
encounters a non-empty `displayFinancialStatus` value that isn't
one of the documented Shopify enum members (PAID, PARTIALLY_PAID,
REFUNDED, PARTIALLY_REFUNDED, VOIDED, PENDING, AUTHORIZED,
EXPIRED). Future Shopify enum additions will surface in logs
instead of silently mapping to "unpaid".
EXPIRED stays mapped to "unpaid" — abandoned-checkout-style edge
case left intentionally for a separate decision (#4 in the audit).
Verification: render-sample now also exercises a VOIDED fixture
(status row "Annulliert", no GiroCode, no payment terms). tsc /
smoke / tests / build all green.
Two related bugs surfaced when a paid order was partially refunded
(Shopify flips `displayFinancialStatus` to PARTIALLY_REFUNDED as soon
as *any* refund is posted, even a small one):
1. The status row showed "Erstattet" / "Refunded" even though the
customer paid in full and the merchant kept the difference. The
correct status is "Bezahlt" / "Paid" — only when the refund equals
(or, defensively, exceeds) the gross is the order genuinely
refunded.
2. The final row beneath the new refund block was labelled "Offener
Betrag" / "Outstanding amount", falsely suggesting the customer
still owes the kept portion. For an order that has been refunded
but is no longer owing anything, that row is just the final amount
the merchant kept — "Endbetrag" / "Total".
Truth table now implemented:
displayFinancialStatus | refunded | paymentStatus | final-row label
-----------------------+--------------+---------------+-----------------
PAID | 0 | paid | (no refund rows)
PAID | >0 | paid | Endbetrag
PARTIALLY_REFUNDED | < gross | paid (NEW) | Endbetrag (NEW)
PARTIALLY_REFUNDED | == gross | refunded | Endbetrag
REFUNDED | == gross | refunded | Endbetrag
PARTIALLY_PAID | 0 | partial | (no refund rows)
PARTIALLY_PAID | >0 (exotic) | partial | Offener Betrag
PENDING/AUTHORIZED/etc | 0 | unpaid | (no refund rows)
storno / offer | 0 (forced) | n/a | n/a
Implementation:
- composeInvoice.ts: after computing refundedAmount, reclassify
paymentStatus="refunded" → "paid" when 0 < refundedAmount <
totals.gross. requiresPayment is derived from paymentStatus, so
it correctly stays false for partial-refund-on-paid (no GiroCode,
no payment terms — nothing is owed).
- i18n.ts: new `finalAmountLabel` ("Endbetrag" / "Total") in both
languages.
- InvoiceDocument.tsx: the final-row label now picks
outstandingLabel vs. finalAmountLabel based on requiresPayment,
so PARTIALLY_PAID with a refund still says "Offener Betrag"
while PARTIALLY_REFUNDED says "Endbetrag".
Verification: render-sample now runs four refund scenarios — paid +
no refund (regression guard), full refund (status=Erstattet, final
row=Endbetrag 0,00 EUR), partial refund on a paid order (status=
Bezahlt, final row=Endbetrag, no Erstattet), and PARTIALLY_REFUNDED
with refund==gross (status stays refunded). tsc / smoke / tests /
build all green.
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.
Wraps the phone in a @react-pdf 'Link' with src='tel:+\u2026' just like the
existing email (mailto:) and website (https:) entries in the contact
footer. Display string keeps the human-readable formatting (spaces,
parens) while the underlying URL is normalized per RFC 3966 (digits
plus an optional leading '+').
PDF readers on macOS, iOS, Android and most desktop Linux setups
launch the system dialer or a VoIP app on click; readers without a
'tel' handler fall back to selecting the number.
The 'Hallo,' (DE) / 'Dear Sir or Madam,' (EN) line above the items
table was leftover letter framing. Removing it:
- reclaims a bit of vertical space at the top of every PDF, and
- sidesteps the formal-vs-informal tone debate entirely \u2014 a tax
document doesn't need a greeting.
The 'Vielen Dank f\u00fcr deine Bestellung. Wir stellen dir hiermit
folgendes in Rechnung:' / 'Thank you for your order. We hereby
invoice you for the following:' line is kept as it actually
introduces the items table.
Smoke assertions flipped from 'must contain Hallo,' to 'must NOT
contain Hallo, / Sehr geehrte …' so the suppression is enforced
in CI.
Customer reported that on the German invoice PDF the payment method
showed up as 'Zahlart: Bank Deposit' while the order-confirmation page
on the storefront localized it correctly to 'Bank\u00fc1berweisung'. Cause:
Shopify's Admin GraphQL API only ever returns the *English* template
name in 'Order.paymentGatewayNames', even when the shop / order locale
is German \u2014 the localization happens client-side at checkout but is
NOT exposed via the API. So the PDF and the storefront naturally
diverge unless we mirror the translations ourselves.
Fix: introduce a per-language 'paymentGatewayLabels' map on
'InvoiceStrings' covering the built-in Shopify manual-payment
templates (Bank Deposit, Money Order, Cash on Delivery) plus the
standard non-manual gateways (Shopify Payments, PayPal, Klarna,
Sofort, Giropay, Bogus). 'prettifyGatewayName' now takes this map
and looks up the normalized key (lowercased, separators collapsed),
falling back to a title-cased rendering for unknown values.
DE result: 'Zahlart: Bank\u00fc1berweisung', 'Manuelle Zahlung', 'Nachnahme'.
EN result: unchanged.
New smoke assertions verify the DE PDF now shows 'Manuelle Zahlung'
for the AT B2B fixture's 'manual' gateway and that the raw English
'Manual' no longer appears next to the 'Zahlart' label.
Note on other Shopify-sourced strings on the PDF: 'shippingLine.title'
(e.g. 'Standard') is similarly merchant/locale-dependent, but unlike
gateway names it's fully customizable per-shop in Shopify Admin and
is not a fixed enum we can translate \u2014 left untouched pending an
explicit report. Product titles, discount codes and addresses are
likewise merchant-/customer-supplied and flow through verbatim by
design.
Two related fixes around the order/invoice number:
1) The thank-you page and the customer-account order page were showing
the bare Shopify order name (e.g. '#1034') as the payment reference,
while the PDF (and its GiroCode QR) used the canonical invoice
number (e.g. 'RE-1034'). Banks treat each unique reference as a
separate payment, and several reject the '#' character outright \u2014
so customers who pasted the thank-you reference into their banking
app ended up with a payment the shop couldn't reconcile.
New shared helper resolveOrderRemittance() (services/invoice/
remittance.server.ts) returns the single source of truth for the
reference: latest non-cancelled Invoice row for the order, falling
back to '${prefix}${orderNumber}' when no PDF has been generated yet.
Both /api/public/payment-info and /api/public/girocode.png now route
through it, so the thank-you page, the customer-account page and the
GiroCode QR are guaranteed to match the PDF byte-for-byte.
2) Drop the redundant '\u00b7 Bestellnummer: #1004' suffix from the PDF
title when the invoice number's trailing digits already match the
Shopify order name (default 'order_number' numbering mode). In that
mode the two strings carry identical numeric content and the suffix
only adds noise; sequential mode (RE-7 vs #1004) keeps the suffix.
- New smoke assertion verifies the suppression triggers on
invoiceNumber='RE-1004' + orderName='#1004' and that the invoice
number itself is still shown.
- Both endpoints now also query 'Order.number' (already covered by
read_orders) so the fallback path can build the prefix+order-number
string without requiring the Invoice row.
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.
- New custom server.js (replaces react-router-serve): ISO timestamps on
all console.* output and on access logs, and skip successful /healthz
polls so real traffic stays visible.
- New ProcessedWebhook table + dedupe helper keyed on
X-Shopify-Webhook-Id; stops Shopify retries from triggering a second
invoice email when the original delivery exceeded the 5s ack timeout.
- orders/create + orders/fulfilled now respond 200 immediately and run
the PDF/email work in the background so we stay under that timeout.
- pickLanguage(): non-German locales (it, fr, es, ...) now default to
English instead of falling back to German. Empty/unknown still maps to
'de' so the per-shop defaultLanguage chain keeps working.
- Tests for pickLanguage and dedupe via node --test + tsx.
- Drop wireTransferGatewayNames from ShopSettings (new migration).
- Replace string-matching with a GraphQL query against
Order.transactions[].manualPaymentGateway, the first-class flag
Shopify exposes for any merchant-defined manual payment method.
- Both webhook handlers now fetch the order on the fly to classify it,
removing the configurable gateway-names field from settings.
- New ShopSettings fields: autoEmailOnWireTransferPlaced,
autoEmailOnFulfilledNonWireTransfer, wireTransferGatewayNames.
- New Automations section in settings with two toggles + gateway list.
- orders/create webhook now fires automation 1 (wire-transfer placed).
- New orders/fulfilled webhook fires automation 2 (non-wire-transfer fulfilled).
- Shared helper services/invoice/automations.server.ts handles classification
and idempotent generate+send (skips if already sent).
- Webhook subscription for orders/fulfilled added to all 3 app tomls.
This is the non-Plus fallback for Shopify Flow, whose custom-app actions
are gated to Plus stores only.