Commit Graph

14 Commits

Author SHA1 Message Date
Gerhard Scheikl 40ee895719 feat(invoice): show refund + outstanding-amount rows on the PDF
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.
2026-05-15 16:45:09 +02:00
Gerhard Scheikl 9c732618e1 fix(invoice): suppress GiroCode + payment terms for refunded (and paid) orders
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.
2026-05-15 16:33:34 +02:00
Gerhard Scheikl 09769153be fix(invoice): drop salutation from PDF (it's an invoice, not a letter)
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.
2026-05-15 16:16:10 +02:00
Gerhard Scheikl c24d567ae4 fix(invoice): localize Shopify payment-gateway names on the PDF
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.
2026-05-15 16:08:19 +02:00
Gerhard Scheikl 2a4a7fd983 fix(invoice): unify customer-facing remittance reference with the printed invoice number
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.
2026-05-15 15:51:10 +02:00
Gerhard Scheikl a2b3c14022 fix(invoice): detect pickup via missing shippingAddress (real signal for built-in 'Shop location' rate)
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.
2026-05-15 15:24:43 +02:00
Gerhard Scheikl 8a40bcbee6 fix(invoice): use shippingLine.deliveryCategory as primary pickup signal
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.
2026-05-15 15:12:59 +02:00
Gerhard Scheikl f16ef4e103 fix(invoice): drop fulfillmentOrders query (scope-denied), keep shippingLine pickup heuristic
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.
2026-05-15 15:04:16 +02:00
Gerhard Scheikl d742e75419 fix(invoice): detect pickup via DeliveryMethodType and show 'Abholort: <location>' meta row
- 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.
2026-05-15 14:46:55 +02:00
Gerhard Scheikl 415a9dd462 feat(invoice): per-line + cart discounts, fulfillment delivery date, pickup label, header layout refresh
- 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.
2026-05-15 13:59:08 +02:00
Gerhard Scheikl 8780b4a68a feat(invoice): add Shopify order #, shipping address/method/cost and tracking
- 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).
2026-05-15 13:41:53 +02:00
Gerhard Scheikl 55a0dd03f2 feat(invoice): informal German tone + show payment method and status
- 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.
2026-05-15 11:26:26 +02:00
Gerhard Scheikl 770c6fd16a many updates :-) 2026-05-08 10:40:19 +02:00
Gerhard Scheikl 5b2aa5d62b first version 2026-04-28 21:56:11 +02:00