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