Compare commits

..

64 Commits

Author SHA1 Message Date
Gerhard Scheikl 2837731815 fixed issue in order cancellation 2026-05-31 11:12:20 +02:00
Gerhard Scheikl 15c62627be switch to newer nodejs version 2026-05-31 10:54:00 +02:00
Gerhard Scheikl 01b4734477 security hardening 2026-05-31 09:35:31 +02:00
Gerhard Scheikl d7d437a871 removed duplicate MCP server entry 2026-05-30 22:27:02 +02:00
Gerhard Scheikl c5b6bfc20d refactor(invoice): drop dead paid/paidStamp; classify VOIDED; warn on unknown payment status
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.
2026-05-15 18:12:06 +02:00
Gerhard Scheikl 91c1a74c1b fix(invoice): partial refund stays "Bezahlt" + use "Endbetrag" final-row label
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.
2026-05-15 16:56:45 +02:00
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 fe54f6e64a feat(invoice): make the phone number on the PDF a clickable tel: link
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.
2026-05-15 16:20:55 +02:00
Gerhard Scheikl d5bdc41e0a start german thankYouLine with a capital letter 2026-05-15 16:18:10 +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 720f508ec3 chore: gitignore .react-router/ generated types 2026-05-15 15:13:19 +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 4e522f41df chore: gitignore log.txt (debug artifact) 2026-05-15 15:04:42 +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 dde53319e5 fix(observability,webhooks,i18n): timestamped logs, dedupe webhook retries, default non-de locales to English
- 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.
2026-05-15 11:02:17 +02:00
Gerhard Scheikl 274ccfbc01 attempt to fix mail sending 2026-05-09 22:26:04 +02:00
Gerhard Scheikl 3a77bed716 fix security issues 2026-05-09 22:19:25 +02:00
Gerhard Scheikl c45648832a fix(customer-account): retarget to order-status.payment-details.render-after 2026-05-09 21:52:47 +02:00
Gerhard Scheikl ca769c49a4 feat(customer-account): payment extension for order page (shares /api/public/payment-info; dual auth) 2026-05-09 21:45:27 +02:00
Gerhard Scheikl 5ac2e09f8c cleanup(thank-you): remove debug, render nothing when no payment data 2026-05-09 21:38:16 +02:00
Gerhard Scheikl 53ce591f55 image test 2026-05-09 21:26:32 +02:00
Gerhard Scheikl f6c5d108ad fix(thank-you): force https for QR PNG URL behind TLS-terminating proxy 2026-05-09 21:19:01 +02:00
Gerhard Scheikl 3fb8600402 fix(thank-you): serve GiroCode as signed PNG URL instead of data URL 2026-05-09 21:14:47 +02:00
Gerhard Scheikl f59c981ff4 attempted fix for girocode in thank you page 2026-05-09 21:11:11 +02:00
Gerhard Scheikl cc7cedfedb fix(payment-info): rewrite OrderIdentity GID to Order GID; surface error detail; brief retry 2026-05-09 21:07:39 +02:00
Gerhard Scheikl 8bc86ef985 payment info updates 2026-05-09 21:05:09 +02:00
Gerhard Scheikl 35dea965f6 feat(thank-you): make extension APP_URL shop-aware (dev shop -> dev backend) 2026-05-09 20:52:10 +02:00
Gerhard Scheikl 884070cddc feat(thank-you): payment instructions extension (GiroCode + bank details) for manual payment orders 2026-05-09 20:48:08 +02:00
Gerhard Scheikl 93aec2f368 refactor(automations): detect manual payment via OrderTransaction.manualPaymentGateway
- 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.
2026-05-09 20:31:31 +02:00
Gerhard Scheikl 0800d1160b feat(automations): auto-email invoice on wire-transfer placed and on fulfillment
- 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.
2026-05-09 20:21:41 +02:00
Gerhard Scheikl a99dbc51c5 attempted fix for flow actions 2026-05-09 20:09:27 +02:00
Gerhard Scheikl 5061dbb3d5 remove unwanted characters in file name 2026-05-09 19:48:18 +02:00
Gerhard Scheikl 6ded8ec1b9 fix(offers): add read_draft_orders scope so draftOrders query works 2026-05-09 19:41:12 +02:00
Gerhard Scheikl 6224597497 feat(offers): generate Angebot/Offer PDFs for draft orders 2026-05-09 19:26:33 +02:00
Gerhard Scheikl 1ec4faaac5 security: restrict installs to ALLOWED_SHOP and remove generic landing form 2026-05-09 18:01:14 +02:00
Gerhard Scheikl ecd2b00985 feat(pdf): make footer email and website clickable 2026-05-09 17:45:56 +02:00
Gerhard Scheikl 8cceb8af66 fix: use invoice.number for girocode reference 2026-05-09 17:41:42 +02:00
Gerhard Scheikl 9bfce39db2 feat(girocode): use full company name + add Recipient/Bank/Amount/Reference labels 2026-05-09 17:41:22 +02:00
Gerhard Scheikl 85a56cac59 fix(settings): preserve stored logo + persist editor changes on save 2026-05-09 17:17:55 +02:00
Gerhard Scheikl d454843856 fix(email): use invoice language so email matches PDF attachment 2026-05-09 17:14:20 +02:00
Gerhard Scheikl b5d41046a0 feat(invoices): make order number link to admin order page 2026-05-09 17:11:13 +02:00
Gerhard Scheikl cc159f9b6b feat(ui): add Send/Re-send button on invoices page and order block 2026-05-09 17:09:33 +02:00
Gerhard Scheikl 227c00b3a0 fix(settings): place save banner next to Save button 2026-05-09 16:21:55 +02:00
Gerhard Scheikl f97d6dc9d2 feat(email): text colour menu in WYSIWYG (LinumIQ blue + presets) 2026-05-09 16:11:49 +02:00
Gerhard Scheikl 26e4af97bc fix(email): preserve <img style> in WYSIWYG so logo stays scaled 2026-05-09 08:22:07 +02:00
Gerhard Scheikl 67204d79ac feat(email): render shop logo inside WYSIWYG editor (cid swap) 2026-05-09 08:05:00 +02:00
Gerhard Scheikl 573dfbfd50 feat(email): default template with inline logo + shop contact vars
Mirrors the layout from data/mail_template.png:
- Company name + greeting headline
- Body referencing the invoice number
- Inline logo (cid:invoice-logo) attached automatically
- Footer with mailto + website links

New template vars: {{shopEmail}}, {{shopWebsite}}.
Settings UI prefills empty fields with the defaults so users see and
can tweak them without losing the fallback.
2026-05-08 23:12:23 +02:00
Gerhard Scheikl 04933fcac6 feat(email): WYSIWYG template editor with variable substitution
- Add emailSubject{De,En} + emailBodyHtml{De,En} to ShopSettings
- New RichTextEditor component (TipTap) with toolbar + variable insert
- Settings UI: Email templates section per language
- email.server.ts: substitute {{var}} placeholders, fall back to defaults
- Default vars: invoiceNumber, customerName, customerFirstName, orderName,
  totalGross, dueDate, companyName, ownerName
2026-05-08 23:06:40 +02:00
Gerhard Scheikl 537dfd34cb fix(pdf): hide payment terms text on paid invoices 2026-05-08 22:52:18 +02:00
Gerhard Scheikl 64ac54d3c3 fix(ui): align section headings (drop padding=none + redundant inner s-box) 2026-05-08 22:47:10 +02:00
Gerhard Scheikl 093db30b6c feat(email): always BCC shop@linumiq.com on outgoing invoice mails 2026-05-08 22:44:21 +02:00
Gerhard Scheikl 02a93b502b fix(health): add /healthz route and tighten docker healthcheck 2026-05-08 22:42:09 +02:00
Gerhard Scheikl 64dbdcbc6f set context for linumiq.com deployment 2026-05-08 22:07:38 +02:00
Gerhard Scheikl bbb2cdc94a fixed dockerfile location in final folder 2026-05-08 22:02:01 +02:00
Gerhard Scheikl e865bc5985 deploy: add shopify.app.prod.toml (linumiq-invoice prod app, client_id c5cb73...) 2026-05-08 21:50:33 +02:00
Gerhard Scheikl 9557a3b335 deploy: split into dev/prod with separate Shopify configs and containers
- shopify.app.toml -> shopify.app.dev.toml (domain: invoice-app-dev.linumiq.com)
- New shopify.app.prod.toml will be created via shopify app config link --config prod
- docker-compose split into deploy/docker-compose.{dev,prod}.yml with distinct
  container names (linumiq-invoice-{dev,prod}), images, env files and bind mounts
- Caddyfile snippet maps both subdomains to their respective containers
- .env.{dev,prod}.example templates committed in deploy/
- deploy/README.md documents the layout and day-to-day workflow
2026-05-08 21:41:22 +02:00
77 changed files with 6687 additions and 370 deletions
+15
View File
@@ -8,9 +8,24 @@ node_modules
!.env.production.example
prisma/dev.sqlite
prisma/dev.sqlite-journal
# Any local SQLite DB / journal / WAL must never enter the image.
prisma/*.sqlite*
prisma/dev.sqlite*
data/
.shopify
.git
.github
*.log
extensions/*/dist
# Dev-only tooling / docs — not needed at build or runtime.
# NOTE: prisma/schema.prisma and prisma/migrations are intentionally NOT
# excluded (required by `prisma generate` and `prisma migrate deploy`).
tests/
scripts/
.cursor/
.gemini/
.vscode/
*.md
**/*.md
+5 -1
View File
@@ -19,6 +19,9 @@ database.sqlite
.env
.env.*
!.env.production.example
!deploy/.env.dev.example
!deploy/.env.prod.example
@@ -31,4 +34,5 @@ database.sqlite
.shopify.lock
# Hide files auto-generated by react router
.react-router/
.react-router/log.txt
.react-router/
-9
View File
@@ -1,9 +0,0 @@
{
"mcpServers": {
"shopify-dev-mcp": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@shopify/dev-mcp@latest"]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"chat.tools.terminal.autoApprove": {
"setopt": true,
"npx shopify": true,
"npx tsx": true
}
}
+73 -5
View File
@@ -1,18 +1,86 @@
FROM node:20-alpine
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Base image pin
# ---------------------------------------------------------------------------
# Pinned to Node 24 (Active LTS, supported until ~April 2028) so rebuilds are
# reproducible and satisfy the package.json `engines` constraint
# (">=20.19 <22 || >=22.12"). Node 20 is EOL (~April 2026) and its frozen
# `20.19-alpine` snapshot accumulates unpatched CVEs, so we track the
# actively-patched 24.x line instead.
# A digest pin is PREFERRED for full immutability, e.g.:
# FROM node:24-alpine@sha256:<real-digest>
# Add the real sha256 (from `docker buildx imagetools inspect node:24-alpine`)
# when you have network access. We do NOT invent a fake digest here.
# ===========================================================================
# Stage 1 — builder: install ALL deps, generate Prisma client, build the app
# ===========================================================================
FROM node:24-alpine AS builder
# openssl is required by Prisma's engines.
RUN apk add --no-cache openssl
EXPOSE 3000
WORKDIR /app
# Install the full dependency tree (incl. devDependencies needed by the
# Vite / React Router build toolchain). NODE_ENV is intentionally left unset
# here so `npm ci` does not prune devDependencies.
COPY package.json package-lock.json* ./
RUN npm ci
# Copy the rest of the source and produce the production build + Prisma client.
COPY . .
RUN npx prisma generate \
&& npm run build
# ===========================================================================
# Stage 2 — runtime: pruned prod deps + only the artifacts needed to run
# ===========================================================================
FROM node:24-alpine AS runtime
# openssl for Prisma engines at runtime (migrate deploy / query engine).
RUN apk add --no-cache openssl
WORKDIR /app
ENV NODE_ENV=production
# Keep npm + Prisma incidental writes off the (dev) read-only root filesystem:
# both are redirected to the tmpfs-backed /tmp mount.
ENV NPM_CONFIG_CACHE=/tmp/.npm
ENV CHECKPOINT_DISABLE=1
# Install ONLY production dependencies.
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev && npm cache clean --force
COPY . .
# Prisma schema + migrations are needed for `prisma generate` (below) and for
# `prisma migrate deploy` on container start.
COPY --from=builder /app/prisma ./prisma
# Bake the generated Prisma client into the image so the container start
# command only has to run migrations (no generate at runtime → compatible with
# a read-only root filesystem).
RUN npx prisma generate
RUN npm run build
# Application artifacts required at runtime.
COPY --from=builder /app/build ./build
COPY --from=builder /app/public ./public
COPY --from=builder /app/server.js ./server.js
# Run as the unprivileged, pre-existing `node` user (uid/gid 1000). Ensure the
# app tree is owned by it. NOTE: the SQLite DB is written to the /data bind
# mount — the HOST directory mounted at /data MUST be chown'd to uid 1000
# (see deploy/README.md), otherwise migrations/writes will fail as non-root.
RUN chown -R node:node /app
USER node
EXPOSE 3000
# Container-level health probe hitting the unauthenticated /healthz route.
# busybox wget ships with node:alpine.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
# Functionally equivalent to today's start: runs DB migrations then the server.
# (`docker-start` no longer needs `prisma generate` — the client is baked above.)
CMD ["npm", "run", "docker-start"]
+372
View File
@@ -0,0 +1,372 @@
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { useEffect, useState } from "react";
const PRESET_COLORS = [
{ name: "Default", value: null as string | null },
{ name: "LinumIQ blue", value: "#0883DA" },
{ name: "Black", value: "#000000" },
{ name: "Grey", value: "#6d7175" },
{ name: "Red", value: "#d72c0d" },
{ name: "Green", value: "#1a7e3a" },
];
interface RichTextEditorProps {
/** Hidden form field name; the rendered HTML is mirrored into it. */
name: string;
/** Visible label rendered above the editor. */
label: string;
/** Initial HTML loaded into the editor. */
defaultValue?: string;
/** Optional helper text shown below the editor. */
helpText?: string;
/** Insertable variable tokens shown as quick-insert buttons. */
variables?: { token: string; label?: string }[];
/** Min editor height in px. */
minHeight?: number;
/**
* If set, occurrences of `cid:invoice-logo` (in src attributes) are
* displayed using this data/HTTPS URL inside the editor, but mirrored
* back to `cid:invoice-logo` in the submitted hidden field. This keeps
* the stored template portable while showing the real logo while editing.
*/
logoDataUrl?: string | null;
}
/**
* TipTap-based WYSIWYG editor that mirrors its HTML content into a hidden
* input so the parent <Form> can submit it like any other field.
*
* Variables like {{invoiceNumber}} are inserted as plain text — the email
* renderer is responsible for substituting them at send time.
*/
export function RichTextEditor({
name,
label,
defaultValue = "",
helpText,
variables = [],
minHeight = 200,
logoDataUrl = null,
}: RichTextEditorProps) {
// TipTap calls into the DOM on init; defer mounting until after hydration
// so SSR markup matches the initial client render.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// Mirror editor HTML into local state so the hidden <input> always
// reflects the latest content. Without this, React doesn't re-render
// when TipTap's content changes and the form submits stale HTML.
const initialHtml = swapCidToLogo(defaultValue || "<p></p>", logoDataUrl);
const [html, setHtml] = useState(initialHtml);
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
// Drop heading levels we don't need to keep the toolbar focused.
heading: { levels: [2, 3] },
}),
Link.configure({ openOnClick: false }),
// Extend Image so the inline `style` attribute (e.g. max-height) is
// preserved on parse — TipTap's default Image only keeps src/alt/title.
Image.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
default: "max-height:48px;",
parseHTML: (el) => (el as HTMLElement).getAttribute("style"),
renderHTML: (attrs) =>
attrs.style ? { style: attrs.style as string } : {},
},
};
},
}).configure({ inline: false, allowBase64: true }),
TextStyle,
Color,
],
content: swapCidToLogo(defaultValue || "<p></p>", logoDataUrl),
onUpdate: ({ editor }) => {
setHtml(editor.getHTML());
},
editorProps: {
attributes: {
class: "wysiwyg-editor",
style: `min-height:${minHeight}px;padding:8px 12px;border:1px solid #c9cccf;border-top:0;border-radius:0 0 6px 6px;background:#fff;outline:none;`,
},
},
});
// Keep editor disposed cleanly on unmount.
useEffect(() => () => editor?.destroy(), [editor]);
const submittedHtml = swapLogoToCid(html, logoDataUrl);
if (!mounted) {
// Server / pre-hydration fallback: a textarea so the value is still
// submittable if JS fails or before TipTap mounts.
return (
<div>
{label ? (
<label style={{ display: "block", fontSize: 14, fontWeight: 500, marginBottom: 6 }}>
{label}
</label>
) : null}
<textarea
name={name}
defaultValue={defaultValue}
style={{ width: "100%", minHeight, padding: 8, border: "1px solid #c9cccf", borderRadius: 6 }}
/>
{helpText ? (
<div style={{ fontSize: 12, color: "#6d7175", marginTop: 4 }}>{helpText}</div>
) : null}
</div>
);
}
return (
<div>
{label ? (
<label style={{ display: "block", fontSize: 14, fontWeight: 500, marginBottom: 6 }}>
{label}
</label>
) : null}
<div
role="toolbar"
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
padding: 6,
border: "1px solid #c9cccf",
borderRadius: "6px 6px 0 0",
background: "#f6f6f7",
}}
>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleBold().run()}
active={editor?.isActive("bold") ?? false}
label="B"
title="Bold"
bold
/>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleItalic().run()}
active={editor?.isActive("italic") ?? false}
label="I"
title="Italic"
italic
/>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleStrike().run()}
active={editor?.isActive("strike") ?? false}
label="S"
title="Strikethrough"
/>
<Sep />
<ToolbarButton
onClick={() => editor?.chain().focus().setParagraph().run()}
active={editor?.isActive("paragraph") ?? false}
label="¶"
title="Paragraph"
/>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor?.isActive("heading", { level: 2 }) ?? false}
label="H2"
title="Heading 2"
/>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor?.isActive("heading", { level: 3 }) ?? false}
label="H3"
title="Heading 3"
/>
<Sep />
<ToolbarButton
onClick={() => editor?.chain().focus().toggleBulletList().run()}
active={editor?.isActive("bulletList") ?? false}
label="• List"
title="Bullet list"
/>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
active={editor?.isActive("orderedList") ?? false}
label="1. List"
title="Numbered list"
/>
<Sep />
<ToolbarButton
onClick={() => {
const url = window.prompt("URL");
if (url) editor?.chain().focus().setLink({ href: url }).run();
else editor?.chain().focus().unsetLink().run();
}}
active={editor?.isActive("link") ?? false}
label="Link"
title="Insert/remove link"
/>
<Sep />
<ColorMenu editor={editor} />
<Sep />
<ToolbarButton
onClick={() => editor?.chain().focus().undo().run()}
active={false}
label="↺"
title="Undo"
/>
<ToolbarButton
onClick={() => editor?.chain().focus().redo().run()}
active={false}
label="↻"
title="Redo"
/>
</div>
<EditorContent editor={editor} />
<input type="hidden" name={name} value={submittedHtml} />
{variables.length > 0 ? (
<div style={{ marginTop: 6, display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" }}>
<span style={{ fontSize: 12, color: "#6d7175", marginRight: 4 }}>Insert variable:</span>
{variables.map((v) => (
<button
key={v.token}
type="button"
onClick={() => editor?.chain().focus().insertContent(v.token).run()}
style={{
fontSize: 12,
padding: "2px 8px",
border: "1px solid #c9cccf",
borderRadius: 12,
background: "#fff",
cursor: "pointer",
}}
title={`Inserts ${v.token}`}
>
{v.label ?? v.token}
</button>
))}
</div>
) : null}
{helpText ? (
<div style={{ fontSize: 12, color: "#6d7175", marginTop: 4 }}>{helpText}</div>
) : null}
</div>
);
}
function ToolbarButton({
onClick,
active,
label,
title,
bold,
italic,
}: {
onClick: () => void;
active: boolean;
label: string;
title: string;
bold?: boolean;
italic?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
style={{
padding: "4px 10px",
background: active ? "#e3e5e7" : "#fff",
border: "1px solid #c9cccf",
borderRadius: 4,
fontWeight: bold ? 700 : 500,
fontStyle: italic ? "italic" : "normal",
cursor: "pointer",
fontSize: 13,
minWidth: 28,
}}
>
{label}
</button>
);
}
function Sep() {
return <span aria-hidden style={{ width: 1, background: "#c9cccf", margin: "2px 4px" }} />;
}
function ColorMenu({ editor }: { editor: ReturnType<typeof useEditor> | null }) {
if (!editor) return null;
return (
<span style={{ display: "inline-flex", gap: 2, alignItems: "center" }} title="Text colour">
{PRESET_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() =>
c.value
? editor.chain().focus().setColor(c.value).run()
: editor.chain().focus().unsetColor().run()
}
title={c.name}
style={{
width: 20,
height: 20,
border: "1px solid #c9cccf",
borderRadius: 4,
background: c.value ?? "#fff",
cursor: "pointer",
position: "relative",
}}
>
{c.value === null ? (
<span
aria-hidden
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
}}
>
×
</span>
) : null}
</button>
))}
</span>
);
}
/**
* Replaces `src="cid:invoice-logo"` with the supplied URL so the editor
* can display the actual logo. Done as a string replace because TipTap
* doesn't render `cid:` URLs.
*/
function swapCidToLogo(html: string, logoUrl: string | null): string {
if (!logoUrl) return html;
const escaped = logoUrl.replace(/"/g, "&quot;");
return html
.replace(/src="cid:invoice-logo"/g, `src="${escaped}"`)
.replace(/src='cid:invoice-logo'/g, `src='${escaped}'`);
}
/** Inverse of swapCidToLogo — ensures the cid token is what we store. */
function swapLogoToCid(html: string, logoUrl: string | null): string {
if (!logoUrl) return html;
const escaped = logoUrl.replace(/"/g, "&quot;");
return html
.replace(new RegExp(`src="${escapeRegExp(escaped)}"`, "g"), 'src="cid:invoice-logo"')
.replace(new RegExp(`src='${escapeRegExp(escaped)}'`, "g"), "src='cid:invoice-logo'");
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+20 -35
View File
@@ -1,56 +1,41 @@
import type { LoaderFunctionArgs } from "react-router";
import { redirect, Form, useLoaderData } from "react-router";
import { login } from "../../shopify.server";
import { redirect, useLoaderData } from "react-router";
import styles from "./styles.module.css";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const allowedShop = process.env.ALLOWED_SHOP?.trim();
const shop = url.searchParams.get("shop");
if (url.searchParams.get("shop")) {
throw redirect(`/app?${url.searchParams.toString()}`);
// If a shop param is present and it's the allow-listed merchant, send them
// straight into the embedded app. Any other shop is rejected so this URL
// can't be used to install the app on arbitrary stores.
if (shop) {
if (!allowedShop || shop.toLowerCase() === allowedShop.toLowerCase()) {
throw redirect(`/app?${url.searchParams.toString()}`);
}
throw new Response("This app is private and not available for installation.", { status: 403 });
}
return { showForm: Boolean(login) };
return { allowedShop: allowedShop ?? null };
};
export default function App() {
const { showForm } = useLoaderData<typeof loader>();
const { allowedShop } = useLoaderData<typeof loader>();
return (
<div className={styles.index}>
<div className={styles.content}>
<h1 className={styles.heading}>A short heading about [your app]</h1>
<h1 className={styles.heading}>LinumIQ Invoice</h1>
<p className={styles.text}>
A tagline about [your app] that describes your value proposition.
Private Shopify app for issuing GoBD-compliant PDF invoices.
</p>
<p className={styles.text}>
{allowedShop
? `This installation is reserved for ${allowedShop}. Open the app from the Shopify admin.`
: "Open the app from the Shopify admin."}
</p>
{showForm && (
<Form className={styles.form} method="post" action="/auth/login">
<label className={styles.label}>
<span>Shop domain</span>
<input className={styles.input} type="text" name="shop" />
<span>e.g: my-shop-domain.myshopify.com</span>
</label>
<button className={styles.button} type="submit">
Log in
</button>
</Form>
)}
<ul className={styles.list}>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
</ul>
</div>
</div>
);
+12 -6
View File
@@ -15,15 +15,17 @@ import { sendInvoiceEmail } from "../services/invoice/email.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { session, cors } = await authenticate.admin(request);
const orderId = requireOrderId(params);
const url = new URL(request.url);
const kind = (url.searchParams.get("kind") === "offer" ? "offer" : "invoice") as "invoice" | "offer";
const orderGid = orderId.startsWith("gid://")
? orderId
: `gid://shopify/Order/${orderId}`;
: `gid://shopify/${kind === "offer" ? "DraftOrder" : "Order"}/${orderId}`;
const invoices = await db.invoice.findMany({
where: { shopDomain: session.shop, orderId: orderGid },
orderBy: [{ issuedAt: "desc" }],
});
const latest = invoices.find((i) => i.kind === "invoice" && !i.cancelledAt);
const latest = invoices.find((i) => i.kind === kind && !i.cancelledAt);
return cors(
Response.json({
@@ -41,15 +43,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const orderId = requireOrderId(params);
const url = new URL(request.url);
let op = url.searchParams.get("action");
if (!op) {
// Also accept the action from the form body (used by the in-app fetcher).
let kindParam = url.searchParams.get("kind");
if (!op || !kindParam) {
// Also accept the action / kind from the form body (used by the in-app fetcher).
const ct = request.headers.get("content-type") || "";
if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
const form = await request.formData();
op = (form.get("action") as string | null) ?? null;
op = op ?? ((form.get("action") as string | null) ?? null);
kindParam = kindParam ?? ((form.get("kind") as string | null) ?? null);
}
}
op = op ?? "generate";
const kind: "invoice" | "offer" = kindParam === "offer" ? "offer" : "invoice";
try {
if (op === "cancel_reissue") {
@@ -109,8 +114,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
shopDomain: session.shop,
admin,
orderId,
kind,
});
return cors(Response.json({ ok: true, op: "generate", ...result }));
return cors(Response.json({ ok: true, op: kind === "offer" ? "generate_offer" : "generate", ...result }));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("invoice action failed:", err);
+100
View File
@@ -0,0 +1,100 @@
import type { LoaderFunctionArgs } from "react-router";
import { unauthenticated } from "../shopify.server";
import db from "../db.server";
import { buildGiroCodePngBuffer } from "../services/invoice/girocode";
import { verifyGiroCodeUrl } from "../services/invoice/signedUrl";
import { resolveOrderRemittance } from "../services/invoice/remittance.server";
/**
* Public PNG endpoint that returns the GiroCode QR image bytes for an order.
* Auth: short-lived HMAC-signed URL (issued by /api/public/payment-info).
*
* Required query params: shop, orderId, exp, sig.
*/
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const verified = verifyGiroCodeUrl(url.searchParams);
if (!verified.ok) {
return new Response(`unauthorized: ${verified.reason ?? "invalid"}`, { status: 401 });
}
const { shop, orderId } = verified;
if (!shop || !orderId) {
return new Response("bad request", { status: 400 });
}
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.iban) {
return new Response("not found", { status: 404 });
}
// Recompute payload server-side from order + settings (don't trust client).
const numericId = orderId.replace(/[^0-9]/g, "");
const orderGid = `gid://shopify/Order/${numericId}`;
const { admin } = await unauthenticated.admin(shop);
const res = await admin.graphql(
`#graphql
query GiroCodeOrderInfo($id: ID!) {
order(id: $id) {
name
number
currencyCode
totalPriceSet { shopMoney { amount } }
totalOutstandingSet { shopMoney { amount } }
}
}`,
{ variables: { id: orderGid } },
);
const json = (await res.json()) as {
data?: {
order?: {
name?: string;
number?: number | null;
currencyCode?: string;
totalPriceSet?: { shopMoney: { amount: string } };
totalOutstandingSet?: { shopMoney: { amount: string } };
} | null;
};
};
const o = json.data?.order;
if (!o) {
return new Response("not found", { status: 404 });
}
const total = parseFloat(o.totalPriceSet?.shopMoney.amount ?? "0");
const outstanding = parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0");
const amount = outstanding > 0 ? outstanding : total;
// Use the canonical invoice number printed on the PDF — keeping the QR
// and the customer-facing thank-you/account page in lockstep so the
// bank treats both as one and the same payment.
const remittance = await resolveOrderRemittance({
shopDomain: shop,
orderGid,
orderNumber: typeof o.number === "number" ? o.number : null,
settings,
});
const png = await buildGiroCodePngBuffer({
beneficiaryName: [settings.companyName, settings.legalForm].filter(Boolean).join(" "),
iban: settings.iban,
bic: settings.bic,
amount,
currency: o.currencyCode ?? "EUR",
remittance,
});
const body = new Uint8Array(png);
return new Response(body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "private, max-age=300",
// No CORS header: the PNG is rendered via an <s-image> tag in the
// checkout/customer-account extensions (see extensions/*/src/*.tsx),
// i.e. a plain image load, which is not subject to CORS. Dropping the
// previous `Access-Control-Allow-Origin: *` removes the ability for any
// origin to fetch() these bytes cross-origin while keeping the
// legitimate <img>-style loads working.
},
});
};
+320
View File
@@ -0,0 +1,320 @@
import crypto from "node:crypto";
import type { LoaderFunctionArgs } from "react-router";
import { authenticate, unauthenticated } from "../shopify.server";
import db from "../db.server";
import { formatMoney, formatDate, addDays } from "../services/invoice/format";
import { getStrings, pickLanguage } from "../services/invoice/i18n";
import { signGiroCodeUrl } from "../services/invoice/signedUrl";
import { resolveOrderRemittance } from "../services/invoice/remittance.server";
/**
* Public endpoint consumed by the checkout / thank-you UI extension AND by
* the customer-account order page extension to fetch payment instructions
* (GiroCode + bank details) for an order.
*
* Auth: validated Shopify session token. The handler tries
* `authenticate.public.customerAccount` first and falls back to
* `authenticate.public.checkout` so a single endpoint serves both surfaces.
* The shop domain is derived from `sessionToken.dest`; the order id is read
* from the `?orderId=` query parameter (numeric or GID, both accepted).
*
* Returns:
* { showPaymentInstructions: boolean, payload?: { ... } }
*
* `payload` is only populated when:
* - the order has at least one transaction processed by a manual payment
* gateway (Shopify's `manualPaymentGateway` flag), and
* - the shop has an IBAN configured.
*/
export const loader = async ({ request }: LoaderFunctionArgs) => {
type AuthSource = "customerAccount" | "checkout";
type SessionTokenLike = { dest?: string; sub?: string };
type CorsFn = (res: Response) => Response;
let sessionToken: SessionTokenLike | null = null;
let cors: CorsFn = (r) => r;
let authSource: AuthSource | null = null;
try {
const auth = await authenticate.public.customerAccount(request);
sessionToken = auth.sessionToken as SessionTokenLike;
cors = auth.cors as CorsFn;
authSource = "customerAccount";
} catch {
try {
const auth = await authenticate.public.checkout(request);
sessionToken = auth.sessionToken as SessionTokenLike;
cors = auth.cors as CorsFn;
authSource = "checkout";
} catch (err) {
throw err;
}
}
const shop = (sessionToken?.dest ?? "").toString().replace(/^https?:\/\//, "");
if (!shop) {
return cors(Response.json({ showPaymentInstructions: false, error: "no-shop" }, { status: 400 }));
}
const url = new URL(request.url);
const orderIdRaw = url.searchParams.get("orderId");
if (!orderIdRaw) {
return cors(Response.json({ showPaymentInstructions: false, error: "no-order-id" }, { status: 400 }));
}
// The thank-you page exposes the order id as an `OrderIdentity` GID
// (e.g. `gid://shopify/OrderIdentity/123`). For the Admin API we need an
// `Order` GID. The numeric id is the same — just rewrite the type segment.
const numericId = orderIdRaw.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
if (!numericId) {
return cors(Response.json({ showPaymentInstructions: false, error: "bad-order-id" }, { status: 400 }));
}
const orderGid = `gid://shopify/Order/${numericId}`;
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.iban || !settings.giroCodeEnabled) {
// No bank details / GiroCode disabled — nothing to render.
return cors(Response.json({ showPaymentInstructions: false, reason: "no-iban-or-disabled" }));
}
let orderInfo: OrderInfo | null = null;
try {
const { admin } = await unauthenticated.admin(shop);
// Brief retry: the Order may not be queryable for a moment after creation.
let lastErr: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
orderInfo = await fetchOrderInfo(admin, orderGid);
if (orderInfo) break;
} catch (e) {
lastErr = e;
}
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
if (!orderInfo && lastErr) throw lastErr;
} catch (err) {
// Log the upstream detail server-side only — never echo internal error
// messages (which may contain Admin API internals / order data) to the
// public client.
console.error(`payment-info: failed to load order ${orderGid} for ${shop}:`, err);
return cors(
Response.json(
{ showPaymentInstructions: false, error: "order-load-failed" },
{ status: 502 },
),
);
}
if (!orderInfo || !orderInfo.isManual) {
return cors(
Response.json({
showPaymentInstructions: false,
reason: "not-manual-payment",
}),
);
}
// ---- Ownership check ----
// Without this, any authenticated buyer of the shop could enumerate
// arbitrary orderIds and harvest the shop's bank details / amounts.
//
// Token claims available (see @shopify/shopify-api `JwtPayload`): only the
// standard JWT fields — iss, dest, aud, sub, exp, nbf, iat, jti, sid. There
// is NO order- or checkout-scoped claim in either the checkout or the
// customer-account session token, so we cannot bind the token to the
// requested orderId directly. We therefore bind by customer identity where
// possible and fall back to a tightened recency window for true guests.
//
// - customerAccount tokens always carry a customer GID in `sub`. We require
// that the order's customer matches (strong binding — preferred path).
// - Checkout (thank-you page) tokens for logged-in buyers also carry the
// customer GID in `sub`; we bind to it identically.
// - For guest checkouts (no customer on the order, checkout source only) we
// have nothing in the token to bind against. We accept only when the order
// was placed within a SHORT window — the thank-you page is rendered
// immediately after checkout, so a few minutes is ample for the legitimate
// flow. Residual risk: within this small window an attacker holding ANY
// valid checkout token for this shop could enumerate the numeric ids of
// very-recently-placed guest orders and read their amount/reference. This
// is mitigated (not eliminated) by (a) the short window, (b) per-IP rate
// limiting on /api/public/* (see server.js), and (c) numeric order ids
// being unguessable in the short window. The customer-account path remains
// the preferred, fully-bound surface.
const tokenSub = (sessionToken?.sub ?? "").toString();
const tokenCustomerNumeric = tokenSub.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
const orderCustomerNumeric = (orderInfo.customerId ?? "").replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
let ownershipOk = false;
if (orderCustomerNumeric && tokenCustomerNumeric) {
ownershipOk = orderCustomerNumeric === tokenCustomerNumeric;
} else if (authSource === "checkout" && !orderCustomerNumeric) {
// Guest checkout: no customer to bind against. Accept only if the order is
// very fresh (the buyer has just completed checkout for it). Kept short to
// shrink the enumeration window — see residual-risk note above.
const placedAtMs = orderInfo.processedAtMs ?? 0;
const RECENT_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
ownershipOk = placedAtMs > 0 && Date.now() - placedAtMs <= RECENT_WINDOW_MS;
}
if (!ownershipOk) {
// Minimal correlation log: never log raw customer identifiers (PII). Hash
// the token subject (sha256, truncated) so repeated abuse from the same
// principal is still correlatable without storing the GID itself.
const subHash = tokenSub
? crypto.createHash("sha256").update(tokenSub).digest("hex").slice(0, 12)
: "-";
console.warn(
`payment-info: ownership check failed for shop=${shop} order=${orderGid} ` +
`authSource=${authSource} subHash=${subHash}`,
);
return cors(
Response.json(
{ showPaymentInstructions: false, error: "forbidden" },
{ status: 403 },
),
);
}
const language = pickLanguage(orderInfo.customerLocale ?? settings.defaultLanguage);
const t = getStrings(language);
// Outstanding amount: prefer totalOutstanding (set by Shopify for unpaid),
// fall back to totalPrice when zero.
const amount = orderInfo.outstandingAmount > 0 ? orderInfo.outstandingAmount : orderInfo.totalAmount;
// Always use the canonical invoice number (e.g. "RE-1034") as the
// remittance reference — NEVER the bare Shopify order name ("#1034"),
// because:
// (a) the customer sees this on the thank-you page and pastes it into
// their banking app; if it doesn't match what's printed on the PDF
// (which uses the invoice number), the bank treats them as two
// different payments, and
// (b) several banks reject "#" in the reference field.
const remittance = await resolveOrderRemittance({
shopDomain: shop,
orderGid,
orderNumber: orderInfo.orderNumber,
settings,
});
const giroCodeUrl = (() => {
const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour
const reqUrl = new URL(request.url);
// Behind a reverse proxy that terminates TLS the inbound URL is http.
// Trust X-Forwarded-Proto, otherwise force https for any non-localhost host.
const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
const isLocal = reqUrl.hostname === "localhost" || reqUrl.hostname === "127.0.0.1";
const proto = forwardedProto ?? (isLocal ? reqUrl.protocol.replace(":", "") : "https");
const origin = `${proto}://${reqUrl.host}`;
const qs = signGiroCodeUrl({ shop, orderId: numericId, exp });
return `${origin}/api/public/girocode.png?${qs}`;
})();
const dueDate = settings.paymentTermDays > 0
? addDays(new Date(), settings.paymentTermDays)
: null;
return cors(
Response.json({
showPaymentInstructions: true,
payload: {
language,
heading: t.giroCodeCaption,
giroCodeUrl,
recipient: [settings.companyName, settings.legalForm].filter(Boolean).join(" "),
bankName: settings.bankName,
iban: settings.iban,
bic: settings.bic,
amountFormatted: formatMoney(amount, orderInfo.currency, language),
reference: remittance,
dueDateFormatted: dueDate ? formatDate(dueDate, language) : null,
instructions: dueDate
? t.paymentTerms(settings.paymentTermDays, formatDate(dueDate, language))
: t.paymentTermsImmediate,
labels: {
recipient: t.recipientLabel,
bank: t.bankLabel,
iban: t.ibanLabel,
bic: t.bicLabel,
amount: t.amountLabel,
reference: t.referenceLabel,
},
},
}),
);
};
interface OrderInfo {
isManual: boolean;
totalAmount: number;
outstandingAmount: number;
currency: string;
orderName: string;
orderNumber: number | null;
customerLocale?: string;
customerId?: string;
processedAtMs?: number;
txCount: number;
manualFlags: Array<{ status?: string; manual?: boolean }>;
}
async function fetchOrderInfo(
admin: { graphql: (q: string, opts?: { variables?: Record<string, unknown> }) => Promise<Response> },
orderGid: string,
): Promise<OrderInfo | null> {
const res = await admin.graphql(
`#graphql
query OrderPaymentInfo($id: ID!) {
order(id: $id) {
name
number
currencyCode
customerLocale
processedAt
createdAt
customer { id }
totalPriceSet { shopMoney { amount } }
totalOutstandingSet { shopMoney { amount } }
transactions(first: 20) {
status
manualPaymentGateway
}
}
}`,
{ variables: { id: orderGid } },
);
const json = (await res.json()) as {
data?: {
order?: {
name?: string;
number?: number | null;
currencyCode?: string;
customerLocale?: string | null;
processedAt?: string | null;
createdAt?: string | null;
customer?: { id?: string } | null;
totalPriceSet?: { shopMoney: { amount: string } };
totalOutstandingSet?: { shopMoney: { amount: string } };
transactions?: Array<{ status?: string; manualPaymentGateway?: boolean }>;
} | null;
};
};
const o = json.data?.order;
if (!o) return null;
const txs = o.transactions ?? [];
const isManual = txs.some(
(t) => t.manualPaymentGateway === true && t.status !== "FAILURE" && t.status !== "ERROR",
);
return {
isManual,
totalAmount: parseFloat(o.totalPriceSet?.shopMoney.amount ?? "0"),
outstandingAmount: parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0"),
currency: o.currencyCode ?? "EUR",
orderName: o.name ?? "",
orderNumber: typeof o.number === "number" ? o.number : null,
customerLocale: o.customerLocale ?? undefined,
customerId: o.customer?.id ?? undefined,
processedAtMs: (() => {
const raw = o.processedAt ?? o.createdAt ?? null;
if (!raw) return undefined;
const t = Date.parse(raw);
return Number.isFinite(t) ? t : undefined;
})(),
txCount: txs.length,
manualFlags: txs.map((t) => ({ status: t.status, manual: t.manualPaymentGateway })),
};
}
+11 -16
View File
@@ -113,20 +113,17 @@ export default function Index() {
<s-section
heading="Recent invoices"
padding="none"
accessibilityLabel="Recent invoices table"
>
{recent.length === 0 ? (
<s-box padding="base">
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No invoices yet</s-text>
<s-paragraph tone="neutral">
Generate your first invoice from the Invoices page or directly
from a Shopify order.
</s-paragraph>
<s-link href="/app/invoices">Open invoices </s-link>
</s-stack>
</s-box>
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No invoices yet</s-text>
<s-paragraph tone="neutral">
Generate your first invoice from the Invoices page or directly
from a Shopify order.
</s-paragraph>
<s-link href="/app/invoices">Open invoices </s-link>
</s-stack>
) : (
<s-table>
<s-table-header-row>
@@ -171,11 +168,9 @@ export default function Index() {
</s-table-body>
</s-table>
)}
<s-box padding="base">
<s-stack direction="inline" justifyContent="end">
<Link to="/app/invoices">View all invoices </Link>
</s-stack>
</s-box>
<s-stack direction="inline" justifyContent="end">
<Link to="/app/invoices">View all invoices </Link>
</s-stack>
</s-section>
<s-section heading="How it works">
+239 -44
View File
@@ -3,6 +3,7 @@ import { Link, useLoaderData, useNavigation, useFetcher } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { buildRepresentativeInvoiceMap } from "../services/invoice/representativeInvoice";
interface RecentOrder {
id: string; // gid
@@ -20,6 +21,20 @@ interface RecentOrder {
pdfUrl?: string;
}
interface DraftOrderRow {
id: string; // gid
numericId: string;
name: string;
createdAt: string;
totalPrice: string;
currency: string;
customerName: string;
hasOffer: boolean;
offerNumber?: string;
offerVersion?: number;
pdfUrl?: string;
}
const RECENT_ORDERS_QUERY = `#graphql
query RecentOrders($first: Int!) {
orders(first: $first, sortKey: CREATED_AT, reverse: true) {
@@ -35,6 +50,20 @@ const RECENT_ORDERS_QUERY = `#graphql
}
`;
const RECENT_DRAFTS_QUERY = `#graphql
query RecentDrafts($first: Int!) {
draftOrders(first: $first, sortKey: UPDATED_AT, reverse: true, query: "status:open") {
nodes {
id
name
createdAt
totalPriceSet { shopMoney { amount currencyCode } }
customer { firstName lastName }
}
}
}
`;
type Filter = "all" | "missing" | "with";
export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -47,6 +76,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
: "all";
let orders: RecentOrder[] = [];
let drafts: DraftOrderRow[] = [];
try {
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } });
const json = (await res.json()) as {
@@ -73,10 +103,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
},
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
const latestByOrder = new Map<string, (typeof invoices)[number]>();
for (const inv of invoices) {
if (!latestByOrder.has(inv.orderId)) latestByOrder.set(inv.orderId, inv);
}
// Pick the representative invoice per order. `invoices` is sorted by
// version desc, but a cancelled invoice can carry a HIGHER version than
// the current active one (cancel-and-reissue bumps versions), so a naive
// "first row wins" would surface a stale cancelled invoice and hide the
// live one. Prefer the latest non-cancelled invoice; only fall back to a
// cancelled row when no active invoice exists.
const latestByOrder = buildRepresentativeInvoiceMap(invoices);
orders = nodes.map((n) => {
const inv = latestByOrder.get(n.id);
@@ -103,6 +136,63 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
console.warn("Failed to load recent orders:", err);
}
try {
const res = await admin.graphql(RECENT_DRAFTS_QUERY, { variables: { first: 50 } });
const json = (await res.json()) as {
data?: {
draftOrders?: {
nodes?: Array<{
id: string;
name: string;
createdAt: string;
totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } };
customer?: { firstName: string | null; lastName: string | null } | null;
}>;
};
};
errors?: Array<{ message: string }>;
};
if (json.errors?.length) {
console.warn(
"draftOrders query returned errors:",
json.errors.map((e) => e.message).join("; "),
);
}
const nodes = json.data?.draftOrders?.nodes ?? [];
const draftIds = nodes.map((n) => n.id);
const offers = await db.invoice.findMany({
where: { shopDomain: session.shop, orderId: { in: draftIds }, kind: "offer" },
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
const latestByDraft = new Map<string, (typeof offers)[number]>();
for (const off of offers) {
if (!latestByDraft.has(off.orderId)) latestByDraft.set(off.orderId, off);
}
drafts = nodes.map((n) => {
const off = latestByDraft.get(n.id);
const customer = n.customer
? [n.customer.firstName, n.customer.lastName].filter(Boolean).join(" ").trim()
: "";
return {
id: n.id,
numericId: n.id.replace(/^.*\//, ""),
name: n.name,
createdAt: n.createdAt,
totalPrice: n.totalPriceSet?.shopMoney.amount ?? "",
currency: n.totalPriceSet?.shopMoney.currencyCode ?? "EUR",
customerName: customer || "Guest",
hasOffer: !!off && !off.cancelledAt,
offerNumber: off?.invoiceNumber,
offerVersion: off?.version,
pdfUrl: off?.pdfUrl,
};
});
} catch (err) {
console.warn("Failed to load draft orders:", err);
}
const allCount = orders.length;
const withCount = orders.filter((o) => o.hasInvoice).length;
const missingCount = allCount - withCount;
@@ -112,6 +202,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return {
orders,
drafts,
filter,
counts: { all: allCount, with: withCount, missing: missingCount },
};
@@ -134,54 +225,48 @@ function formatMoney(amount: string, currency: string): string {
}
export default function InvoicesPage() {
const { orders, filter, counts } = useLoaderData<typeof loader>();
const { orders, drafts, filter, counts } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
return (
<s-page heading="Invoices">
<s-section heading="Recent orders" padding="none">
<s-box padding="base">
<s-stack direction="block" gap="base">
<s-paragraph>
Generate the invoice for an order, regenerate an unsent draft,
or cancel-and-reissue a sent one. Newest orders appear first.
</s-paragraph>
<s-section heading="Recent orders">
<s-stack direction="block" gap="base">
<s-paragraph>
Generate the invoice for an order, regenerate an unsent draft,
or cancel-and-reissue a sent one. Newest orders appear first.
</s-paragraph>
<s-stack direction="inline" gap="small" alignItems="center">
<FilterChip to="?filter=all" active={filter === "all"} count={counts.all}>
All
</FilterChip>
<FilterChip
to="?filter=missing"
active={filter === "missing"}
count={counts.missing}
>
Missing invoice
</FilterChip>
<FilterChip to="?filter=with" active={filter === "with"} count={counts.with}>
Has invoice
</FilterChip>
</s-stack>
<s-stack direction="inline" gap="small" alignItems="center">
<FilterChip to="?filter=all" active={filter === "all"} count={counts.all}>
All
</FilterChip>
<FilterChip
to="?filter=missing"
active={filter === "missing"}
count={counts.missing}
>
Missing invoice
</FilterChip>
<FilterChip to="?filter=with" active={filter === "with"} count={counts.with}>
Has invoice
</FilterChip>
</s-stack>
</s-box>
</s-stack>
{isLoading ? (
<s-box padding="base">
<s-stack direction="inline" gap="small" alignItems="center">
<s-spinner size="small" accessibilityLabel="Loading orders" />
<s-text tone="neutral">Loading</s-text>
</s-stack>
</s-box>
<s-stack direction="inline" gap="base" alignItems="center">
<s-spinner size="base" accessibilityLabel="Loading orders" />
<s-text tone="neutral">Loading</s-text>
</s-stack>
) : orders.length === 0 ? (
<s-box padding="base">
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No orders match this filter</s-text>
<s-paragraph tone="neutral">
Try a different filter or wait for new orders.
</s-paragraph>
</s-stack>
</s-box>
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No orders match this filter</s-text>
<s-paragraph tone="neutral">
Try a different filter or wait for new orders.
</s-paragraph>
</s-stack>
) : (
<s-table>
<s-table-header-row>
@@ -201,6 +286,40 @@ export default function InvoicesPage() {
)}
</s-section>
<s-section heading="Draft orders (offers)">
<s-stack direction="block" gap="base">
<s-paragraph>
Generate a PDF offer (Angebot) for any open draft order. The
offer's number is the draft order name (e.g. <em>D1</em>).
</s-paragraph>
</s-stack>
{drafts.length === 0 ? (
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No open draft orders</s-text>
<s-paragraph tone="neutral">
Create a draft order in Shopify and refresh this page.
</s-paragraph>
</s-stack>
) : (
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Draft</s-table-header>
<s-table-header>Customer</s-table-header>
<s-table-header>Date</s-table-header>
<s-table-header format="numeric">Total</s-table-header>
<s-table-header listSlot="secondary">Offer</s-table-header>
<s-table-header listSlot="labeled">Actions</s-table-header>
</s-table-header-row>
<s-table-body>
{drafts.map((d) => (
<DraftRow key={d.id} draft={d} />
))}
</s-table-body>
</s-table>
)}
</s-section>
<s-section heading="About this page">
<s-stack direction="block" gap="small">
<s-paragraph>
@@ -240,19 +359,24 @@ function FilterChip({
function OrderRow({ order }: { order: RecentOrder }) {
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
const sendFetcher = useFetcher<{ ok: boolean; error?: string }>();
const isBusy = fetcher.state !== "idle";
const isSending = sendFetcher.state !== "idle";
const isCancelReissue = order.hasInvoice && order.invoiceSent;
const buttonLabel = !order.hasInvoice
? "Generate"
: order.invoiceSent
? "Cancel & reissue"
: "Regenerate";
const sendLabel = order.invoiceSent ? "Re-send" : "Send";
return (
<s-table-row>
<s-table-cell>
<s-stack direction="block" gap="none">
<s-text type="strong">{order.name}</s-text>
<s-link href={`shopify://admin/orders/${order.numericId}`}>
<s-text type="strong">{order.name}</s-text>
</s-link>
</s-stack>
</s-table-cell>
<s-table-cell>{order.customerName}</s-table-cell>
@@ -277,6 +401,9 @@ function OrderRow({ order }: { order: RecentOrder }) {
{fetcher.data?.error ? (
<s-text tone="critical">{fetcher.data.error}</s-text>
) : null}
{sendFetcher.data?.error ? (
<s-text tone="critical">{sendFetcher.data.error}</s-text>
) : null}
</s-stack>
) : (
<s-text tone="neutral"></s-text>
@@ -295,13 +422,81 @@ function OrderRow({ order }: { order: RecentOrder }) {
) : null}
<s-button
type="submit"
disabled={isBusy}
disabled={isBusy || isSending}
variant={order.hasInvoice ? "secondary" : "primary"}
tone={isCancelReissue ? "critical" : "auto"}
>
{isBusy ? "Working…" : buttonLabel}
</s-button>
</fetcher.Form>
<sendFetcher.Form method="post" action={`/api/orders/${order.numericId}/invoice`}>
<input type="hidden" name="action" value="send" />
<s-button
type="submit"
disabled={isBusy || isSending}
variant={order.hasInvoice && !order.invoiceSent ? "primary" : "secondary"}
>
{isSending ? "Sending…" : sendLabel}
</s-button>
</sendFetcher.Form>
</s-stack>
</s-table-cell>
</s-table-row>
);
}
function DraftRow({ draft }: { draft: DraftOrderRow }) {
const fetcher = useFetcher<{ ok: boolean; error?: string }>();
const isBusy = fetcher.state !== "idle";
const buttonLabel = draft.hasOffer ? "Regenerate offer" : "Generate offer";
return (
<s-table-row>
<s-table-cell>
<s-stack direction="block" gap="none">
<s-link href={`shopify://admin/draft_orders/${draft.numericId}`}>
<s-text type="strong">{draft.name}</s-text>
</s-link>
</s-stack>
</s-table-cell>
<s-table-cell>{draft.customerName}</s-table-cell>
<s-table-cell>{dateFmt.format(new Date(draft.createdAt))}</s-table-cell>
<s-table-cell>{formatMoney(draft.totalPrice, draft.currency)}</s-table-cell>
<s-table-cell>
{draft.hasOffer ? (
<s-stack direction="block" gap="none">
<s-stack direction="inline" gap="small" alignItems="center">
<s-text type="strong">{draft.offerNumber}</s-text>
<s-badge tone="info">Issued</s-badge>
{draft.offerVersion && draft.offerVersion > 1 ? (
<s-text tone="neutral">v{draft.offerVersion}</s-text>
) : null}
</s-stack>
{fetcher.data?.error ? (
<s-text tone="critical">{fetcher.data.error}</s-text>
) : null}
</s-stack>
) : (
<s-text tone="neutral"></s-text>
)}
</s-table-cell>
<s-table-cell>
<s-stack direction="inline" gap="small" justifyContent="end" alignItems="center">
{draft.pdfUrl ? (
<s-link href={draft.pdfUrl} target="_blank">
PDF
</s-link>
) : null}
<fetcher.Form method="post" action={`/api/orders/${draft.numericId}/invoice`}>
<input type="hidden" name="kind" value="offer" />
<s-button
type="submit"
disabled={isBusy}
variant={draft.hasOffer ? "secondary" : "primary"}
>
{isBusy ? "Working…" : buttonLabel}
</s-button>
</fetcher.Form>
</s-stack>
</s-table-cell>
</s-table-row>
+164 -19
View File
@@ -9,10 +9,19 @@ import {
normaliseIban,
} from "../services/invoice/validation";
import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants";
import { validateMerchantHttpsUrl } from "../services/invoice/safeFetch.server";
import {
deleteStoredLogo,
storeUploadedLogo,
} from "../services/invoice/logoCache.server";
import { encryptField } from "../services/crypto/fieldCrypto.server";
import { RichTextEditor } from "../components/RichTextEditor";
import {
DEFAULT_EMAIL_BODY_DE,
DEFAULT_EMAIL_BODY_EN,
DEFAULT_EMAIL_SUBJECT_DE,
DEFAULT_EMAIL_SUBJECT_EN,
} from "../services/invoice/emailTemplates";
interface SettingsFieldErrors {
vatId?: string;
@@ -24,6 +33,13 @@ interface SettingsFieldErrors {
logo?: string;
}
/**
* Sentinel value used in the SMTP password input. The real password is
* never sent to the client; if the form posts back this exact value the
* action treats it as "unchanged" and keeps whatever is already in the DB.
*/
const SMTP_PASSWORD_SENTINEL = "__unchanged__";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const settings = await db.shopSettings.upsert({
@@ -37,8 +53,17 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
if (cached) {
logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`;
}
} else if (settings.logoUrl) {
// External HTTPS URL — fine to display directly in the editor.
logoPreviewDataUrl = settings.logoUrl;
}
return { settings, logoPreviewDataUrl };
// Never expose the SMTP password to the browser. We replace it with a
// sentinel and the form action interprets that as "keep existing value".
const safeSettings = {
...settings,
smtpPassword: settings.smtpPassword ? SMTP_PASSWORD_SENTINEL : "",
};
return { settings: safeSettings, logoPreviewDataUrl, smtpPasswordSentinel: SMTP_PASSWORD_SENTINEL };
};
export const action = async ({ request }: ActionFunctionArgs) => {
@@ -91,12 +116,28 @@ export const action = async ({ request }: ActionFunctionArgs) => {
// 2. Remove the current logo (`removeLogo=on`).
// 3. Provide an external URL via the `logoUrl` field.
// If a file is uploaded it wins over a manually-entered URL.
let resolvedLogoUrl = str("logoUrl");
// Look up the existing logoUrl so we don't accidentally clear it when
// the user just edited unrelated fields (the visible URL field is hidden
// for stored uploads, so it submits empty in that case).
const existing = await db.shopSettings.findUnique({
where: { shopDomain: session.shop },
select: { logoUrl: true },
});
const submittedLogoUrl = str("logoUrl");
// Validate any merchant-supplied external logo URL at the trust boundary:
// require a syntactically valid https URL whose host is a domain name, not
// an IP literal (SSRF defence-in-depth; safeFetch is the runtime backstop).
if (submittedLogoUrl && submittedLogoUrl !== STORED_LOGO_SENTINEL) {
const urlError = validateMerchantHttpsUrl(submittedLogoUrl);
if (urlError) errors.logo = urlError;
}
const removeLogo = bool("removeLogo");
const logoFile = form.get("logoFile");
const hasUpload =
logoFile && typeof logoFile === "object" && "size" in logoFile && (logoFile as File).size > 0;
let resolvedLogoUrl = submittedLogoUrl || existing?.logoUrl || "";
if (removeLogo && !hasUpload) {
await deleteStoredLogo(session.shop);
resolvedLogoUrl = "";
@@ -115,6 +156,26 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return { ok: false, errors, savedAt: null as string | null };
}
// Resolve SMTP password: the loader sends a sentinel instead of the real
// value. If the form posts that sentinel back unchanged, keep whatever is
// already in the DB; otherwise persist the new value (including the empty
// string, which means "clear the password").
const submittedSmtpPassword = str("smtpPassword");
let nextSmtpPassword: string;
if (submittedSmtpPassword === SMTP_PASSWORD_SENTINEL) {
// Unchanged: keep the stored value as-is (already encrypted at rest).
const current = await db.shopSettings.findUnique({
where: { shopDomain: session.shop },
select: { smtpPassword: true },
});
nextSmtpPassword = current?.smtpPassword ?? "";
} else {
// New password (including "" to clear). Encrypt non-empty values at rest.
nextSmtpPassword = submittedSmtpPassword
? encryptField(submittedSmtpPassword)
: "";
}
const data = {
companyName: str("companyName"),
legalForm: str("legalForm"),
@@ -148,10 +209,16 @@ export const action = async ({ request }: ActionFunctionArgs) => {
smtpPort: smtpPort ?? 587,
smtpSecure: bool("smtpSecure"),
smtpUser: str("smtpUser"),
smtpPassword: str("smtpPassword"),
smtpPassword: nextSmtpPassword,
smtpFromName: str("smtpFromName"),
smtpFromEmail: str("smtpFromEmail"),
smtpReplyTo: str("smtpReplyTo"),
emailSubjectDe: str("emailSubjectDe"),
emailBodyHtmlDe: str("emailBodyHtmlDe"),
emailSubjectEn: str("emailSubjectEn"),
emailBodyHtmlEn: str("emailBodyHtmlEn"),
autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"),
autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"),
};
await db.shopSettings.upsert({
@@ -184,17 +251,6 @@ export default function SettingsRoute() {
</s-paragraph>
</s-section>
{actionData?.ok && (
<s-banner tone="success" heading="Settings saved">
Your changes are now live and will be used for the next invoice.
</s-banner>
)}
{actionData && !actionData.ok && (
<s-banner tone="critical" heading="Please fix the highlighted errors">
Some fields below need attention before settings can be saved.
</s-banner>
)}
<Form method="post" encType="multipart/form-data">
<s-section heading="Company">
<s-stack direction="block" gap="base">
@@ -361,12 +417,88 @@ export default function SettingsRoute() {
</s-stack>
</s-section>
<s-section heading="Email templates">
<s-stack direction="block" gap="base">
<s-paragraph>
These templates are used when sending the invoice PDF by email.
Leave a field empty to fall back to the built-in default.
</s-paragraph>
<Field
label="Subject (German)"
name="emailSubjectDe"
defaultValue={settings.emailSubjectDe || DEFAULT_EMAIL_SUBJECT_DE}
helpText="Variables like {{invoiceNumber}} are substituted at send time."
/>
<RichTextEditor
name="emailBodyHtmlDe"
label="Body (German)"
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE}
variables={EMAIL_VARS}
minHeight={220}
logoDataUrl={logoPreviewDataUrl}
/>
<Field
label="Subject (English)"
name="emailSubjectEn"
defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN}
helpText="Variables like {{invoiceNumber}} are substituted at send time."
/>
<RichTextEditor
name="emailBodyHtmlEn"
label="Body (English)"
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
variables={EMAIL_VARS}
minHeight={220}
logoDataUrl={logoPreviewDataUrl}
/>
</s-stack>
</s-section>
<s-section heading="Automations">
<s-stack direction="block" gap="base">
<s-paragraph>
These trigger directly from Shopify order webhooks no Shopify
Flow required (Flow is gated to Plus stores for custom apps).
When an automation fires, the invoice is generated (if it doesn't
already exist) and emailed to the customer using the SMTP and
email-template settings above. "Wire-transfer" is detected via
Shopify's <code>OrderTransaction.manualPaymentGateway</code> flag,
so any merchant-defined manual payment method (Überweisung, Cash
on Delivery, Money Order, ) qualifies.
</s-paragraph>
<Toggle
label='Auto-email the invoice when a wire-transfer order is placed (so the customer gets the bank details + GiroCode immediately).'
name="autoEmailOnWireTransferPlaced"
checked={settings.autoEmailOnWireTransferPlaced}
/>
<Toggle
label='Auto-email the invoice when an order is fulfilled and is NOT a wire-transfer order (e.g. the customer paid by card and we send the invoice with the shipment).'
name="autoEmailOnFulfilledNonWireTransfer"
checked={settings.autoEmailOnFulfilledNonWireTransfer}
/>
</s-stack>
</s-section>
<s-section>
<s-stack direction="inline" gap="base" justifyContent="end" alignItems="center">
{isSaving ? <s-text tone="neutral">Saving</s-text> : null}
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
Save settings
</s-button>
<s-stack direction="block" gap="base">
{actionData?.ok && (
<s-banner tone="success" heading="Settings saved">
Your changes are now live and will be used for the next invoice.
</s-banner>
)}
{actionData && !actionData.ok && (
<s-banner tone="critical" heading="Please fix the highlighted errors">
Some fields below need attention before settings can be saved.
</s-banner>
)}
<s-stack direction="inline" gap="base" justifyContent="end" alignItems="center">
{isSaving ? <s-text tone="neutral">Saving</s-text> : null}
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
Save settings
</s-button>
</s-stack>
</s-stack>
</s-section>
</Form>
@@ -374,6 +506,19 @@ export default function SettingsRoute() {
);
}
const EMAIL_VARS = [
{ token: "{{invoiceNumber}}" },
{ token: "{{customerName}}" },
{ token: "{{customerFirstName}}" },
{ token: "{{orderName}}" },
{ token: "{{totalGross}}" },
{ token: "{{dueDate}}" },
{ token: "{{companyName}}" },
{ token: "{{ownerName}}" },
{ token: "{{shopEmail}}" },
{ token: "{{shopWebsite}}" },
];
interface FieldProps {
label: string;
name: string;
+35 -2
View File
@@ -6,13 +6,46 @@ import { Form, useActionData, useLoaderData } from "react-router";
import { login } from "../../shopify.server";
import { loginErrorMessage } from "./error.server";
function enforceAllowedShop(request: Request) {
const allowedShop = process.env.ALLOWED_SHOP?.trim();
if (!allowedShop) return;
const url = new URL(request.url);
const fromQuery = url.searchParams.get("shop");
let fromBody: string | null = null;
// Action requests submit the shop in the form body; we re-read it here.
// (request.formData() can only be consumed once, so we clone.)
if (request.method === "POST") {
// We can't await the clone here without making this async; instead the
// caller awaits the actual action and we re-validate the redirect target
// via the query string check above. The action wrapper below also runs
// a body check before delegating to `login()`.
}
if (fromQuery && fromQuery.toLowerCase() !== allowedShop.toLowerCase() && !fromBody) {
throw new Response("This app is private.", { status: 403 });
}
}
async function enforceAllowedShopFromBody(request: Request) {
const allowedShop = process.env.ALLOWED_SHOP?.trim();
if (!allowedShop) return request;
const cloned = request.clone();
const form = await cloned.formData();
const shop = (form.get("shop") ?? "").toString().trim().toLowerCase();
if (shop && shop !== allowedShop.toLowerCase()) {
throw new Response("This app is private.", { status: 403 });
}
return request;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
enforceAllowedShop(request);
const errors = loginErrorMessage(await login(request));
return { errors };
return { errors, allowedShop: process.env.ALLOWED_SHOP ?? null };
};
export const action = async ({ request }: ActionFunctionArgs) => {
await enforceAllowedShopFromBody(request);
const errors = loginErrorMessage(await login(request));
return {
@@ -23,7 +56,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
export default function Auth() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [shop, setShop] = useState("");
const [shop, setShop] = useState(loaderData.allowedShop ?? "");
const { errors } = actionData || loaderData;
return (
+8
View File
@@ -0,0 +1,8 @@
// Lightweight unauthenticated health check used by Docker/Caddy.
// Returns 200 with a tiny JSON body. Do NOT touch the database here:
// the goal is only to confirm the Node process is serving HTTP.
export const loader = () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
+67 -4
View File
@@ -1,11 +1,74 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import {
generateAndEmailInvoice,
isManualPaymentOrder,
} from "../services/invoice/automations.server";
import { reserveWebhook } from "../services/webhooks/dedupe.server";
import { runWebhookInBackground } from "../services/webhooks/background.server";
// We don't auto-generate invoices on order create. This handler just
// acknowledges the webhook so Shopify keeps it healthy and gives us a
// hook point for future work (e.g. cache invalidation).
/**
* orders/create — Automation 1: when a wire-transfer (manual-payment-gateway)
* order is placed, immediately generate and email the invoice (which includes
* the bank details + GiroCode) so the customer can pay. Other orders are
* ignored here; they're handled by orders/fulfilled (Automation 2).
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic } = await authenticate.webhook(request);
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// Reserve this delivery (status="processing"). `null` => already
// done/in-flight, so short-circuit. The reservation is committed only after
// the background work succeeds, and released on failure so Shopify's retry
// re-runs it (prevents the silent invoice loss we'd get if we recorded the
// id as processed before the slow PDF/email work).
const reservation = await reserveWebhook(request, shop, topic);
if (!reservation) return new Response();
if (!session || !admin) {
// App uninstalled before the webhook drained — nothing to do.
await reservation.commit();
return new Response();
}
const orderId = payload?.id;
if (orderId == null) {
await reservation.commit();
return new Response();
}
const customerLocale =
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
// Respond 200 immediately and run the (slow) PDF + email work in the
// background — keeps us well under Shopify's ~5s ack timeout. The queue
// commits the reservation on success and releases it on failure.
runWebhookInBackground(
`${topic} order=${orderId} shop=${shop}`,
async () => {
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.autoEmailOnWireTransferPlaced) return;
const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
if (!(await isManualPaymentOrder(admin, orderGid))) return;
const result = await generateAndEmailInvoice({
shopDomain: shop,
admin,
orderId,
customerLocale,
});
if (!result.ok) {
// Throw so the reservation is released and Shopify retries — don't
// swallow the failure (which would leave the invoice unsent forever).
throw new Error(
`auto-email (wire-transfer placed) failed for order ${orderId} on ${shop}: ${result.reason}`,
);
}
},
reservation,
);
return new Response();
};
+73
View File
@@ -0,0 +1,73 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import {
generateAndEmailInvoice,
isManualPaymentOrder,
} from "../services/invoice/automations.server";
import { reserveWebhook } from "../services/webhooks/dedupe.server";
import { runWebhookInBackground } from "../services/webhooks/background.server";
/**
* orders/fulfilled — Automation 2: when an order is fulfilled and is NOT a
* wire-transfer (manual-payment-gateway) order, automatically email the
* invoice to the customer. Manual-gateway orders are intentionally skipped
* because Automation 1 already emailed them at order-create time.
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// Reserve/commit dedupe — see webhooks/dedupe.server.ts. `null` => already
// done/in-flight; commit on success / release on failure happen in the
// background queue so a failed send is retried by Shopify, not dropped.
const reservation = await reserveWebhook(request, shop, topic);
if (!reservation) return new Response();
if (!session || !admin) {
// App was uninstalled before the webhook drained — nothing to do.
await reservation.commit();
return new Response();
}
const orderId = payload?.id;
if (orderId == null) {
await reservation.commit();
return new Response();
}
const customerLocale =
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
// Respond fast; do the heavy lifting after the response (see notes in
// webhooks.orders.create.tsx for the rationale).
runWebhookInBackground(
`${topic} order=${orderId} shop=${shop}`,
async () => {
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.autoEmailOnFulfilledNonWireTransfer) return;
const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
if (await isManualPaymentOrder(admin, orderGid)) {
// Manual / wire-transfer order — handled by Automation 1, skip here.
return;
}
const result = await generateAndEmailInvoice({
shopDomain: shop,
admin,
orderId,
customerLocale,
});
if (!result.ok) {
// Throw so the reservation is released and Shopify retries.
throw new Error(
`auto-email (fulfilled) failed for order ${orderId} on ${shop}: ${result.reason}`,
);
}
},
reservation,
);
return new Response();
};
+6
View File
@@ -1,10 +1,16 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import { reserveWebhook } from "../services/webhooks/dedupe.server";
// Acknowledged but not yet acted on. Future: invalidate cached invoice
// snapshots when a relevant field on the order changes.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// Idempotency against Shopify retries — see webhooks/dedupe.server.ts.
const reservation = await reserveWebhook(request, shop, topic);
if (!reservation) return new Response();
// No side-effect work yet, so the delivery is immediately complete.
await reservation.commit();
return new Response();
};
+33
View File
@@ -0,0 +1,33 @@
/**
* Small env-access helpers that fail closed.
*
* `requireEnv` throws a clear error (without ever printing the secret value)
* when a required environment variable is missing or empty. `optionalEnv`
* returns the trimmed value or undefined.
*/
/**
* Returns the value of `name` from `process.env`, throwing if it is unset or
* empty (after trimming). The secret value itself is never included in the
* error message.
*/
export function requireEnv(name: string): string {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "") {
throw new Error(
`Missing required environment variable "${name}". ` +
`Set it before starting the app (see deploy/.env.dev.example).`,
);
}
return raw;
}
/**
* Returns the value of `name` from `process.env`, or `undefined` if it is
* unset or empty (after trimming).
*/
export function optionalEnv(name: string): string | undefined {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "") return undefined;
return raw;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Field-level encryption at rest using AES-256-GCM.
*
* Output format: `enc:v1:<base64(iv)>:<base64(tag)>:<base64(ciphertext)>`
*
* `decryptField` is backward-compatible: values that do not carry the
* `enc:v1:` prefix are assumed to be legacy plaintext and returned unchanged,
* so an existing (dev) database keeps working without a data migration.
*/
import crypto from "node:crypto";
import { requireEnv } from "../config/env.server";
const PREFIX = "enc:v1:";
const IV_BYTES = 12;
const KEY_BYTES = 32;
let cachedKey: Buffer | null = null;
/**
* Loads and validates the 32-byte AES key from `DATA_ENCRYPTION_KEY`
* (base64-encoded). Cached after first use. Throws if unset or wrong length.
*/
function getKey(): Buffer {
if (cachedKey) return cachedKey;
const b64 = requireEnv("DATA_ENCRYPTION_KEY");
let key: Buffer;
try {
key = Buffer.from(b64, "base64");
} catch {
throw new Error('DATA_ENCRYPTION_KEY must be valid base64 of 32 bytes.');
}
if (key.length !== KEY_BYTES) {
throw new Error(
`DATA_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes (got ${key.length}).`,
);
}
cachedKey = key;
return key;
}
/** Encrypts `plaintext` and returns the `enc:v1:...` envelope. */
export function encryptField(plaintext: string): string {
const key = getKey();
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return (
PREFIX +
iv.toString("base64") +
":" +
tag.toString("base64") +
":" +
ciphertext.toString("base64")
);
}
/**
* Decrypts an `enc:v1:...` envelope. If `value` is not in that format it is
* assumed to be legacy plaintext and returned unchanged.
*/
export function decryptField(value: string): string {
if (!value.startsWith(PREFIX)) return value;
const parts = value.slice(PREFIX.length).split(":");
if (parts.length !== 3) {
throw new Error("Malformed encrypted field (expected iv:tag:ciphertext).");
}
const [ivB64, tagB64, dataB64] = parts;
const key = getKey();
const iv = Buffer.from(ivB64, "base64");
const tag = Buffer.from(tagB64, "base64");
const ciphertext = Buffer.from(dataB64, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return plaintext.toString("utf8");
}
+113
View File
@@ -0,0 +1,113 @@
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
import db from "../../db.server";
import { generateInvoice } from "./generateInvoice.server";
import { sendInvoiceEmail } from "./email.server";
/**
* Returns true when the order has at least one transaction processed by a
* Shopify "manual" payment gateway (wire transfer, cash on delivery,
* money order, custom manual methods, …). This uses
* `OrderTransaction.manualPaymentGateway`, the only first-class flag
* Shopify exposes for distinguishing manual gateways from automated ones.
*
* Falls back to `false` on any GraphQL error so we don't block fulfilment
* automations on transient API issues.
*/
export async function isManualPaymentOrder(
admin: AdminApiContext,
orderGid: string,
): Promise<boolean> {
try {
const res = await admin.graphql(
`#graphql
query OrderManualPaymentCheck($id: ID!) {
order(id: $id) {
transactions(first: 20) {
kind
status
manualPaymentGateway
}
}
}`,
{ variables: { id: orderGid } },
);
const json = (await res.json()) as {
data?: {
order?: {
transactions?: Array<{
kind?: string;
status?: string;
manualPaymentGateway?: boolean;
}>;
} | null;
};
};
const txs = json.data?.order?.transactions ?? [];
// Any non-failed transaction processed by a manual gateway counts.
return txs.some(
(t) => t.manualPaymentGateway === true && t.status !== "FAILURE" && t.status !== "ERROR",
);
} catch (err) {
console.warn(`isManualPaymentOrder query failed for ${orderGid}:`, err);
return false;
}
}
export interface AutoEmailArgs {
shopDomain: string;
admin: AdminApiContext;
/** Numeric Shopify order id (from REST webhook payload `id`). */
orderId: string | number;
/** Customer locale forwarded to the email service for subject/body language. */
customerLocale?: string;
}
/**
* Idempotent: if an unsent invoice already exists for the order, it is reused
* and emailed. If a sent invoice already exists, sending is skipped (we never
* spam the customer with the same invoice twice from automations).
*/
export async function generateAndEmailInvoice(args: AutoEmailArgs): Promise<{
ok: boolean;
reason?: string;
invoiceNumber?: string;
}> {
const orderNumeric = String(args.orderId).replace(/^.*\//, "");
const orderGid = `gid://shopify/Order/${orderNumeric}`;
const existing = await db.invoice.findFirst({
where: {
shopDomain: args.shopDomain,
orderId: orderGid,
kind: "invoice",
cancelledAt: null,
},
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
if (existing && existing.sentAt) {
return { ok: true, reason: "already-sent", invoiceNumber: existing.invoiceNumber };
}
let invoiceId = existing?.id;
let invoiceNumber = existing?.invoiceNumber;
if (!invoiceId) {
const generated = await generateInvoice({
shopDomain: args.shopDomain,
admin: args.admin,
orderId: orderNumeric,
});
invoiceId = generated.invoiceId;
invoiceNumber = generated.invoiceNumber;
}
const result = await sendInvoiceEmail({
shopDomain: args.shopDomain,
invoiceId,
customerLocale: args.customerLocale,
});
if (!result.ok) {
return { ok: false, reason: result.errorMessage ?? "email-failed", invoiceNumber };
}
return { ok: true, invoiceNumber };
}
+292 -13
View File
@@ -1,6 +1,6 @@
import type { ShopSettings } from "@prisma/client";
import type { RawOrderForInvoice, RawTaxLine } from "./loadOrderForInvoice.server";
import type { RawOrderForInvoice, RawShippingLine, RawTaxLine } from "./loadOrderForInvoice.server";
import type {
InvoiceLine,
InvoiceNotice,
@@ -8,10 +8,11 @@ import type {
InvoiceViewModel,
IssuerData,
RecipientData,
TrackingInfo,
VatBreakdownEntry,
} from "./types";
import { addDays } from "./format";
import { pickLanguage, type InvoiceLanguage } from "./i18n";
import { derivePaymentStatus, getStrings, pickLanguage, type InvoiceLanguage } from "./i18n";
interface ComposeArgs {
order: RawOrderForInvoice;
@@ -28,6 +29,14 @@ interface ComposeArgs {
storno?: { cancelsNumber: string };
/** Optional override for invoice/delivery date (defaults to order date). */
issueDate?: Date;
/**
* When true, render as an Angebot/Offer instead of an invoice:
* - `kind = "offer"`
* - no payment-due date (the dueDate field is repurposed by the renderer
* as the offer's validity expiry).
* - GiroCode and payment-terms text are suppressed.
*/
offer?: boolean;
}
export function composeInvoice({
@@ -37,6 +46,7 @@ export function composeInvoice({
forceLanguage,
storno,
issueDate,
offer,
}: ComposeArgs): InvoiceViewModel {
const language = forceLanguage
?? pickLanguage(order.customer?.locale ?? settings.defaultLanguage);
@@ -46,21 +56,78 @@ export function composeInvoice({
const isB2B = !!order.purchasingEntity?.company;
const recipientVatId = order.purchasingEntity?.company?.vatId ?? undefined;
let { lines, totals } = mapLinesAndTotals(order);
const strings = getStrings(language);
let { lines, totals } = mapLinesAndTotals(order, {
shippingItemPrefix: strings.shippingItemPrefix,
});
let notices = deriveNotices({ order, settings, isB2B });
const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt);
const deliveryDate = invoiceDate;
const dueDate = !storno && settings.paymentTermDays > 0
? addDays(invoiceDate, settings.paymentTermDays)
: undefined;
const pickupInfo = detectPickup(order);
const isPickup = pickupInfo != null;
const separateShippingAddress = isPickup ? undefined : mapSeparateShippingAddress(order);
// For shipping orders we surface the carrier label (e.g. "Standardversand").
// For pickup orders the meta row uses a different label entirely
// ("Abholort: <location>") — see the renderer.
const shippingMethod = isPickup
? undefined
: order.shippingLine?.title?.trim() || undefined;
const tracking = mapTracking(order);
const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID";
const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt);
// §11 UStG: deliveryDate is the date goods/services were rendered. Prefer
// the latest fulfillment timestamp; fall back to invoice date when the
// order is unfulfilled (e.g. immediate-render services or digital orders).
const deliveryDate = pickDeliveryDate(order, invoiceDate);
// For offers we treat `dueDate` as the offer's validity expiry (default 30
// days from issue). The PDF renderer renders a different label.
const dueDate = offer
? addDays(invoiceDate, 30)
: !storno && settings.paymentTermDays > 0
? addDays(invoiceDate, settings.paymentTermDays)
: undefined;
// Refunded gross amount, mirrored from Shopify's `totalRefundedSet`.
// Storno/offer documents don't carry a refund row — a storno *is*
// already the cancellation document, and offers have no payments yet.
const refundedAmount = storno || offer
? 0
: Math.max(0, parseFloat(order.totalRefundedSet?.shopMoney.amount ?? "0") || 0);
let paymentStatus = derivePaymentStatus(order.displayFinancialStatus);
// Reclassification: Shopify flips `displayFinancialStatus` to
// PARTIALLY_REFUNDED as soon as *any* refund is posted against a
// paid order, even when the customer only got back a small fraction.
// For our purposes such an order is still "paid" — the merchant kept
// the difference — and showing "Erstattet" / "Refunded" in the
// status row would falsely imply the customer got everything back.
// Only when the refund equals (or, defensively, exceeds) the gross
// do we keep the "refunded" status.
if (
paymentStatus === "refunded" &&
refundedAmount > 0 &&
refundedAmount < totals.gross
) {
paymentStatus = "paid";
}
// A document only requires payment when it's a regular invoice (not a
// storno or an offer) AND money is still actually owed. Refunded and
// paid orders both have a 0 outstanding balance — the difference is
// just whether the money was kept (`paid`) or returned (`refunded`).
const requiresPayment =
!storno &&
!offer &&
paymentStatus !== "paid" &&
paymentStatus !== "refunded" &&
paymentStatus !== "voided";
const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter(
(n) => typeof n === "string" && n.trim().length > 0,
);
if (storno) {
lines = lines.map((l) => ({
...l,
unitPriceNet: -l.unitPriceNet,
originalUnitPriceNet:
l.originalUnitPriceNet != null ? -l.originalUnitPriceNet : undefined,
totalNet: -l.totalNet,
}));
totals = {
@@ -80,7 +147,7 @@ export function composeInvoice({
return {
language,
currency: order.currencyCode,
kind: storno ? "storno" : "invoice",
kind: storno ? "storno" : offer ? "offer" : "invoice",
number: invoiceNumber,
cancelsNumber: storno?.cancelsNumber,
invoiceDate,
@@ -93,7 +160,17 @@ export function composeInvoice({
lines,
totals,
notices,
paid,
paymentStatus,
requiresPayment,
refundedAmount,
paymentGatewayNames,
orderName: order.name,
separateShippingAddress,
shippingMethod,
tracking,
discountCodes: order.discountCodes ?? [],
isPickup,
pickupLocationName: pickupInfo?.locationName ?? undefined,
};
}
@@ -153,7 +230,10 @@ function mapRecipient(order: RawOrderForInvoice): RecipientData {
};
}
function mapLinesAndTotals(order: RawOrderForInvoice): {
function mapLinesAndTotals(
order: RawOrderForInvoice,
opts: { shippingItemPrefix: string },
): {
lines: InvoiceLine[];
totals: InvoiceTotals;
} {
@@ -164,7 +244,13 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
order.lineItems.forEach((li, idx) => {
const qty = li.quantity;
const grossOrNetUnit = parseFloat(li.originalUnitPriceSet.shopMoney.amount);
// Prefer the post-discount unit price when Shopify provides one (it
// reflects both line-level and cart-level discount allocations). Fall
// back to original price when no discount applied.
const grossOrNetUnit = parseFloat(
(li.discountedUnitPriceSet ?? li.originalUnitPriceSet).shopMoney.amount,
);
const originalGrossOrNetUnit = parseFloat(li.originalUnitPriceSet.shopMoney.amount);
// Total tax for this line summed across its tax lines.
const lineTax = li.taxLines.reduce(
(acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount),
@@ -174,6 +260,17 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
const lineGross = grossOrNetUnit * qty + (taxesIncluded ? 0 : lineTax);
const lineNet = taxesIncluded ? grossOrNetUnit * qty - lineTax : grossOrNetUnit * qty;
const unitNet = qty > 0 ? lineNet / qty : 0;
// For the strikethrough original, compute net the same way the line is
// computed: when taxesIncluded, derive an equivalent net per unit using
// the line's effective tax rate; when not, the original IS the net.
const effectiveRate = qty > 0 && grossOrNetUnit > 0
? lineTax / (grossOrNetUnit * qty)
: 0;
const originalUnitNet = taxesIncluded
? originalGrossOrNetUnit / (1 + effectiveRate)
: originalGrossOrNetUnit;
const hasDiscount =
Math.round(originalGrossOrNetUnit * 100) !== Math.round(grossOrNetUnit * 100);
linesOut.push({
position: idx + 1,
@@ -181,6 +278,7 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
sku: li.sku ?? undefined,
quantity: qty,
unitPriceNet: round2(unitNet),
originalUnitPriceNet: hasDiscount ? round2(originalUnitNet) : undefined,
totalNet: round2(lineNet),
imageUrl: li.imageUrl ?? undefined,
});
@@ -205,6 +303,18 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
});
}
// Append the shipping line as a synthetic invoice row when the order has
// a shipping cost > 0. This makes shipping appear in the items table
// (visible to the customer) and folds its tax into the VAT breakdown.
const shippingLineNet = appendShippingLine(
order.shippingLine,
taxesIncluded,
linesOut,
vatMap,
opts.shippingItemPrefix,
);
netSum += shippingLineNet;
const vatBreakdown = Array.from(vatMap.values())
.map((e) => ({ ratePct: e.ratePct, net: round2(e.net), tax: round2(e.tax) }))
.filter((e) => e.tax > 0)
@@ -299,3 +409,172 @@ const EU_COUNTRIES = new Set([
function isEuCountry(code: string): boolean {
return EU_COUNTRIES.has(code.toUpperCase());
}
/**
* Add a synthetic line item for the order's shipping cost. Returns the net
* amount added (used to keep the running net subtotal in sync). Returns 0
* when there's no shipping line or the shipping price is zero (e.g. free
* shipping or digital orders).
*/
function appendShippingLine(
shippingLine: RawShippingLine | null,
taxesIncluded: boolean,
linesOut: InvoiceLine[],
vatMap: Map<number, VatBreakdownEntry>,
prefix: string,
): number {
if (!shippingLine) return 0;
const priceSet = shippingLine.discountedPriceSet ?? shippingLine.originalPriceSet;
const grossOrNet = priceSet ? parseFloat(priceSet.shopMoney.amount) : 0;
if (!Number.isFinite(grossOrNet) || grossOrNet === 0) return 0;
const tax = shippingLine.taxLines.reduce(
(acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount),
0,
);
const net = taxesIncluded ? grossOrNet - tax : grossOrNet;
const title = shippingLine.title?.trim()
? `${prefix}: ${shippingLine.title.trim()}`
: prefix;
linesOut.push({
position: linesOut.length + 1,
title,
quantity: 1,
unitPriceNet: round2(net),
totalNet: round2(net),
});
shippingLine.taxLines.forEach((t) =>
accumulateVat(vatMap, t, parseFloat(t.priceSet.shopMoney.amount), net),
);
return net;
}
/**
* Returns the shipping address as a recipient block when it differs in any
* meaningful way from the billing address. Returns undefined when both are
* the same (so the renderer doesn't show a redundant block) or when there
* is no shipping address at all.
*/
function mapSeparateShippingAddress(
order: RawOrderForInvoice,
): RecipientData | undefined {
const ship = order.shippingAddress;
const bill = order.billingAddress;
if (!ship) return undefined;
// No billing address → just use the existing recipient block, no need to
// duplicate.
if (!bill) return undefined;
const sameAddress =
(ship.name ?? "") === (bill.name ?? "") &&
(ship.company ?? "") === (bill.company ?? "") &&
(ship.address1 ?? "") === (bill.address1 ?? "") &&
(ship.address2 ?? "") === (bill.address2 ?? "") &&
(ship.zip ?? "") === (bill.zip ?? "") &&
(ship.city ?? "") === (bill.city ?? "") &&
(ship.countryCode ?? "") === (bill.countryCode ?? "");
if (sameAddress) return undefined;
const customerFullName = [order.customer?.firstName, order.customer?.lastName]
.filter(Boolean)
.join(" ")
.trim();
return {
name: ship.name ?? customerFullName,
company: ship.company ?? "",
addressLine1: ship.address1 ?? "",
addressLine2: ship.address2 ?? "",
postalCode: ship.zip ?? "",
city: ship.city ?? "",
countryCode: ship.countryCode ?? "",
};
}
/**
* Flatten tracking info from all fulfillments. Skips entries without a
* tracking number. Deduplicates on `number`.
*/
function mapTracking(order: RawOrderForInvoice): TrackingInfo[] {
const out: TrackingInfo[] = [];
const seen = new Set<string>();
for (const f of order.fulfillments ?? []) {
for (const t of f.trackingInfo ?? []) {
const number = (t.number ?? "").trim();
if (!number || seen.has(number)) continue;
seen.add(number);
out.push({
number,
url: t.url?.trim() || undefined,
company: t.company?.trim() || undefined,
});
}
}
return out;
}
/**
* Detects whether the order is a "local pickup" order using three signals
* (any one is enough). All rely only on the `read_orders` scope.
*
* 1. **No shipping address** despite `requiresShipping == true`. Shopify
* never lets a regular ship-to-customer order check out without one,
* so this combination is a textbook pickup. This is the *only* signal
* for the built-in "Shop location" rate, which leaves
* `deliveryCategory` null and the title/code as the bare location
* name (e.g. "Shop location" / "Lager Graz").
* 2. `shippingLine.deliveryCategory` contains "pickup"/"local_pickup".
* Set by some Local Pickup integrations.
* 3. Regex on `shippingLine.{source,code,title,carrierIdentifier}` for
* custom rates titled "Abholung"/"Pickup".
*
* (We deliberately do NOT query `Order.fulfillmentOrders.deliveryMethod`:
* that field requires the `read_merchant_managed_fulfillment_orders` scope,
* which would force every install to re-grant permissions.)
*
* Location name is taken from `shippingLine.title` — for the Shopify
* Local Pickup app and the built-in "Shop location" rate, the title IS
* the chosen location name.
*
* Returns the pickup descriptor or `null` when the order is a normal
* shipping order. Callers should not render the pickup-location address
* as a separate "delivery address".
*/
function detectPickup(
order: RawOrderForInvoice,
): { locationName: string | null } | null {
const sl = order.shippingLine;
// Strongest signal: shipping is required but there's no shipping address.
// Shopify rejects checkout otherwise, so this is conclusive.
const noShipAddrButRequired =
order.requiresShipping && order.shippingAddress == null;
// Secondary: explicit pickup category from Local Pickup apps.
const dc = (sl?.deliveryCategory ?? "").toLowerCase();
const isPickupCategory = dc.includes("pickup") || dc.includes("pick_up") || dc.includes("pick-up");
// Tertiary: regex on title/code/source/carrier — covers merchants who
// model pickup as a custom shipping rate.
const haystack = [sl?.source, sl?.code, sl?.title, sl?.carrierIdentifier]
.filter(Boolean)
.join(" ")
.toLowerCase();
const isPickupString = /pick[\s-]?up|abholung|abhol\b/.test(haystack);
if (!noShipAddrButRequired && !isPickupCategory && !isPickupString) return null;
return { locationName: sl?.title?.trim() || null };
}
/**
* Picks the delivery date for §11 UStG: the latest fulfillment timestamp
* when the order is fulfilled, otherwise the invoice date itself (best
* approximation for unfulfilled / digital orders).
*/
function pickDeliveryDate(order: RawOrderForInvoice, invoiceDate: Date): Date {
const stamps = (order.fulfillments ?? [])
.map((f) => (f.createdAt ? new Date(f.createdAt).getTime() : NaN))
.filter((n) => Number.isFinite(n));
if (stamps.length === 0) return invoiceDate;
return new Date(Math.max(...stamps));
}
+219 -11
View File
@@ -4,6 +4,16 @@ import type { ShopSettings } from "@prisma/client";
import db from "../../db.server";
import { getStrings, pickLanguage } from "./i18n";
import {
DEFAULT_EMAIL_BODY_DE,
DEFAULT_EMAIL_BODY_EN,
DEFAULT_EMAIL_SUBJECT_DE,
DEFAULT_EMAIL_SUBJECT_EN,
} from "./emailTemplates";
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
import { decryptField } from "../crypto/fieldCrypto.server";
import { optionalEnv } from "../config/env.server";
export interface SendInvoiceEmailArgs {
shopDomain: string;
@@ -60,23 +70,48 @@ export async function sendInvoiceEmail(
}
if (!to) return failLog(args, "No recipient email available.", invoice.id);
// Build email content.
const language = pickLanguage(args.customerLocale ?? settings.defaultLanguage);
// Build email content. Always use the language the invoice PDF was
// rendered in, so the email matches the attachment. Caller can still
// override via `customerLocale` if they really want a different language.
const language = pickLanguage(args.customerLocale ?? invoice.language ?? settings.defaultLanguage);
const t = getStrings(language);
const subject = `${t.invoice} ${invoice.invoiceNumber}` +
(settings.companyName ? `${settings.companyName}` : "");
const body = renderEmailBody({
const customer = parseCustomer(invoice.customerJson);
const totals = parseTotals(invoice.totalsJson);
const vars = buildTemplateVars({
invoice,
settings,
invoiceNumber: invoice.invoiceNumber,
language,
customerName: customer.customerName ?? "",
customerFirstName: (customer.customerName ?? "").split(/\s+/)[0] ?? "",
totalGross: totals.totalGross ?? "",
});
const customSubject =
(language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe) ||
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
const subject = renderTemplate(customSubject, vars, { html: false });
const customBodyHtml =
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
(language === "en" ? DEFAULT_EMAIL_BODY_EN : DEFAULT_EMAIL_BODY_DE);
const body = renderHtmlBody(renderTemplate(customBodyHtml, vars));
// If the rendered body references the inline logo, attach it.
const inlineLogo = body.html.includes("cid:invoice-logo")
? await loadInlineLogo(args.shopDomain, settings)
: null;
// Download the PDF (Shopify Files URLs are public CDN URLs).
let pdfBytes: Uint8Array;
try {
const res = await fetch(invoice.pdfUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
pdfBytes = new Uint8Array(await res.arrayBuffer());
const res = await safeFetch(invoice.pdfUrl, {
maxBytes: 25 * 1024 * 1024, // 25 MB — generous; emails impose their own limit later
accept: "application/pdf",
// Invoice PDFs always live on Shopify's Files CDN — anything else is
// suspicious and should be rejected.
allowedHosts: SHOPIFY_CDN_HOSTS,
});
if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}`);
pdfBytes = res.bytes;
} catch (err) {
const m = err instanceof Error ? err.message : String(err);
return failLog(args, `Failed to download invoice PDF: ${m}`, invoice.id);
@@ -91,9 +126,13 @@ export async function sendInvoiceEmail(
}
try {
// Optional archival BCC. Off by default for privacy/GDPR; set INVOICE_BCC
// to a comma-separated address list to opt in.
const bcc = optionalEnv("INVOICE_BCC");
const info = await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to,
...(bcc ? { bcc } : {}),
replyTo: settings.smtpReplyTo || undefined,
subject,
text: body.text,
@@ -104,6 +143,16 @@ export async function sendInvoiceEmail(
content: Buffer.from(pdfBytes),
contentType: "application/pdf",
},
...(inlineLogo
? [
{
filename: "logo",
content: Buffer.from(inlineLogo.bytes),
contentType: inlineLogo.contentType,
cid: "invoice-logo",
},
]
: []),
],
});
@@ -136,7 +185,7 @@ function buildTransport(settings: ShopSettings): Transporter {
port: settings.smtpPort,
secure: settings.smtpSecure,
auth: settings.smtpUser
? { user: settings.smtpUser, pass: settings.smtpPassword }
? { user: settings.smtpUser, pass: decryptField(settings.smtpPassword) }
: undefined,
});
}
@@ -202,3 +251,162 @@ function escapeHtml(s: string): string {
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
);
}
// --- Template variables ----------------------------------------------------
interface TemplateVars {
invoiceNumber: string;
customerName: string;
customerFirstName: string;
orderName: string;
totalGross: string;
dueDate: string;
companyName: string;
ownerName: string;
shopEmail: string;
shopWebsite: string;
}
function buildTemplateVars(args: {
invoice: { invoiceNumber: string; orderName: string };
settings: ShopSettings;
customerName: string;
customerFirstName: string;
totalGross: string;
}): TemplateVars {
const dueMs = Number((args.invoice as unknown as { dueDate?: string | Date }).dueDate ?? 0);
return {
invoiceNumber: args.invoice.invoiceNumber,
orderName: args.invoice.orderName,
customerName: args.customerName,
customerFirstName: args.customerFirstName,
totalGross: args.totalGross,
dueDate: dueMs ? new Date(dueMs).toLocaleDateString() : "",
companyName: args.settings.companyName,
ownerName: args.settings.ownerName,
shopEmail: args.settings.email,
shopWebsite: args.settings.website,
};
}
/**
* Substitutes {{token}} placeholders in `template`. Unknown tokens are left
* in place so the user notices typos instead of silent blanks.
*
* For HTML output (the default), every interpolated value is HTML-escaped to
* prevent stored-XSS from merchant- or customer-derived data bleeding into the
* email body. URL-valued tokens that land inside `href` attributes are scheme-
* validated first: `shopWebsite` must be an `https:` URL and `shopEmail` must
* look like a bare email address (rendered after a `mailto:` prefix); anything
* else renders empty so a hostile `javascript:`/`data:` value can't be planted.
*
* Pass `{ html: false }` for plain-text contexts (e.g. the subject line), where
* the raw value is substituted without HTML entity encoding.
*/
const URL_TOKENS = new Set(["shopWebsite"]);
const EMAIL_TOKENS = new Set(["shopEmail"]);
function safeHttpsUrl(value: string): string {
const v = value.trim();
try {
return new URL(v).protocol === "https:" ? v : "";
} catch {
return "";
}
}
function safeEmailAddress(value: string): string {
const v = value.trim();
return /^[^\s@<>"'/\\]+@[^\s@<>"'/\\]+\.[^\s@<>"'/\\]+$/.test(v) ? v : "";
}
function renderTemplate(
template: string,
vars: TemplateVars,
opts: { html?: boolean } = {},
): string {
const html = opts.html !== false; // HTML-escape by default
return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => {
const raw = (vars as unknown as Record<string, string | undefined>)[key];
if (raw === undefined) return full;
if (!html) return raw;
let value = raw;
if (URL_TOKENS.has(key)) value = safeHttpsUrl(raw);
else if (EMAIL_TOKENS.has(key)) value = safeEmailAddress(raw);
return escapeHtml(value);
});
}
/** Strips HTML tags to produce a plain-text fallback for the multipart email. */
function htmlToText(html: string): string {
return html
.replace(/<br\s*\/?>(?=\s|$)/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function renderHtmlBody(html: string): { text: string; html: string } {
return { html, text: htmlToText(html) };
}
interface InvoiceCustomerSnapshot {
customerEmail?: string;
customerName?: string;
}
function parseCustomer(json: string): InvoiceCustomerSnapshot {
try {
return JSON.parse(json) as InvoiceCustomerSnapshot;
} catch {
return {};
}
}
interface InvoiceTotalsSnapshot {
totalGross?: string;
}
function parseTotals(json: string): InvoiceTotalsSnapshot {
try {
return JSON.parse(json) as InvoiceTotalsSnapshot;
} catch {
return {};
}
}
/**
* Resolves the shop logo bytes for inline embedding. Returns null if no
* logo is configured or the lookup fails — the email is still sent, just
* without the inline image (the alt text remains visible).
*/
async function loadInlineLogo(
shopDomain: string,
settings: ShopSettings,
): Promise<{ bytes: Uint8Array; contentType: string } | null> {
if (!settings.logoUrl) return null;
try {
if (settings.logoUrl === STORED_LOGO_SENTINEL) {
const cached = await db.logoCache.findUnique({ where: { shopDomain } });
if (!cached) return null;
return { bytes: new Uint8Array(cached.bytes), contentType: cached.contentType };
}
const res = await safeFetch(settings.logoUrl, {
maxBytes: 5 * 1024 * 1024,
accept: "image/*",
});
if (res.status < 200 || res.status >= 300) return null;
const ct = res.contentType ?? "image/png";
return { bytes: res.bytes, contentType: ct };
} catch (err) {
if (err instanceof SafeFetchError) {
console.warn(`Inline logo fetch refused (${err.code}): ${err.message}`);
}
return null;
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Default invoice email templates per language. Used when the user hasn't
* customised them in settings. Variables ({{invoiceNumber}}, etc.) are
* substituted by `renderTemplate` at send time.
*
* The shop logo is rendered as an inline attachment with content-id
* `invoice-logo`; the email sender attaches the logo bytes automatically
* when the template (or any custom template) references that cid.
*/
const DE_HTML = `\
<h2 style="margin:0 0 8px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">{{companyName}}</span></h2>
<h3 style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">Danke für deinen Einkauf!</span></h3>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Die Rechnung befindet sich im Anhang.
</p>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Bei Überweisung bitte die Rechnungs-Nummer als Referenz verwenden:
<strong>{{invoiceNumber}}</strong><br>
Besten Dank!
</p>
<p style="margin-top:24px;">
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
</p>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#0883DA;">
✉ <a href="mailto:{{shopEmail}}" style="color:#0883DA;">Kontakt</a><br>
🌐 <a href="{{shopWebsite}}" style="color:#0883DA;">{{shopWebsite}}</a>
</p>`;
const EN_HTML = `\
<h2 style="margin:0 0 8px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">{{companyName}}</span></h2>
<h3 style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">Thank you for your purchase!</span></h3>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Please find the invoice attached.
</p>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
When paying by bank transfer, please use the invoice number as the reference:
<strong>{{invoiceNumber}}</strong><br>
Thanks a lot!
</p>
<p style="margin-top:24px;">
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
</p>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#0883DA;">
✉ <a href="mailto:{{shopEmail}}" style="color:#0883DA;">Contact</a><br>
🌐 <a href="{{shopWebsite}}" style="color:#0883DA;">{{shopWebsite}}</a>
</p>`;
export const DEFAULT_EMAIL_SUBJECT_DE = "Rechnung {{invoiceNumber}} {{companyName}}";
export const DEFAULT_EMAIL_SUBJECT_EN = "Invoice {{invoiceNumber}} {{companyName}}";
export const DEFAULT_EMAIL_BODY_DE = DE_HTML;
export const DEFAULT_EMAIL_BODY_EN = EN_HTML;
+54 -23
View File
@@ -6,6 +6,7 @@ import db from "../../db.server";
import { composeInvoice } from "./composeInvoice";
import { buildGiroCodeDataUrl } from "./girocode";
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
import { loadDraftOrderForOffer } from "./loadDraftOrderForOffer.server";
import { getLogoDataUrl } from "./logoCache.server";
import { attachLineItemImages } from "./productImageCache.server";
import { allocateInvoiceNumber } from "./numbering.server";
@@ -19,6 +20,15 @@ export interface GenerateInvoiceArgs {
orderId: string;
/** When true, bypass the "sent invoice is locked" rule and regenerate in place. */
forceRegenerate?: boolean;
/**
* Document kind. Default "invoice". When "offer":
* - `orderId` is interpreted as a DraftOrder id (numeric or GID).
* - The number is the draft order's name (e.g. "D1") rather than an
* allocated invoice number.
* - GiroCode is suppressed and the dueDate is repurposed as the offer's
* validity expiry.
*/
kind?: "invoice" | "offer";
}
export interface GeneratedInvoice {
@@ -44,7 +54,8 @@ export async function generateInvoice(
args: GenerateInvoiceArgs,
): Promise<GeneratedInvoice> {
const { shopDomain, admin } = args;
const orderGid = toOrderGid(args.orderId);
const kind = args.kind ?? "invoice";
const orderGid = kind === "offer" ? toDraftOrderGid(args.orderId) : toOrderGid(args.orderId);
const settings = await db.shopSettings.upsert({
where: { shopDomain },
@@ -52,15 +63,17 @@ export async function generateInvoice(
create: { shopDomain },
});
const order = await loadOrderForInvoice(admin, orderGid);
const order = kind === "offer"
? await loadDraftOrderForOffer(admin, orderGid)
: await loadOrderForInvoice(admin, orderGid);
// Find latest existing invoice (excluding storno) for this order.
// Find latest existing document of this kind for this (draft) order.
const latest = await db.invoice.findFirst({
where: { shopDomain, orderId: orderGid, kind: "invoice", cancelledAt: null },
where: { shopDomain, orderId: orderGid, kind, cancelledAt: null },
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
if (latest && latest.sentAt && !args.forceRegenerate) {
if (kind === "invoice" && latest && latest.sentAt && !args.forceRegenerate) {
throw new Error(
`Invoice ${latest.invoiceNumber} has already been sent. Use cancel-and-reissue to correct it.`,
);
@@ -68,10 +81,12 @@ export async function generateInvoice(
const invoiceNumber = latest
? latest.invoiceNumber
: await allocateInvoiceNumber(settings, order.orderNumber);
: kind === "offer"
? order.name // e.g. "D1" — Shopify's draft order name is the offer number.
: await allocateInvoiceNumber(settings, order.orderNumber);
// Compose view model and render PDF.
const viewModel = composeInvoice({ order, settings, invoiceNumber });
const viewModel = composeInvoice({ order, settings, invoiceNumber, offer: kind === "offer" });
// Logo (cached).
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
@@ -80,15 +95,21 @@ export async function generateInvoice(
// Product images for each line (best-effort, parallel, in-process cache).
await attachLineItemImages(viewModel.lines);
// GiroCode (only for unpaid + IBAN configured + enabled).
// GiroCode (only when the invoice is actually outstanding + IBAN
// configured + enabled). `requiresPayment` already encodes "regular
// invoice AND money still owed" — so paid, refunded, storno and
// offer documents all skip QR generation and the PDF stays tidy.
if (
kind === "invoice" &&
settings.giroCodeEnabled &&
settings.iban &&
!viewModel.paid &&
viewModel.requiresPayment &&
viewModel.totals.gross > 0
) {
viewModel.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName || "Beneficiary",
beneficiaryName:
[settings.companyName, settings.legalForm].filter(Boolean).join(" ") ||
"Beneficiary",
iban: settings.iban,
bic: settings.bic,
amount: viewModel.totals.gross,
@@ -99,12 +120,13 @@ export async function generateInvoice(
const pdfBuffer = await renderInvoicePdf(viewModel);
const filename = `Rechnung-${sanitiseForFilename(invoiceNumber)}.pdf`;
const filenamePrefix = kind === "offer" ? "Angebot" : "Rechnung";
const filename = `${filenamePrefix}-${sanitiseForFilename(invoiceNumber)}.pdf`;
const upload = await uploadPdfToShopifyFiles(admin, {
bytes: pdfBuffer,
filename,
alt: `Invoice ${invoiceNumber}`,
alt: kind === "offer" ? `Offer ${invoiceNumber}` : `Invoice ${invoiceNumber}`,
});
const version = latest ? latest.version + 1 : 1;
@@ -139,7 +161,7 @@ export async function generateInvoice(
orderNumber: order.orderNumber,
invoiceNumber,
language: viewModel.language,
kind: "invoice",
kind,
version: 1,
pdfFileGid: upload.fileGid,
pdfUrl: upload.url,
@@ -150,15 +172,18 @@ export async function generateInvoice(
});
// Link the latest PDF on the order via metafields (best-effort; do not
// fail the whole operation if scopes are missing).
try {
await writeOrderMetafields(admin, orderGid, {
pdfUrl: upload.url,
number: invoiceNumber,
version: invoice.version,
});
} catch (err) {
console.warn("Order metafield write failed:", err);
// fail the whole operation if scopes are missing). Skip for offers since
// draft orders don't accept the same metafields.
if (kind === "invoice") {
try {
await writeOrderMetafields(admin, orderGid, {
pdfUrl: upload.url,
number: invoiceNumber,
version: invoice.version,
});
} catch (err) {
console.warn("Order metafield write failed:", err);
}
}
return {
@@ -177,8 +202,14 @@ export function toOrderGid(input: string): string {
return `gid://shopify/Order/${input}`;
}
/** Same idea for DraftOrder ids. */
export function toDraftOrderGid(input: string): string {
if (input.startsWith("gid://")) return input;
return `gid://shopify/DraftOrder/${input}`;
}
function sanitiseForFilename(s: string): string {
return s.replace(/[^A-Za-z0-9._-]/g, "_");
return s.replace(/[^A-Za-z0-9._-]/g, "");
}
export { sanitiseForFilename };
+26 -3
View File
@@ -18,6 +18,15 @@ export interface GiroCodeInput {
remittance: string;
}
/**
* Replaces CR/LF in a free-text EPC field with a single space and collapses
* runs of whitespace, so the line-delimited payload can't be tampered with by
* smuggling newlines into user-supplied text (beneficiary name / remittance).
*/
function sanitizeEpcField(value: string): string {
return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
}
export function buildGiroCodePayload(input: GiroCodeInput): string {
const currency = input.currency || "EUR";
if (currency !== "EUR") {
@@ -25,12 +34,14 @@ export function buildGiroCodePayload(input: GiroCodeInput): string {
console.warn(`GiroCode: non-EUR currency ${currency} is non-standard.`);
}
// Beneficiary name max 70 chars per spec.
const name = input.beneficiaryName.slice(0, 70);
// Beneficiary name max 70 chars per spec. Strip CR/LF first so injected
// newlines can't forge/add EPC fields (the payload is line-delimited).
const name = sanitizeEpcField(input.beneficiaryName).slice(0, 70);
const iban = input.iban.replace(/\s+/g, "").toUpperCase();
const bic = (input.bic || "").replace(/\s+/g, "").toUpperCase();
const amount = input.amount.toFixed(2);
const remittance = input.remittance.slice(0, 140);
// Unstructured remittance max 140 chars; strip CR/LF for the same reason.
const remittance = sanitizeEpcField(input.remittance).slice(0, 140);
// Field order is fixed; trailing fields can be empty.
// Service tag SCT = SEPA Credit Transfer.
@@ -61,3 +72,15 @@ export async function buildGiroCodeDataUrl(
width: 256,
});
}
export async function buildGiroCodePngBuffer(
input: GiroCodeInput,
): Promise<Buffer> {
const payload = buildGiroCodePayload(input);
return QRCode.toBuffer(payload, {
errorCorrectionLevel: "M",
margin: 1,
width: 256,
type: "png",
});
}
+204 -9
View File
@@ -5,6 +5,10 @@ export type InvoiceLanguage = "de" | "en";
export interface InvoiceStrings {
invoice: string;
stornoInvoice: string;
offer: string;
offerNumber: string;
offerDate: string;
offerValidUntil: (until: string) => string;
stornoReference: (originalNumber: string) => string;
invoiceNumber: string;
invoiceDate: string;
@@ -37,6 +41,24 @@ export interface InvoiceStrings {
ibanLabel: string;
bicLabel: string;
bankLabel: string;
recipientLabel: string;
amountLabel: string;
referenceLabel: string;
/** Label for the refund row that appears below `grossTotal` when the
* order has been (partially or fully) refunded. Mirrors what Shopify
* shows on the order page ("Zurückerstattet" / "Refunded"). */
refundedLabel: string;
/** Label for the final outstanding balance row (`grossTotal -
* refundedAmount`) shown when there has been a refund. "Offener
* Betrag" / "Outstanding amount". */
outstandingLabel: string;
/** Label used in place of `outstandingLabel` when the order has been
* refunded but nothing is actually owed any more (i.e. the customer
* paid in full and got back only part — or all — of the gross via
* refunds). "Endbetrag" / "Total". The distinction matters for
* PARTIALLY_REFUNDED orders, where calling the kept portion
* "outstanding" would falsely suggest the customer still owes it. */
finalAmountLabel: string;
addressHeading: string;
contactHeading: string;
legalHeading: string;
@@ -44,17 +66,106 @@ export interface InvoiceStrings {
emailLabel: string;
webLabel: string;
phoneLabel: string;
paidStamp: string;
paymentMethodLabel: string;
paymentStatusLabel: string;
paymentStatusPaid: string;
paymentStatusUnpaid: string;
paymentStatusPartial: string;
paymentStatusRefunded: string;
paymentStatusVoided: string;
orderNumberLabel: string;
shippingAddressHeading: string;
shippingMethodLabel: string;
trackingLabel: string;
shippingItemPrefix: string;
discountCodeLabel: string;
pickupLabel: string;
/** Used as the meta-row label when the order is a local pickup. The row
* value is then the pickup location name (e.g. "Lager Graz"). */
pickupLocationLabel: string;
/** Localized labels for Shopify's built-in payment-gateway names. The
* Admin GraphQL API only ever returns the *English* template name
* (e.g. "Bank Deposit") in `Order.paymentGatewayNames`, even when the
* storefront / order-confirmation page renders the localized variant
* ("Banküberweisung"). We mirror Shopify's checkout copy here so the
* printed PDF matches what the customer saw at checkout. Lookup is
* case-insensitive on the normalized key (lowercased, separators
* collapsed). Unknown gateways fall back to a title-cased rendering
* of the raw name. */
paymentGatewayLabels: Record<string, string>;
}
/** Status displayed for the order's payment, derived from Shopify's
* `displayFinancialStatus`. */
export type PaymentStatus =
| "paid"
| "unpaid"
| "partial"
| "refunded"
| "voided";
export function paymentStatusLabel(
status: PaymentStatus,
strings: InvoiceStrings,
): string {
switch (status) {
case "paid":
return strings.paymentStatusPaid;
case "partial":
return strings.paymentStatusPartial;
case "refunded":
return strings.paymentStatusRefunded;
case "voided":
return strings.paymentStatusVoided;
default:
return strings.paymentStatusUnpaid;
}
}
/** Maps Shopify's `displayFinancialStatus` to our condensed enum.
*
* - PAID → paid
* - PARTIALLY_PAID → partial
* - REFUNDED / PARTIALLY_REFUNDED → refunded
* (composeInvoice further reclassifies PARTIALLY_REFUNDED with a
* refund < gross back to "paid".)
* - VOIDED → voided
* (authorisation cancelled before capture; no money was ever
* received and none is owed — distinct from "unpaid".)
* - PENDING / AUTHORIZED / EXPIRED / unknown → unpaid
*
* Unknown values log a warning so we notice when Shopify adds a new
* enum member. */
export function derivePaymentStatus(
displayFinancialStatus: string | null | undefined,
): PaymentStatus {
const v = (displayFinancialStatus || "").toUpperCase();
if (v === "PAID") return "paid";
if (v === "PARTIALLY_PAID") return "partial";
if (v === "REFUNDED" || v === "PARTIALLY_REFUNDED") return "refunded";
if (v === "VOIDED") return "voided";
if (v && v !== "PENDING" && v !== "AUTHORIZED" && v !== "EXPIRED") {
console.warn(
`[invoice] derivePaymentStatus: unknown displayFinancialStatus ${JSON.stringify(
displayFinancialStatus,
)} — falling back to "unpaid".`,
);
}
return "unpaid";
}
const de: InvoiceStrings = {
invoice: "Rechnung",
stornoInvoice: "Stornorechnung",
offer: "Angebot",
offerNumber: "Angebots-Nr.",
offerDate: "Angebotsdatum",
offerValidUntil: (d) => `Dieses Angebot ist gültig bis ${d}.`,
stornoReference: (n) => `Storno zu Rechnung Nr. ${n}`,
invoiceNumber: "Rechnungs-Nr.",
invoiceDate: "Rechnungsdatum",
deliveryDate: "Lieferdatum",
customerVatId: "Ihre USt-Id.",
customerVatId: "Deine USt-Id.",
position: "Pos.",
description: "Beschreibung",
quantity: "Menge",
@@ -63,12 +174,12 @@ const de: InvoiceStrings = {
netTotal: "Gesamtbetrag netto",
vatLine: (r) => `zzgl. Umsatzsteuer ${r}`,
grossTotal: "Gesamtbetrag brutto",
salutationGeneric: "Sehr geehrte Damen und Herren,",
salutationGeneric: "Hallo,",
thankYouLine:
"vielen Dank für Ihren Auftrag. Wir erlauben uns, Ihnen folgende Leistungen in Rechnung zu stellen:",
"Vielen Dank für deine Bestellung. Wir berechnen dir folgende Leistungen:",
closing: "Danke für deinen Einkauf",
paymentTerms: (days, due) =>
`Bitte überweisen Sie den Rechnungsbetrag innerhalb von ${days} Tagen, spätestens bis zum ${due}, auf das unten angegebene Konto. Bei Fragen zur Rechnung stehen wir Ihnen gerne zur Verfügung.`,
`Bitte überweise den Rechnungsbetrag innerhalb von ${days} Tagen, spätestens bis zum ${due}, auf das unten angegebene Konto. Bei Fragen zur Rechnung sind wir gerne für dich da.`,
paymentTermsImmediate:
"Der Rechnungsbetrag ist sofort nach Erhalt zur Zahlung fällig.",
giroCodeCaption: "GiroCode",
@@ -87,6 +198,12 @@ const de: InvoiceStrings = {
ibanLabel: "IBAN",
bicLabel: "BIC",
bankLabel: "Bank",
recipientLabel: "Empfänger",
amountLabel: "Betrag",
referenceLabel: "Referenz",
refundedLabel: "Zurückerstattet",
outstandingLabel: "Offener Betrag",
finalAmountLabel: "Endbetrag",
addressHeading: "Adresse",
contactHeading: "Kontakt",
legalHeading: "Rechtliches",
@@ -94,12 +211,47 @@ const de: InvoiceStrings = {
emailLabel: "E-Mail",
webLabel: "Web",
phoneLabel: "Tel.",
paidStamp: "BEZAHLT",
paymentMethodLabel: "Zahlart",
paymentStatusLabel: "Zahlstatus",
paymentStatusPaid: "Bezahlt",
paymentStatusUnpaid: "Offen",
paymentStatusPartial: "Teilweise bezahlt",
paymentStatusRefunded: "Erstattet",
paymentStatusVoided: "Annulliert",
orderNumberLabel: "Bestellnummer",
shippingAddressHeading: "Lieferadresse",
shippingMethodLabel: "Versandart",
trackingLabel: "Sendungsnummer",
shippingItemPrefix: "Versand",
discountCodeLabel: "Rabattcode",
pickupLabel: "Abholung",
pickupLocationLabel: "Abholort",
paymentGatewayLabels: {
// Built-in Shopify manual payment methods (template names).
"bank deposit": "Banküberweisung",
"bank transfer": "Banküberweisung",
"money order": "Postanweisung",
"cash on delivery": "Nachnahme",
"cash on delivery (cod)": "Nachnahme",
// Generic / technical gateways.
manual: "Manuelle Zahlung",
bogus: "Bogus (Test)",
"shopify payments": "Shopify Payments",
paypal: "PayPal",
"paypal express checkout": "PayPal",
klarna: "Klarna",
sofort: "Sofort",
giropay: "Giropay",
},
};
const en: InvoiceStrings = {
invoice: "Invoice",
stornoInvoice: "Cancellation invoice",
offer: "Offer",
offerNumber: "Offer no.",
offerDate: "Offer date",
offerValidUntil: (d) => `This offer is valid until ${d}.`,
stornoReference: (n) => `Cancels invoice no. ${n}`,
invoiceNumber: "Invoice no.",
invoiceDate: "Invoice date",
@@ -136,6 +288,12 @@ const en: InvoiceStrings = {
ibanLabel: "IBAN",
bicLabel: "BIC",
bankLabel: "Bank",
recipientLabel: "Recipient",
amountLabel: "Amount",
referenceLabel: "Reference",
refundedLabel: "Refunded",
outstandingLabel: "Outstanding amount",
finalAmountLabel: "Total",
addressHeading: "Address",
contactHeading: "Contact",
legalHeading: "Legal",
@@ -143,14 +301,51 @@ const en: InvoiceStrings = {
emailLabel: "E-mail",
webLabel: "Web",
phoneLabel: "Tel.",
paidStamp: "PAID",
paymentMethodLabel: "Payment method",
paymentStatusLabel: "Payment status",
paymentStatusPaid: "Paid",
paymentStatusUnpaid: "Outstanding",
paymentStatusPartial: "Partially paid",
paymentStatusRefunded: "Refunded",
paymentStatusVoided: "Voided",
orderNumberLabel: "Order no.",
shippingAddressHeading: "Shipping address",
shippingMethodLabel: "Shipping method",
trackingLabel: "Tracking no.",
shippingItemPrefix: "Shipping",
discountCodeLabel: "Discount code",
pickupLabel: "Pick-up",
pickupLocationLabel: "Pick-up location",
paymentGatewayLabels: {
"bank deposit": "Bank deposit",
"bank transfer": "Bank transfer",
"money order": "Money order",
"cash on delivery": "Cash on delivery",
"cash on delivery (cod)": "Cash on delivery (COD)",
manual: "Manual",
bogus: "Bogus (Test)",
"shopify payments": "Shopify Payments",
paypal: "PayPal",
"paypal express checkout": "PayPal",
klarna: "Klarna",
sofort: "Sofort",
giropay: "Giropay",
},
};
// Locale → invoice language. We only render in German (`de`) when the
// caller is explicitly German-speaking (de, de-AT, de-DE, de_CH, …).
// Everything else (it, fr, es, en, …) falls back to English so that
// non-German-speaking customers don't receive a German invoice. Callers
// that have a per-shop default fall back to it via
// `pickLanguage(customerLocale ?? settings.defaultLanguage)`, which is why
// `null`/`undefined` still maps to `de` (the legacy default for the
// Austrian shops this app was built for).
export function pickLanguage(input: string | null | undefined): InvoiceLanguage {
if (!input) return "de";
const v = input.toLowerCase();
if (v.startsWith("en")) return "en";
return "de";
if (v.startsWith("de")) return "de";
return "en";
}
export function getStrings(language: InvoiceLanguage): InvoiceStrings {
@@ -0,0 +1,192 @@
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
import type {
RawAddress,
RawLineItem,
RawMoney,
RawOrderForInvoice,
RawTaxLine,
} from "./loadOrderForInvoice.server";
/**
* Loads a Shopify DraftOrder and adapts it to the same `RawOrderForInvoice`
* shape used for completed orders, so the rest of the pipeline (composer,
* PDF, etc.) doesn't need to know whether it's rendering an invoice or an
* offer.
*
* Drafts have no `processedAt` (we use createdAt) and no
* `displayFinancialStatus` (we treat them as not paid).
*/
const QUERY = `#graphql
query DraftOrderForOffer($id: ID!) {
draftOrder(id: $id) {
id
name
createdAt
currencyCode
taxesIncluded
customer {
firstName
lastName
email
locale
}
billingAddress {
name
company
address1
address2
zip
city
province
countryCode: countryCodeV2
}
shippingAddress {
name
company
address1
address2
zip
city
province
countryCode: countryCodeV2
}
subtotalPriceSet { shopMoney { amount currencyCode } }
totalTaxSet { shopMoney { amount currencyCode } }
totalPriceSet { shopMoney { amount currencyCode } }
taxLines {
title
rate
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
lineItems(first: 250) {
edges {
node {
title
sku
quantity
originalUnitPriceSet { shopMoney { amount currencyCode } }
image { url altText }
taxLines {
title
rate
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
}
}
}
purchasingEntity {
... on PurchasingCompany {
company { name }
location {
taxRegistrationId
billingAddress {
address1
address2
zip
city
countryCode
}
}
}
}
}
}
`;
interface RawAdminResponse {
data?: {
draftOrder?: {
id: string;
name: string;
createdAt: string;
currencyCode: string;
taxesIncluded: boolean;
customer: {
firstName: string | null;
lastName: string | null;
email: string | null;
locale: string | null;
} | null;
billingAddress: RawAddress | null;
shippingAddress: RawAddress | null;
subtotalPriceSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
lineItems: { edges: { node: RawLineItem & { image?: { url: string | null } | null } }[] };
purchasingEntity: {
company?: { name: string } | null;
location?: {
taxRegistrationId: string | null;
billingAddress: RawAddress | null;
} | null;
} | null;
} | null;
};
}
export async function loadDraftOrderForOffer(
admin: AdminApiContext,
draftOrderGid: string,
): Promise<RawOrderForInvoice> {
const response = await admin.graphql(QUERY, { variables: { id: draftOrderGid } });
const json = (await response.json()) as RawAdminResponse;
const draft = json.data?.draftOrder;
if (!draft) {
throw new Error(`Draft order ${draftOrderGid} not found.`);
}
const purchasingCompany = draft.purchasingEntity?.company
? {
name: draft.purchasingEntity.company.name,
vatId: draft.purchasingEntity.location?.taxRegistrationId ?? null,
address: draft.purchasingEntity.location?.billingAddress ?? null,
}
: null;
// Drafts don't have a numeric "order number" — use a hash of the GID as a
// numeric proxy for the invoice-counter signature (not actually used when
// generating offers, but kept non-zero to satisfy downstream code).
const orderNumber = parseInt(draft.id.replace(/[^0-9]/g, "").slice(-9), 10) || 0;
return {
id: draft.id,
name: draft.name,
orderNumber,
createdAt: draft.createdAt,
processedAt: null,
currencyCode: draft.currencyCode,
displayFinancialStatus: null,
paymentGatewayNames: [],
requiresShipping: false,
shippingLine: null,
fulfillments: [],
discountCodes: [],
taxesIncluded: draft.taxesIncluded,
customer: draft.customer,
billingAddress: draft.billingAddress,
shippingAddress: draft.shippingAddress,
subtotalSet: draft.subtotalPriceSet,
totalTaxSet: draft.totalTaxSet,
totalPriceSet: draft.totalPriceSet,
// Drafts have no concept of refunds.
totalRefundedSet: null,
taxLines: draft.taxLines || [],
lineItems: (draft.lineItems?.edges || []).map((e) => {
const node = e.node;
return {
title: node.title,
sku: node.sku,
quantity: node.quantity,
originalUnitPriceSet: node.originalUnitPriceSet,
discountedUnitPriceSet: null,
taxLines: node.taxLines,
imageUrl: node.image?.url ?? null,
};
}),
purchasingEntity: { company: purchasingCompany },
};
}
@@ -12,6 +12,12 @@ export interface RawOrderForInvoice {
processedAt: string | null;
currencyCode: string;
displayFinancialStatus: string | null;
paymentGatewayNames: string[];
/** True when the order contains at least one shippable line item. For
* pickup orders this is `true` but `shippingAddress` is `null` — that
* combination is the most reliable pickup signal we have without
* hitting `read_merchant_managed_fulfillment_orders`. */
requiresShipping: boolean;
customer: {
firstName: string | null;
lastName: string | null;
@@ -22,10 +28,21 @@ export interface RawOrderForInvoice {
shippingAddress: RawAddress | null;
lineItems: RawLineItem[];
taxLines: RawTaxLine[];
shippingLine: RawShippingLine | null;
fulfillments: RawFulfillment[];
/** Discount codes applied to the cart (e.g. `["SUMMER10"]`). Empty when
* no codes were used. Manual / automatic discounts without a code are
* not exposed here. */
discountCodes: string[];
taxesIncluded: boolean;
subtotalSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null;
/** Cumulative gross amount that has been refunded against this order
* via Shopify (sum of all refund transactions). Always present on
* real orders — may be `null` for synthetic / draft fixtures, in
* which case the composer treats it as 0. */
totalRefundedSet: { shopMoney: RawMoney } | null;
purchasingEntity: {
company?: {
name: string;
@@ -56,6 +73,10 @@ export interface RawLineItem {
sku: string | null;
quantity: number;
originalUnitPriceSet: { shopMoney: RawMoney };
/** Per-unit price after Shopify has allocated cart-level discounts to this
* line. May be null when no discount applied (in which case use the
* original price). */
discountedUnitPriceSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
imageUrl: string | null;
}
@@ -67,6 +88,34 @@ export interface RawTaxLine {
priceSet: { shopMoney: RawMoney };
}
export interface RawShippingLine {
title: string | null;
code: string | null;
source: string | null;
carrierIdentifier: string | null;
/** Lowercase string like "shipping", "pickup", "local_pickup". Used as
* the primary pickup signal because it doesn't require the
* fulfillment-orders scope. */
deliveryCategory: string | null;
originalPriceSet: { shopMoney: RawMoney } | null;
discountedPriceSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
}
export interface RawTrackingInfo {
number: string | null;
url: string | null;
company: string | null;
}
export interface RawFulfillment {
/** ISO timestamp of when the fulfillment was created (i.e. when the goods
* were dispatched / handed over). Used for the legally-required delivery
* date on the invoice when present. */
createdAt: string | null;
trackingInfo: RawTrackingInfo[];
}
const QUERY = `#graphql
query OrderForInvoice($id: ID!) {
order(id: $id) {
@@ -77,7 +126,9 @@ const QUERY = `#graphql
processedAt
currencyCode
displayFinancialStatus
paymentGatewayNames
taxesIncluded
requiresShipping
customer {
firstName
lastName
@@ -107,12 +158,38 @@ const QUERY = `#graphql
subtotalPriceSet { shopMoney { amount currencyCode } }
totalTaxSet { shopMoney { amount currencyCode } }
totalPriceSet { shopMoney { amount currencyCode } }
totalRefundedSet { shopMoney { amount currencyCode } }
taxLines {
title
rate
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
discountCode
discountCodes
shippingLine {
title
code
source
carrierIdentifier
deliveryCategory
originalPriceSet { shopMoney { amount currencyCode } }
discountedPriceSet { shopMoney { amount currencyCode } }
taxLines {
title
rate
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
}
fulfillments(first: 10) {
createdAt
trackingInfo {
number
url
company
}
}
lineItems(first: 250) {
edges {
node {
@@ -120,6 +197,7 @@ const QUERY = `#graphql
sku
quantity
originalUnitPriceSet { shopMoney { amount currencyCode } }
discountedUnitPriceSet { shopMoney { amount currencyCode } }
image { url altText }
taxLines {
title
@@ -161,7 +239,9 @@ interface RawAdminResponse {
processedAt: string | null;
currencyCode: string;
displayFinancialStatus: string | null;
paymentGatewayNames: string[] | null;
taxesIncluded: boolean;
requiresShipping: boolean | null;
customer: {
firstName: string | null;
lastName: string | null;
@@ -173,7 +253,12 @@ interface RawAdminResponse {
subtotalPriceSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null;
totalRefundedSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
discountCode: string | null;
discountCodes: string[] | null;
shippingLine: RawShippingLine | null;
fulfillments: RawFulfillment[] | null;
lineItems: { edges: { node: RawLineItem }[] };
purchasingEntity: {
company?: { name: string } | null;
@@ -213,14 +298,22 @@ export async function loadOrderForInvoice(
processedAt: order.processedAt,
currencyCode: order.currencyCode,
displayFinancialStatus: order.displayFinancialStatus,
paymentGatewayNames: order.paymentGatewayNames ?? [],
taxesIncluded: order.taxesIncluded,
requiresShipping: order.requiresShipping ?? false,
customer: order.customer,
billingAddress: order.billingAddress,
shippingAddress: order.shippingAddress,
subtotalSet: order.subtotalPriceSet,
totalTaxSet: order.totalTaxSet,
totalPriceSet: order.totalPriceSet,
totalRefundedSet: order.totalRefundedSet ?? null,
taxLines: order.taxLines || [],
discountCodes: order.discountCodes && order.discountCodes.length > 0
? order.discountCodes
: (order.discountCode ? [order.discountCode] : []),
shippingLine: order.shippingLine ?? null,
fulfillments: order.fulfillments ?? [],
lineItems: (order.lineItems?.edges || []).map((e) => {
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
return {
@@ -228,6 +321,7 @@ export async function loadOrderForInvoice(
sku: node.sku,
quantity: node.quantity,
originalUnitPriceSet: node.originalUnitPriceSet,
discountedUnitPriceSet: node.discountedUnitPriceSet ?? null,
taxLines: node.taxLines,
imageUrl: node.image?.url ?? null,
};
+15 -12
View File
@@ -1,5 +1,6 @@
import db from "../../db.server";
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
import { safeFetch, SafeFetchError } from "./safeFetch.server";
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB cap
const STALE_AFTER_MS = 24 * 60 * 60 * 1000; // re-fetch once a day at most
@@ -41,26 +42,28 @@ export async function getLogoDataUrl(
return toDataUrl(cached.bytes, cached.contentType);
}
let response: Response;
let response: Awaited<ReturnType<typeof safeFetch>>;
try {
response = await fetch(logoUrl);
response = await safeFetch(logoUrl, {
maxBytes: MAX_BYTES,
accept: "image/*",
});
} catch (err) {
console.warn(`Logo fetch failed for ${shopDomain}:`, err);
if (err instanceof SafeFetchError) {
console.warn(`Logo fetch refused for ${shopDomain} (${err.code}): ${err.message}`);
} else {
console.warn(`Logo fetch failed for ${shopDomain}:`, err);
}
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
}
if (!response.ok) {
if (response.status < 200 || response.status >= 300) {
console.warn(`Logo fetch HTTP ${response.status} for ${shopDomain}`);
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
}
const arrayBuf = await response.arrayBuffer();
if (arrayBuf.byteLength > MAX_BYTES) {
console.warn(`Logo too large (${arrayBuf.byteLength} bytes) — skipping cache.`);
return undefined;
}
const bytes = Buffer.from(arrayBuf);
const contentType = response.headers.get("content-type") || guessContentType(logoUrl);
const etag = response.headers.get("etag") || "";
const bytes = Buffer.from(response.bytes);
const contentType = response.contentType || guessContentType(logoUrl);
const etag = "";
await db.logoCache.upsert({
where: { shopDomain },
+259 -43
View File
@@ -2,6 +2,7 @@
import {
Document,
Image,
Link,
Page,
StyleSheet,
Text,
@@ -10,7 +11,7 @@ import {
import React from "react";
import { formatDate, formatMoney, formatQuantity, formatTaxRate } from "../format";
import { getStrings } from "../i18n";
import { getStrings, paymentStatusLabel as getPaymentStatusLabel } from "../i18n";
import type { InvoiceLanguage } from "../i18n";
import type { InvoiceViewModel, InvoiceLine, IssuerData, RecipientData } from "../types";
@@ -21,6 +22,21 @@ const TEXT_DARK = "#1F2933";
const TEXT_MUTED = "#6B7280";
const TABLE_BORDER = "#E5E7EB";
/**
* Returns true only for syntactically valid http(s) URLs. Used to gate
* carrier/fulfillment-supplied tracking URLs before embedding them as PDF
* link annotations, so non-http schemes (javascript:, file:, data:, …) can't
* be smuggled into the document.
*/
function isHttpUrl(value: string): boolean {
try {
const u = new URL(value);
return u.protocol === "https:" || u.protocol === "http:";
} catch {
return false;
}
}
const styles = StyleSheet.create({
page: {
paddingTop: 40,
@@ -51,12 +67,32 @@ const styles = StyleSheet.create({
recipientBlock: {
width: "55%",
},
recipientBlockFull: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 20,
},
recipientName: {
fontFamily: "Helvetica-Bold",
fontSize: 10,
},
shippingAddressBlock: {
marginTop: 10,
paddingTop: 6,
borderTopWidth: 0.5,
borderTopColor: TABLE_BORDER,
},
shippingAddressHeading: {
fontFamily: "Helvetica-Bold",
color: BRAND_BLUE,
fontSize: 8,
marginBottom: 2,
},
metaBlock: {
width: "40%",
width: "45%",
},
metaBlockHeader: {
width: "50%",
},
metaTable: {
flexDirection: "column",
@@ -72,6 +108,11 @@ const styles = StyleSheet.create({
metaValue: {
fontFamily: "Helvetica-Bold",
},
unitOriginalStrike: {
color: TEXT_MUTED,
textDecoration: "line-through",
fontSize: 7,
},
invoiceNumberBig: {
color: BRAND_BLUE,
fontFamily: "Helvetica-Bold",
@@ -245,7 +286,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
return (
<Document
title={`${t.invoice} ${invoice.number}`}
title={`${invoice.kind === "offer" ? t.offer : t.invoice} ${invoice.number}`}
author={invoice.issuer.companyName}
creator="LinumIQ Invoice"
>
@@ -257,42 +298,117 @@ export function InvoiceDocument({ invoice }: DocProps) {
</Text>
)}
<Header issuer={invoice.issuer} />
<View style={styles.headerRow}>
<View style={styles.recipientBlock}>
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
<Recipient recipient={invoice.recipient} />
</View>
<View style={styles.metaBlock}>
{invoice.issuer.logoDataUrl ? (
<Image src={invoice.issuer.logoDataUrl} style={styles.logo} />
) : (
<View />
)}
<View style={styles.metaBlockHeader}>
<View style={styles.metaTable}>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.invoiceNumber}</Text>
<Text style={styles.invoiceNumberBig}>{invoice.number}</Text>
</View>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.invoiceDate}</Text>
<Text style={styles.metaLabel}>{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}</Text>
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
</View>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.deliveryDate}</Text>
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
</View>
{invoice.kind !== "offer" && (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.deliveryDate}</Text>
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
</View>
)}
{invoice.recipientVatId ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.customerVatId}</Text>
<Text style={styles.metaValue}>{invoice.recipientVatId}</Text>
</View>
) : null}
{invoice.kind === "invoice" && invoice.paymentGatewayNames.length > 0 && (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.paymentMethodLabel}</Text>
<Text style={styles.metaValue}>
{invoice.paymentGatewayNames.map((n) => prettifyGatewayName(n, t.paymentGatewayLabels)).join(", ")}
</Text>
</View>
)}
{invoice.kind === "invoice" && (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.paymentStatusLabel}</Text>
<Text style={styles.metaValue}>
{getPaymentStatusLabel(invoice.paymentStatus, t)}
</Text>
</View>
)}
{invoice.kind === "invoice" && invoice.discountCodes.length > 0 ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.discountCodeLabel}</Text>
<Text style={styles.metaValue}>{invoice.discountCodes.join(", ")}</Text>
</View>
) : null}
{invoice.kind === "invoice" && invoice.isPickup ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.pickupLocationLabel}</Text>
<Text style={styles.metaValue}>
{invoice.pickupLocationName ?? t.pickupLabel}
</Text>
</View>
) : invoice.kind === "invoice" && invoice.shippingMethod ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
<Text style={styles.metaValue}>{invoice.shippingMethod}</Text>
</View>
) : null}
{invoice.kind === "invoice" && invoice.tracking.map((tr) => (
<View key={tr.number} style={styles.metaRow}>
<Text style={styles.metaLabel}>
{t.trackingLabel}
{tr.company ? ` (${tr.company})` : ""}
</Text>
{tr.url && isHttpUrl(tr.url) ? (
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
) : (
<Text style={styles.metaValue}>{tr.number}</Text>
)}
</View>
))}
</View>
</View>
</View>
<View style={styles.recipientBlockFull}>
<View style={styles.recipientBlock}>
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
<Recipient recipient={invoice.recipient} />
</View>
{invoice.separateShippingAddress ? (
<View style={styles.recipientBlock}>
<Text style={styles.shippingAddressHeading}>{t.shippingAddressHeading}</Text>
<Recipient recipient={invoice.separateShippingAddress} />
</View>
) : null}
</View>
<Text style={styles.title}>
{invoice.kind === "storno" ? t.stornoInvoice : t.invoice} Nr. {invoice.number}
{invoice.kind === "storno"
? t.stornoInvoice
: invoice.kind === "offer"
? t.offer
: t.invoice}{" "}
Nr. {invoice.number}
{invoice.kind === "invoice"
&& invoice.orderName
// Suppress the redundant "· Bestellnummer: #1004" suffix when
// the invoice number is just the Shopify order number with the
// configured prefix (default numbering mode) — they'd carry
// identical trailing digits and only confuse the customer.
&& invoice.number.replace(/\D+/g, "") !== invoice.orderName.replace(/\D+/g, "")
? ` · ${t.orderNumberLabel}: ${invoice.orderName}`
: ""}
</Text>
<Text style={styles.paragraph}>{t.salutationGeneric}</Text>
{/* No salutation here on purpose — this is an invoice, not a
* letter. Dropping the line saves vertical space and avoids
* the formal/informal "Hallo," vs "Dear Sir or Madam" framing
* that doesn't belong on a tax document. */}
<Text style={styles.paragraph}>{t.thankYouLine}</Text>
<View style={styles.table}>
@@ -327,6 +443,24 @@ export function InvoiceDocument({ invoice }: DocProps) {
{formatMoney(invoice.totals.gross, cur, invoice.language)}
</Text>
</View>
{invoice.refundedAmount > 0 && (
<>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{t.refundedLabel}</Text>
<Text style={styles.totalValue}>
{formatMoney(-invoice.refundedAmount, cur, invoice.language)}
</Text>
</View>
<View style={[styles.totalRow, { borderTopWidth: 0.5, borderTopColor: TABLE_BORDER, marginTop: 4, paddingTop: 4 }]}>
<Text style={styles.totalLabelBlue}>
{invoice.requiresPayment ? t.outstandingLabel : t.finalAmountLabel}
</Text>
<Text style={styles.totalValueBoldBlue}>
{formatMoney(invoice.totals.gross - invoice.refundedAmount, cur, invoice.language)}
</Text>
</View>
</>
)}
</View>
{invoice.notices.length > 0 && (
@@ -341,27 +475,41 @@ export function InvoiceDocument({ invoice }: DocProps) {
</View>
)}
<Text style={[styles.paragraph, { marginTop: 16 }]}>
{invoice.dueDate
? t.paymentTerms(
Math.max(0, Math.round((invoice.dueDate.getTime() - invoice.invoiceDate.getTime()) / 86400000)),
formatDate(invoice.dueDate, invoice.language),
)
: t.paymentTermsImmediate}
</Text>
{invoice.kind === "offer" ? (
<Text style={[styles.paragraph, { marginTop: 16 }]}>
{invoice.dueDate ? t.offerValidUntil(formatDate(invoice.dueDate, invoice.language)) : null}
</Text>
) : invoice.requiresPayment && (
<Text style={[styles.paragraph, { marginTop: 16 }]}>
{invoice.dueDate
? t.paymentTerms(
Math.max(0, Math.round((invoice.dueDate.getTime() - invoice.invoiceDate.getTime()) / 86400000)),
formatDate(invoice.dueDate, invoice.language),
)
: t.paymentTermsImmediate}
</Text>
)}
{invoice.giroCodePngDataUrl && !invoice.paid && (
{invoice.giroCodePngDataUrl && invoice.requiresPayment && (
<View style={styles.giroBlock}>
<Image src={invoice.giroCodePngDataUrl} style={styles.giroImage} />
<View>
<Text style={styles.giroCaption}>{t.giroCodeCaption}</Text>
<Text style={styles.giroDetails}>{invoice.issuer.bankName}</Text>
<Text style={styles.giroDetails}>
{t.recipientLabel}: {[invoice.issuer.companyName, invoice.issuer.legalForm].filter(Boolean).join(" ")}
</Text>
{invoice.issuer.bankName ? (
<Text style={styles.giroDetails}>{t.bankLabel}: {invoice.issuer.bankName}</Text>
) : null}
<Text style={styles.giroDetails}>{t.ibanLabel}: {invoice.issuer.iban}</Text>
{invoice.issuer.bic ? (
<Text style={styles.giroDetails}>{t.bicLabel}: {invoice.issuer.bic}</Text>
) : null}
<Text style={styles.giroDetails}>
{formatMoney(invoice.totals.gross, cur, invoice.language)}
{t.amountLabel}: {formatMoney(invoice.totals.gross, cur, invoice.language)}
</Text>
<Text style={styles.giroDetails}>
{t.referenceLabel}: {invoice.number}
</Text>
</View>
</View>
@@ -393,14 +541,12 @@ function senderInline(issuer: IssuerData): string {
.join(" - ");
}
function Header({ issuer }: { issuer: IssuerData }) {
return (
<View style={styles.headerRow}>
<View>{/* spacer; logo is right-aligned */}</View>
{issuer.logoDataUrl ? <Image src={issuer.logoDataUrl} style={styles.logo} /> : <View />}
</View>
);
function Header(_args: { issuer: IssuerData }) {
// Deprecated: header rendering is now inlined in InvoiceDocument so the
// logo and meta block can share a single row at the top of the page.
return null;
}
void Header;
function Recipient({ recipient }: { recipient: RecipientData }) {
const lines: string[] = [];
@@ -448,7 +594,14 @@ function LineRow({
</View>
</View>
<Text style={styles.colQty}>{formatQuantity(line.quantity, t.pieceUnit, language)}</Text>
<Text style={styles.colUnit}>{formatMoney(line.unitPriceNet, currency, language)}</Text>
<View style={styles.colUnit}>
{line.originalUnitPriceNet != null ? (
<Text style={styles.unitOriginalStrike}>
{formatMoney(line.originalUnitPriceNet, currency, language)}
</Text>
) : null}
<Text>{formatMoney(line.unitPriceNet, currency, language)}</Text>
</View>
<Text style={styles.colTotal}>{formatMoney(line.totalNet, currency, language)}</Text>
</View>
);
@@ -468,9 +621,21 @@ function Footer({ issuer, language }: { issuer: IssuerData; language: InvoiceLan
</View>
<View style={styles.footerCol}>
<Text style={styles.footerHeading}>{t.contactHeading}</Text>
{issuer.phone ? <Text>{t.phoneLabel}: {issuer.phone}</Text> : null}
{issuer.email ? <Text>{t.emailLabel}: {issuer.email}</Text> : null}
{issuer.website ? <Text>{t.webLabel}: {issuer.website}</Text> : null}
{issuer.phone ? (
<Text>
{t.phoneLabel}: <Link src={toTelUrl(issuer.phone)}>{issuer.phone}</Link>
</Text>
) : null}
{issuer.email ? (
<Text>
{t.emailLabel}: <Link src={`mailto:${issuer.email}`}>{issuer.email}</Link>
</Text>
) : null}
{issuer.website ? (
<Text>
{t.webLabel}: <Link src={normaliseWebUrl(issuer.website)}>{issuer.website}</Link>
</Text>
) : null}
</View>
<View style={styles.footerCol}>
<Text style={styles.footerHeading}>{t.legalHeading}</Text>
@@ -504,3 +669,54 @@ function pickFooterNote(issuer: { footerNote: string; footerNoteEn: string }, la
}
return issuer.footerNote || "";
}
/**
* Make a website URL safe for `<Link src="...">` — adds an `https://` scheme
* when the user typed something like `linumiq.com` or `www.linumiq.com`.
*/
function normaliseWebUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) return trimmed;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed.replace(/^\/\//, "")}`;
}
/**
* Build a `tel:` URL from a free-form phone string. RFC 3966 allows only
* digits and a leading `+`, so we strip everything else (spaces, parens,
* dashes, slashes, dots, internal letters). The display string above the
* link keeps the human-readable formatting.
*/
function toTelUrl(phone: string): string {
const cleaned = phone.replace(/[^\d+]/g, "");
// Keep only a single leading '+' if present.
const normalized = cleaned.startsWith("+")
? "+" + cleaned.slice(1).replace(/\+/g, "")
: cleaned.replace(/\+/g, "");
return `tel:${normalized}`;
}
/**
* Turn a Shopify payment-gateway machine name (e.g. `shopify_payments`,
* `manual`, `bogus`) or a built-in manual-payment template name (e.g.
* `Bank Deposit`, `Money Order`) into the localized customer-facing label
* shown on the invoice. The Shopify Admin API only exposes English
* template names — see `InvoiceStrings.paymentGatewayLabels` for the
* rationale.
*
* Lookup is keyed on the *normalized* name (lowercased, separators
* collapsed). Unknown gateways fall back to a title-cased rendering
* of the raw name so we never silently print empty meta-rows.
*/
function prettifyGatewayName(
name: string,
labels: Record<string, string>,
): string {
const key = name.trim().toLowerCase().replace(/[_\-]+/g, " ").replace(/\s+/g, " ");
if (labels[key]) return labels[key];
return key
.split(" ")
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
@@ -6,8 +6,15 @@
* served from Shopify's CDN so re-fetching is cheap, but caching avoids
* hammering the network when regenerating an invoice multiple times.
*/
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
import pLimit from "p-limit";
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image
const CACHE_MAX_ENTRIES = 200;
/** Max images fetched/embedded per invoice (DoS bound for large carts). */
const MAX_IMAGES_PER_INVOICE = 100;
/** Max concurrent image fetches per invoice. */
const IMAGE_FETCH_CONCURRENCY = 6;
const cache = new Map<string, string>(); // url -> data URL
@@ -40,42 +47,62 @@ export async function fetchProductImageDataUrl(url: string): Promise<string | un
? `${url}${url.includes("?") ? "&" : "?"}width=128`
: url;
let res: Response;
let res: Awaited<ReturnType<typeof safeFetch>>;
try {
res = await fetch(requestUrl);
res = await safeFetch(requestUrl, {
maxBytes: MAX_BYTES,
accept: "image/*",
// Lock product images to Shopify's CDN — line item image URLs come
// from the Admin API and should never point anywhere else.
allowedHosts: SHOPIFY_CDN_HOSTS,
});
} catch (err) {
console.warn(`Product image fetch failed for ${url}:`, err);
if (err instanceof SafeFetchError) {
console.warn(`Product image refused (${err.code}) for ${url}: ${err.message}`);
} else {
console.warn(`Product image fetch failed for ${url}:`, err);
}
return undefined;
}
if (!res.ok) {
if (res.status < 200 || res.status >= 300) {
console.warn(`Product image HTTP ${res.status} for ${url}`);
return undefined;
}
const buf = await res.arrayBuffer();
if (buf.byteLength === 0 || buf.byteLength > MAX_BYTES) return undefined;
if (res.bytesRead === 0) return undefined;
const contentType = guessContentType(url, res.headers.get("content-type"));
const contentType = guessContentType(url, res.contentType);
// @react-pdf supports png/jpeg natively; webp/gif are unreliable. Skip those.
if (contentType !== "image/png" && contentType !== "image/jpeg") return undefined;
const b64 = Buffer.from(buf).toString("base64");
const b64 = Buffer.from(res.bytes).toString("base64");
const dataUrl = `data:${contentType};base64,${b64}`;
rememberInCache(url, dataUrl);
return dataUrl;
}
/**
* Resolves images for every line in parallel, mutating `imageDataUrl` in place.
* Failures are swallowed (the row simply renders without an icon).
* Resolves images for every line, mutating `imageDataUrl` in place. Fetches
* run with bounded concurrency and a hard cap on the number of images
* embedded per invoice, so a large cart (Shopify allows hundreds of line
* items) can't trigger an unbounded fan-out of network requests. Failures are
* swallowed (the row simply renders without an icon).
*/
export async function attachLineItemImages(
lines: { imageUrl?: string; imageDataUrl?: string }[],
): Promise<void> {
await Promise.all(
lines.map(async (line) => {
if (!line.imageUrl) return;
const dataUrl = await fetchProductImageDataUrl(line.imageUrl);
if (dataUrl) line.imageDataUrl = dataUrl;
}),
);
const limit = pLimit(IMAGE_FETCH_CONCURRENCY);
let budget = MAX_IMAGES_PER_INVOICE;
const tasks: Promise<void>[] = [];
for (const line of lines) {
if (!line.imageUrl) continue;
if (budget <= 0) break; // cap reached — remaining rows render iconless
budget -= 1;
tasks.push(
limit(async () => {
const dataUrl = await fetchProductImageDataUrl(line.imageUrl!);
if (dataUrl) line.imageDataUrl = dataUrl;
}),
);
}
await Promise.all(tasks);
}
+47
View File
@@ -0,0 +1,47 @@
import db from "../../db.server";
import type { ShopSettings } from "@prisma/client";
/**
* Returns the canonical remittance reference for an order — i.e. the
* exact string that should appear:
* - on the printed invoice PDF (`invoice.number`),
* - in the GiroCode QR payload,
* - and in the customer-facing payment instructions on the
* thank-you / customer-account pages.
*
* Banking systems treat each unique reference string as a separate
* payment, so all three surfaces MUST use this single source of truth.
*
* Resolution order:
* 1. The latest non-cancelled `Invoice` row for the order — guaranteed
* to match what's printed on the PDF.
* 2. Predicted default-mode number (`${prefix}${orderNumber}`). Safe
* for the default `order_number` numbering mode and a sensible
* best-guess for `prefix_sequential` before the invoice has been
* generated (the customer just sees the order number with the
* shop's invoice prefix instead of the bare Shopify "#1004").
*/
export async function resolveOrderRemittance(args: {
shopDomain: string;
orderGid: string;
orderNumber: number | null | undefined;
settings: Pick<ShopSettings, "invoicePrefix">;
}): Promise<string> {
const invoice = await db.invoice.findFirst({
where: {
shopDomain: args.shopDomain,
orderId: args.orderGid,
kind: "invoice",
cancelledAt: null,
},
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
select: { invoiceNumber: true },
});
if (invoice?.invoiceNumber) return invoice.invoiceNumber;
const prefix = args.settings.invoicePrefix || "";
if (args.orderNumber != null) return `${prefix}${args.orderNumber}`;
// Last-ditch: derive numeric tail from the GID.
const tail = args.orderGid.split("/").pop() ?? "";
return `${prefix}${tail}`;
}
@@ -0,0 +1,47 @@
/**
* Selection helper for the "recent orders" / "recent drafts" lists.
*
* A single order can accumulate several invoice rows over its lifetime
* (regenerations bump the `version`, cancel-and-reissue cancels the old row
* and issues a new one). Crucially, a CANCELLED invoice can carry a HIGHER
* `version` than the current active one, so picking "highest version wins"
* would surface a stale cancelled invoice and hide the live one — the order
* would render as if it had no invoice ("Generate" button) even though a
* valid issued invoice exists.
*
* The correct representative for the UI is the latest NON-cancelled invoice;
* only when every row is cancelled do we fall back to the latest cancelled
* one (so the order can still show its "cancelled" state).
*/
export interface RepresentativeInvoiceRow {
orderId: string;
version: number;
cancelledAt: Date | null;
}
/**
* Build a map of orderId -> representative invoice.
*
* @param invoices Invoice rows for the relevant orders. MUST already be sorted
* by `version` descending (then `createdAt` descending), matching the
* Prisma query order, so the first non-cancelled row encountered per order
* is the highest-version active invoice.
*/
export function buildRepresentativeInvoiceMap<T extends RepresentativeInvoiceRow>(
invoices: T[],
): Map<string, T> {
const byOrder = new Map<string, T>();
for (const inv of invoices) {
const existing = byOrder.get(inv.orderId);
if (!existing) {
byOrder.set(inv.orderId, inv);
continue;
}
// Upgrade from a cancelled placeholder to the first active invoice seen.
if (existing.cancelledAt && !inv.cancelledAt) {
byOrder.set(inv.orderId, inv);
}
}
return byOrder;
}
+318
View File
@@ -0,0 +1,318 @@
/**
* SSRF-hardened `fetch` for use whenever the URL we're about to call could
* be influenced by user input (shop settings, Shopify-supplied product
* image URLs, DB-stored Files URLs, …).
*
* Defenses:
* - Only `https:` is allowed by default. `http:` is allowed only for
* localhost when `NODE_ENV !== "production"` (handy for local dev).
* - Hostname is DNS-resolved and every returned address is checked
* against private / loopback / link-local / unique-local ranges.
* - The connection is then forced to the resolved IP (with the original
* Host header preserved) to defeat DNS-rebinding.
* - A hard request timeout is enforced (default 5 s).
* - Response size is capped while reading; we abort once the limit is
* exceeded instead of buffering the whole body first.
* - Redirects are not followed — if the caller wants a redirected target
* they have to re-validate it explicitly.
*
* The helper returns the raw bytes plus the response status / content-type
* so callers can decide what to do with them.
*/
import { lookup as dnsLookup } from "node:dns/promises";
import net from "node:net";
import { Agent as HttpAgent } from "node:http";
import { Agent as HttpsAgent } from "node:https";
import http from "node:http";
import https from "node:https";
import ipaddr from "ipaddr.js";
export interface SafeFetchOptions {
/** Hard cap in bytes; the read aborts as soon as this is exceeded. */
maxBytes?: number;
/** Total request timeout in milliseconds (default 5000). */
timeoutMs?: number;
/** Optional `Accept` header. */
accept?: string;
/**
* If non-empty, only hosts whose lowercase name equals one of these or
* ends with `.<entry>` are allowed. Useful for locking calls to known
* good CDNs (e.g. `cdn.shopify.com`).
*/
allowedHosts?: string[];
}
export interface SafeFetchResult {
status: number;
contentType: string | null;
bytes: Uint8Array;
bytesRead: number;
}
export class SafeFetchError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
this.name = "SafeFetchError";
}
}
const DEFAULT_TIMEOUT_MS = 5_000;
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
/**
* Default-deny address classifier backed by the well-vetted `ipaddr.js`
* library. An address is considered safe to connect to ONLY if it is a
* clearly public, globally-routable unicast address. Everything else —
* loopback, private (RFC1918), link-local, unique-local, multicast,
* reserved, unspecified, broadcast, carrier-grade NAT, plus the various
* IPv4-in-IPv6 tunnelling/transition forms — is rejected.
*
* This closes IPv6 bypasses that string-prefix checks miss, e.g.:
* - `::ffff:7f00:1` (IPv4-mapped HEX form of 127.0.0.1)
* - `::7f00:1` (deprecated IPv4-compatible ::127.0.0.1)
* - `fe90::` / `fea0::` / `feb0::` (link-local is fe80::/10, not just fe80:)
*/
function isSafePublicAddress(ip: string): boolean {
let addr: ipaddr.IPv4 | ipaddr.IPv6;
try {
addr = ipaddr.parse(ip);
} catch {
// Unparseable => treat as unsafe.
return false;
}
if (addr.kind() === "ipv4") {
// Only globally-routable unicast IPv4 is allowed. `range()` returns
// 'unicast' exclusively for public space; private/loopback/linkLocal/
// carrierGradeNat/reserved/broadcast/multicast/unspecified are all denied.
return (addr as ipaddr.IPv4).range() === "unicast";
}
const v6 = addr as ipaddr.IPv6;
// Unwrap IPv4-mapped (::ffff:a.b.c.d, incl. hex form ::ffff:7f00:1) and
// validate the embedded IPv4 against the v4 policy.
if (v6.isIPv4MappedAddress()) {
return v6.toIPv4Address().range() === "unicast";
}
// Deprecated IPv4-compatible addresses live in ::/96 (first 96 bits zero,
// e.g. ::7f00:1 == ::127.0.0.1). ipaddr.js classifies these as plain
// 'unicast', so unwrap the trailing 32 bits and validate as IPv4. This
// also covers :: (unspecified) and ::1 (loopback), which map to
// 0.0.0.0 / 0.0.0.1 and are denied by the IPv4 policy.
const p = v6.parts;
if (p[0] === 0 && p[1] === 0 && p[2] === 0 && p[3] === 0 && p[4] === 0 && p[5] === 0) {
const v4 = new ipaddr.IPv4([(p[6] >> 8) & 0xff, p[6] & 0xff, (p[7] >> 8) & 0xff, p[7] & 0xff]);
return v4.range() === "unicast";
}
// Everything else: only true global unicast is allowed. This rejects
// loopback, linkLocal (fe80::/10), uniqueLocal (fc00::/7), multicast,
// reserved, 6to4, teredo, rfc6145/rfc6052 transition ranges, etc.
return v6.range() === "unicast";
}
function isPrivateAddress(ip: string): boolean {
return !isSafePublicAddress(ip);
}
function hostMatchesAllowlist(hostname: string, allowed: string[] | undefined): boolean {
if (!allowed || allowed.length === 0) return true;
const h = hostname.toLowerCase();
return allowed.some((entry) => {
const e = entry.toLowerCase();
return h === e || h.endsWith(`.${e}`);
});
}
/**
* Resolves a hostname to an IPv4/IPv6 address that has been vetted against
* the private/loopback ranges. Throws `SafeFetchError` if no safe address
* can be obtained.
*/
async function resolveSafeAddress(hostname: string): Promise<{ address: string; family: number }> {
// If the hostname is already an IP literal, validate it directly.
if (net.isIP(hostname)) {
const family = net.isIPv6(hostname) ? 6 : 4;
if (isPrivateAddress(hostname)) {
throw new SafeFetchError("blocked-address", `Refusing to connect to private address ${hostname}`);
}
return { address: hostname, family };
}
let results: { address: string; family: number }[];
try {
results = await dnsLookup(hostname, { all: true });
} catch (err) {
throw new SafeFetchError("dns-failed", `DNS lookup failed for ${hostname}: ${(err as Error).message}`);
}
for (const r of results) {
if (isPrivateAddress(r.address)) {
throw new SafeFetchError("blocked-address", `${hostname} resolves to private address ${r.address}`);
}
}
const first = results[0];
if (!first) throw new SafeFetchError("dns-empty", `${hostname} resolved to no addresses`);
return { address: first.address, family: first.family };
}
/**
* Performs an SSRF-safe HTTP(S) GET. Throws `SafeFetchError` for policy
* violations; throws plain `Error` for transport failures (mirroring the
* standard `fetch` error model).
*/
export async function safeFetch(rawUrl: string, opts: SafeFetchOptions = {}): Promise<SafeFetchResult> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new SafeFetchError("bad-url", `Invalid URL: ${rawUrl}`);
}
const allowHttp =
process.env.NODE_ENV !== "production" &&
(url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1");
if (url.protocol !== "https:" && !(url.protocol === "http:" && allowHttp)) {
throw new SafeFetchError("bad-scheme", `Refusing non-https URL: ${url.protocol}//${url.hostname}`);
}
if (!hostMatchesAllowlist(url.hostname, opts.allowedHosts)) {
throw new SafeFetchError("host-not-allowed", `Host ${url.hostname} is not on the allowlist`);
}
const { address, family } = await resolveSafeAddress(url.hostname);
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
// Pin the resolved IP. We pass an Agent with a custom `lookup` that always
// returns our pre-validated address, so the actual TCP connect can't be
// re-resolved to something else (DNS-rebinding defense).
//
// Note: Node 20+ enables Happy Eyeballs (`autoSelectFamily: true`) by
// default on the http/https agents. Happy Eyeballs calls `lookup` with
// `{ all: true }` and expects the callback to receive an *array* of
// `{ address, family }` records. If we ignore that and always invoke the
// 3-arg form, the connector hands `undefined` to `socket.connect()`,
// which then throws `Invalid IP address: undefined`.
type LookupCb =
| ((err: NodeJS.ErrnoException | null, address: string, family: number) => void)
| ((err: NodeJS.ErrnoException | null, addresses: { address: string; family: number }[]) => void);
const pinnedLookup = (
_hostname: string,
optionsOrCb: { all?: boolean; family?: number } | LookupCb,
maybeCb?: LookupCb,
) => {
let options: { all?: boolean; family?: number } = {};
let cb: LookupCb;
if (typeof optionsOrCb === "function") {
cb = optionsOrCb;
} else {
options = optionsOrCb ?? {};
cb = maybeCb as LookupCb;
}
if (options.all) {
(cb as (err: NodeJS.ErrnoException | null, addresses: { address: string; family: number }[]) => void)(
null,
[{ address, family }],
);
} else {
(cb as (err: NodeJS.ErrnoException | null, address: string, family: number) => void)(
null,
address,
family,
);
}
};
const isHttps = url.protocol === "https:";
const agent = isHttps
? new HttpsAgent({ keepAlive: false, lookup: pinnedLookup as never })
: new HttpAgent({ keepAlive: false, lookup: pinnedLookup as never });
const headers: Record<string, string> = {
Host: url.host,
"User-Agent": "linumiq-invoice/1.0 (+https://linumiq.com)",
};
if (opts.accept) headers["Accept"] = opts.accept;
const requestOptions: http.RequestOptions = {
method: "GET",
host: url.hostname,
port: url.port ? parseInt(url.port, 10) : isHttps ? 443 : 80,
path: `${url.pathname}${url.search}`,
headers,
agent,
// Defeat redirects (Node's http doesn't follow by default).
};
return new Promise<SafeFetchResult>((resolve, reject) => {
const lib = isHttps ? https : http;
const req = lib.request(requestOptions, (res) => {
const status = res.statusCode ?? 0;
// Reject 3xx — caller must explicitly re-call with the new URL.
if (status >= 300 && status < 400) {
res.resume();
reject(new SafeFetchError("redirect-not-allowed", `Refusing redirect ${status} from ${rawUrl}`));
return;
}
const chunks: Buffer[] = [];
let total = 0;
res.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
res.destroy(new SafeFetchError("too-large", `Response exceeded ${maxBytes} bytes`));
return;
}
chunks.push(chunk);
});
res.on("end", () => {
const buf = Buffer.concat(chunks, total);
resolve({
status,
contentType: res.headers["content-type"] ?? null,
bytes: new Uint8Array(buf),
bytesRead: total,
});
});
res.on("error", (err) => reject(err));
});
req.setTimeout(timeoutMs, () => {
req.destroy(new SafeFetchError("timeout", `Request to ${url.hostname} exceeded ${timeoutMs}ms`));
});
req.on("error", (err) => reject(err));
req.end();
});
}
/** Common allowlist for Shopify-served assets (CDN + Files). */
export const SHOPIFY_CDN_HOSTS = ["cdn.shopify.com", "shopifycdn.com", "shopify.com"];
/**
* Boundary validation for merchant-supplied URLs (e.g. the logo URL saved in
* settings). Requires a syntactically valid `https:` URL whose host is a DNS
* name rather than an IP literal (v4 or v6). Returns a user-facing error
* string when the URL is unacceptable, or `null` when it is fine to store.
*
* This is a defence-in-depth boundary check; `safeFetch` remains the runtime
* backstop that re-validates the resolved address at fetch time.
*/
export function validateMerchantHttpsUrl(raw: string): string | null {
let url: URL;
try {
url = new URL(raw);
} catch {
return "Enter a valid URL including the https:// prefix.";
}
if (url.protocol !== "https:") {
return "Logo URL must use https://.";
}
// URL.hostname wraps IPv6 literals in brackets; strip them before checking.
const host = url.hostname.replace(/^\[/, "").replace(/\]$/, "");
if (net.isIP(host) !== 0) {
return "Logo URL must point to a domain name, not an IP address.";
}
return null;
}
+55
View File
@@ -0,0 +1,55 @@
import crypto from "node:crypto";
import { optionalEnv } from "../config/env.server";
/**
* Resolves the GiroCode URL signing key lazily (per call, not at module load)
* so the process can boot even when only the fallback secret is present.
*
* Prefers the dedicated `GIROCODE_SIGNING_KEY`; falls back to
* `SHOPIFY_API_SECRET` ONLY when the dedicated key is unset, so existing
* signed URLs and deployments keep working. Throws if neither is set
* (fail closed) — an empty key would make signatures forgeable.
*/
function getSigningKey(): string {
const key = optionalEnv("GIROCODE_SIGNING_KEY") ?? optionalEnv("SHOPIFY_API_SECRET");
if (!key) {
throw new Error(
"GiroCode signing key missing: set GIROCODE_SIGNING_KEY (preferred) " +
"or SHOPIFY_API_SECRET.",
);
}
return key;
}
function hmac(payload: string): string {
return crypto.createHmac("sha256", getSigningKey()).update(payload).digest("hex");
}
export interface GiroCodeUrlParams {
shop: string;
orderId: string;
exp: number; // unix seconds
}
export function signGiroCodeUrl(params: GiroCodeUrlParams): string {
const base = `shop=${params.shop}&orderId=${params.orderId}&exp=${params.exp}`;
const sig = hmac(base);
return `${base}&sig=${sig}`;
}
export function verifyGiroCodeUrl(query: URLSearchParams): { ok: boolean; shop?: string; orderId?: string; reason?: string } {
const shop = query.get("shop") || "";
const orderId = query.get("orderId") || "";
const exp = parseInt(query.get("exp") || "0", 10);
const sig = query.get("sig") || "";
if (!shop || !orderId || !exp || !sig) return { ok: false, reason: "missing-params" };
if (Date.now() / 1000 > exp) return { ok: false, reason: "expired" };
const expected = hmac(`shop=${shop}&orderId=${orderId}&exp=${exp}`);
// timing-safe compare
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length) return { ok: false, reason: "bad-sig" };
if (!crypto.timingSafeEqual(a, b)) return { ok: false, reason: "bad-sig" };
return { ok: true, shop, orderId };
}
+71 -4
View File
@@ -1,4 +1,4 @@
import type { InvoiceLanguage } from "./i18n";
import type { InvoiceLanguage, PaymentStatus } from "./i18n";
/**
* The view model passed into the PDF renderer. Decouples the PDF layer from
@@ -10,7 +10,7 @@ export interface InvoiceViewModel {
currency: string;
// Identity
kind: "invoice" | "storno";
kind: "invoice" | "storno" | "offer";
number: string;
/** Only set for storno: the original invoice number being cancelled. */
cancelsNumber?: string;
@@ -39,7 +39,69 @@ export interface InvoiceViewModel {
giroCodePngDataUrl?: string;
// Status flags
paid: boolean;
/** Condensed payment status derived from Shopify's
* `displayFinancialStatus`. */
paymentStatus: PaymentStatus;
/**
* True when this document represents an *outstanding* request for
* money — i.e. the customer still owes the issuer something. False
* when the invoice has already been settled (`paid`), the order has
* been fully refunded (`refunded`), or the document is structurally
* not a payment request (offers, cancellation invoices).
*
* Drives the GiroCode generation gate, the GiroCode/payment-block
* render gate, and the payment-terms paragraph below the items
* table. Without this flag a fully refunded order would still be
* printed with a SEPA QR code asking the customer to wire the
* original total.
*/
requiresPayment: boolean;
/** Cumulative gross amount that has been refunded against the
* underlying Shopify order, in the same currency as `totals.gross`.
* 0 when there has been no refund (the common case) or when the
* document is structurally not subject to refunds (storno / offer).
* When > 0 the renderer adds two extra rows beneath the gross total:
* a negative "Zurückerstattet" row and a final "Offener Betrag"
* row showing `gross - refundedAmount` so the printed PDF mirrors
* what the merchant sees on the Shopify order page. */
refundedAmount: number;
/** Names of the payment gateways used (e.g. ["bogus"], ["manual",
* "shopify_payments"]). Empty when unknown / draft. */
paymentGatewayNames: string[];
/** Shopify's human-friendly order identifier (e.g. "#1004"). Distinct from
* the sequential `number` used as the invoice number. */
orderName: string;
/** Shipping address — only set when it differs from the billing address.
* Renderer uses this to show a separate delivery-address block. */
separateShippingAddress?: RecipientData;
/** Human-readable shipping method title (e.g. "Standard", "DHL Express").
* Empty / undefined when there is no shipping line (digital orders). */
shippingMethod?: string;
/** Tracking entries collected from order fulfillments. Empty when the
* order is unfulfilled or has no tracking. */
tracking: TrackingInfo[];
/** Discount codes applied to the cart (e.g. `["SUMMER10"]`). Empty when
* none. */
discountCodes: string[];
/** True when the customer chose local pickup (so we shouldn't render the
* pickup-location address as a "delivery address"). */
isPickup: boolean;
/** Name of the pickup location (e.g. "Lager Graz"). Set only when
* `isPickup` is true and the location name was available. */
pickupLocationName?: string;
}
export interface TrackingInfo {
number: string;
url?: string;
company?: string;
}
export interface IssuerData {
@@ -82,8 +144,13 @@ export interface InvoiceLine {
title: string;
/** Raw quantity (e.g. 6). */
quantity: number;
/** Net unit price (excluding tax). */
/** Net unit price (excluding tax). When a discount applies, this is the
* effective discounted price actually charged. */
unitPriceNet: number;
/** Original net unit price BEFORE any discount allocation. Only set when
* it differs from `unitPriceNet`. The renderer uses this to display a
* strikethrough original next to the discounted price. */
originalUnitPriceNet?: number;
/** Net total = quantity * unitPriceNet. */
totalNet: number;
/** Optional SKU for display under the title. */
@@ -0,0 +1,60 @@
/**
* Thin wrapper around `@shopify/shopify-app-session-storage-prisma` that
* encrypts `accessToken` / `refreshToken` at rest using field-level AES-256-GCM.
*
* Tokens are encrypted before being handed to the underlying storage and
* decrypted after they are loaded back out. `decryptField` is backward
* compatible, so any legacy plaintext tokens already in the DB keep working.
*/
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import type { Session } from "@shopify/shopify-api";
import type { PrismaClient } from "@prisma/client";
import { decryptField, encryptField } from "../crypto/fieldCrypto.server";
type SessionTokenFields = {
accessToken?: string;
refreshToken?: string;
};
function encryptTokens(session: Session): Session {
const s = session as Session & SessionTokenFields;
if (s.accessToken) s.accessToken = encryptField(s.accessToken);
if (s.refreshToken) s.refreshToken = encryptField(s.refreshToken);
return session;
}
function decryptTokens(session: Session): Session {
const s = session as Session & SessionTokenFields;
if (s.accessToken) s.accessToken = decryptField(s.accessToken);
if (s.refreshToken) s.refreshToken = decryptField(s.refreshToken);
return session;
}
/**
* Clones a Session so we never mutate the caller's instance when encrypting
* for storage. The Prisma session storage only reads plain properties, so a
* shallow structured copy via the Session class is sufficient.
*/
function cloneSession(session: Session): Session {
return Object.assign(
Object.create(Object.getPrototypeOf(session)),
session,
) as Session;
}
export class EncryptedPrismaSessionStorage extends PrismaSessionStorage<PrismaClient> {
async storeSession(session: Session): Promise<boolean> {
return super.storeSession(encryptTokens(cloneSession(session)));
}
async loadSession(id: string): Promise<Session | undefined> {
const session = await super.loadSession(id);
return session ? decryptTokens(session) : session;
}
async findSessionsByShop(shop: string): Promise<Session[]> {
const sessions = await super.findSessionsByShop(shop);
return sessions.map((s) => decryptTokens(s));
}
}
+118
View File
@@ -0,0 +1,118 @@
import pLimit from "p-limit";
import type { WebhookReservation } from "./dedupe.server";
/**
* Background runner for webhook side-effects.
*
* Shopify expects a 200 response within ~5 seconds, otherwise it considers
* the delivery failed and retries it. Heavy automation work (PDF render,
* Shopify Files upload, SMTP send) routinely exceeded that budget, which
* caused duplicate invoice emails before we added the dedupe table.
*
* Returning the response immediately and finishing the work afterwards keeps
* Shopify happy. Two problems with a naive `void work()`:
*
* 1. DoS / resource exhaustion — an order burst would spawn unbounded
* concurrent PDF renders + SMTP sends. We cap concurrency with a small
* in-process queue (`p-limit`); excess tasks queue instead of piling up.
* 2. Data loss on restart — `void work()` is invisible to shutdown, so a
* container stop (SIGTERM) killed in-flight invoice work mid-send. We
* track in-flight tasks and drain them (bounded) on SIGTERM/SIGINT.
*
* Reserve/commit dedupe (see dedupe.server.ts) is integrated here: on success
* we `commit()` the reservation (permanently deduped); on failure we
* `release()` it so Shopify's retry re-runs the work instead of being dropped
* as a duplicate.
*/
const CONCURRENCY = Math.max(1, Number(process.env.WEBHOOK_CONCURRENCY) || 4);
const DRAIN_TIMEOUT_MS = Math.max(
1000,
Number(process.env.WEBHOOK_DRAIN_TIMEOUT_MS) || 25_000,
);
const limit = pLimit(CONCURRENCY);
const inFlight = new Set<Promise<unknown>>();
let draining = false;
export function runWebhookInBackground(
description: string,
work: () => Promise<unknown>,
reservation?: WebhookReservation | null,
): void {
if (draining) {
// The process is shutting down. We still enqueue so the drain awaits this
// task — the server has already stopped listening, so this is at most the
// tail end of the last accepted request.
console.warn(`[webhook-queue] enqueuing task during shutdown drain: ${description}`);
}
const task = limit(async () => {
try {
await work();
await reservation?.commit();
} catch (err) {
console.error(`background webhook task '${description}' failed:`, err);
// Drop the dedupe reservation so Shopify's retry re-runs the work.
try {
await reservation?.release();
} catch (releaseErr) {
console.error(
`background webhook task '${description}': failed to release dedupe reservation:`,
releaseErr,
);
}
}
});
inFlight.add(task);
void task.finally(() => inFlight.delete(task));
}
/**
* Stop accepting new work (best-effort) and await in-flight + queued tasks,
* bounded by `timeoutMs`, so a container stop drains invoice work instead of
* killing it mid-send. Idempotent.
*/
export async function drainWebhookQueue(timeoutMs = DRAIN_TIMEOUT_MS): Promise<void> {
draining = true;
if (inFlight.size === 0) return;
console.log(
`[webhook-queue] draining ${inFlight.size} in-flight webhook task(s) (timeout ${timeoutMs}ms)...`,
);
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
if (typeof timer.unref === "function") timer.unref();
});
await Promise.race([Promise.allSettled([...inFlight]), timeout]);
if (timer) clearTimeout(timer);
if (inFlight.size > 0) {
console.warn(
`[webhook-queue] drain timed out with ${inFlight.size} task(s) still running`,
);
} else {
console.log("[webhook-queue] drain complete");
}
}
// Bridge for the custom server (server.js), which loads only the bundled
// build and cannot import this module directly. It awaits this drain before
// calling process.exit during graceful shutdown.
type DrainGlobal = typeof globalThis & {
__linumiqWebhookDrain?: typeof drainWebhookQueue;
};
(globalThis as DrainGlobal).__linumiqWebhookDrain = drainWebhookQueue;
// Safety net for runtimes that don't go through server.js (e.g. `shopify app
// dev`): stop accepting work and best-effort drain. The custom server awaits
// the same (idempotent) drain before exiting.
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.once(signal, () => {
void drainWebhookQueue();
});
}
+63
View File
@@ -0,0 +1,63 @@
import db from "../../db.server";
/**
* Periodic TTL cleanup for the `ProcessedWebhook` idempotency table.
*
* The table grows by one row per Shopify webhook delivery and is never read
* after the retry window closes, so without pruning it grows unbounded —
* eventually a disk/space DoS. We only need rows for as long as Shopify might
* retry a delivery (hours), so a generous retention window of a few days is
* ample while keeping the table small.
*/
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const INTERVAL_MS = 60 * 60 * 1000; // hourly
export interface CleanupDeps {
db: {
processedWebhook: {
deleteMany: (args: {
where: { receivedAt: { lt: Date } };
}) => Promise<{ count: number }>;
};
};
}
let scheduled = false;
async function runCleanup(deps: CleanupDeps): Promise<void> {
try {
const cutoff = new Date(Date.now() - RETENTION_MS);
const { count } = await deps.db.processedWebhook.deleteMany({
where: { receivedAt: { lt: cutoff } },
});
if (count > 0) {
console.log(`webhook-cleanup: removed ${count} ProcessedWebhook row(s) older than 7d`);
}
} catch (err) {
// Best-effort housekeeping — never throw into the caller.
console.warn("webhook-cleanup: prune failed:", err);
}
}
/**
* Idempotently schedule the hourly cleanup. Safe to call on every webhook —
* the first call starts a single unref'd interval and runs an immediate
* sweep; subsequent calls are no-ops.
*
* Because this is only ever invoked while handling a live webhook request, it
* never runs during `prisma generate` / `react-router build` or other CLI
* contexts. The interval is `unref`'d so it can never keep the process alive.
*/
export function ensureWebhookCleanupScheduled(deps: CleanupDeps = { db }): void {
if (scheduled) return;
scheduled = true;
const timer = setInterval(() => {
void runCleanup(deps);
}, INTERVAL_MS);
// Don't let the housekeeping interval keep the event loop alive on shutdown.
if (typeof timer.unref === "function") timer.unref();
// Kick off an immediate sweep so a long-lived process prunes promptly.
void runCleanup(deps);
}
+204
View File
@@ -0,0 +1,204 @@
import db from "../../db.server";
import { ensureWebhookCleanupScheduled } from "./cleanup.server";
/**
* How long a `status="processing"` reservation is considered "live" before we
* assume the worker that claimed it crashed mid-process. After this window a
* stale reservation may be reclaimed and the work retried.
*/
const STALE_LEASE_MS = 5 * 60 * 1000; // 5 minutes
interface ProcessedRow {
webhookId: string;
status: string;
receivedAt: Date;
}
/**
* Minimal shape of the Prisma client surface we use — declared inline so the
* helper can be unit-tested with a tiny stub instead of a real database.
*/
export interface DedupeDeps {
db: {
processedWebhook: {
create: (args: {
data: { webhookId: string; topic: string; shopDomain: string; status: string };
}) => Promise<unknown>;
findUnique: (args: { where: { webhookId: string } }) => Promise<ProcessedRow | null>;
update: (args: {
where: { webhookId: string };
data: { status?: string; receivedAt?: Date };
}) => Promise<unknown>;
delete: (args: { where: { webhookId: string } }) => Promise<unknown>;
};
};
}
/**
* A claim on a single Shopify webhook delivery. Obtained from
* {@link reserveWebhook}. The caller MUST eventually `commit()` (work
* succeeded — the delivery is permanently deduped) or `release()` (work
* failed — drop the reservation so Shopify's retry re-runs the work).
*
* `commit`/`release` are no-ops for reservations without a webhook id (unit
* tests / non-Shopify callers) and for the fail-open path.
*/
export interface WebhookReservation {
webhookId: string | null;
commit: () => Promise<void>;
release: () => Promise<void>;
}
function noopReservation(webhookId: string | null): WebhookReservation {
return {
webhookId,
commit: async () => {},
release: async () => {},
};
}
function isP2002(err: unknown): boolean {
// Duck-typed so callers can stub the db without pulling in the real
// `Prisma` namespace. P2002 = unique-constraint violation.
return (err as { code?: string } | null)?.code === "P2002";
}
function makeReservation(
webhookId: string,
shop: string,
topic: string,
deps: DedupeDeps,
): WebhookReservation {
return {
webhookId,
commit: async () => {
try {
await deps.db.processedWebhook.update({
where: { webhookId },
data: { status: "done" },
});
} catch (err) {
// The work already succeeded; a failed commit just risks a later
// duplicate (which the side-effect code is expected to tolerate).
console.warn(`dedupe: failed to commit webhook ${webhookId} (${topic}/${shop}):`, err);
}
},
release: async () => {
try {
await deps.db.processedWebhook.delete({ where: { webhookId } });
} catch (err) {
console.warn(`dedupe: failed to release webhook ${webhookId} (${topic}/${shop}):`, err);
}
},
};
}
/**
* Reserve this Shopify webhook delivery for processing.
*
* Shopify retries a delivery (re-using the same `X-Shopify-Webhook-Id`) when
* it doesn't receive a 200 within its ~5s timeout. Naively recording the id as
* "processed" *before* doing the work meant that if the heavy background work
* later failed (SMTP/GraphQL/PDF error), Shopify's retry was dropped as a
* duplicate and the invoice was never sent.
*
* This uses a two-phase reserve/commit keyed on the webhook id, with the
* unique `webhookId` primary key as the concurrency lock:
*
* - RESERVE: insert a `status="processing"` row. A unique-constraint
* violation (`P2002`) means the id is already claimed; we then inspect the
* existing row:
* - `done` → genuine duplicate → return `null` (skip).
* - `processing`, fresh → another delivery is in flight → `null`.
* - `processing`, stale → previous worker crashed → reclaim & retry.
* - COMMIT (caller, on success) → flip the row to `status="done"`.
* - RELEASE (caller, on failure) → delete the row so a retry reprocesses.
*
* Returns a {@link WebhookReservation} when the caller should process the
* delivery, or `null` when it must short-circuit (duplicate / concurrent).
*
* Fail-open: a dedupe-table error (other than P2002) never silently drops a
* webhook — we return a no-op reservation and let the work run.
*/
export async function reserveWebhook(
request: Request,
shop: string,
topic: string,
deps: DedupeDeps = { db },
): Promise<WebhookReservation | null> {
// Opportunistically schedule TTL cleanup (runtime-only; never in build/CLI
// since this is reached only while handling a live webhook request).
ensureWebhookCleanupScheduled();
const webhookId = request.headers.get("x-shopify-webhook-id");
if (!webhookId) {
// No id (unit tests / non-Shopify callers): process without dedupe.
return noopReservation(null);
}
const reservation = makeReservation(webhookId, shop, topic, deps);
try {
await deps.db.processedWebhook.create({
data: { webhookId, topic, shopDomain: shop, status: "processing" },
});
return reservation;
} catch (err) {
if (!isP2002(err)) {
// Don't fail (or silently drop) a webhook on a logging-table issue.
console.warn(`dedupe: failed to reserve webhook ${webhookId} (${topic}/${shop}):`, err);
return noopReservation(webhookId);
}
}
// A row already exists. Classify it.
let existing: ProcessedRow | null = null;
try {
existing = await deps.db.processedWebhook.findUnique({ where: { webhookId } });
} catch (err) {
console.warn(`dedupe: failed to load existing webhook ${webhookId} (${topic}/${shop}):`, err);
// Another worker owns the row and we can't classify it — be safe and skip.
return null;
}
if (!existing) {
// Raced with a release/delete between create() and findUnique(); reclaim.
return reservation;
}
if (existing.status === "done") {
console.log(
`dedupe: skipping already-processed ${topic} for ${shop} (webhookId=${webhookId})`,
);
return null;
}
const age = Date.now() - new Date(existing.receivedAt).getTime();
if (age > STALE_LEASE_MS) {
// The worker that reserved this crashed mid-process (or left a stale row).
// Renew the lease and retry the work.
try {
await deps.db.processedWebhook.update({
where: { webhookId },
data: { status: "processing", receivedAt: new Date() },
});
} catch (err) {
console.warn(`dedupe: failed to reclaim stale webhook ${webhookId}:`, err);
return null;
}
console.log(
`dedupe: reclaiming stale ${topic} reservation for ${shop} ` +
`(webhookId=${webhookId}, age=${Math.round(age / 1000)}s)`,
);
return reservation;
}
// A fresh "processing" row: another delivery is actively working on it.
// Skip this concurrent delivery. Shopify will retry; if the active worker
// fails it releases the reservation so a later retry reprocesses.
console.log(
`dedupe: ${topic} for ${shop} already in-flight (webhookId=${webhookId}); ` +
`skipping concurrent delivery`,
);
return null;
}
+6 -5
View File
@@ -4,17 +4,18 @@ import {
AppDistribution,
shopifyApp,
} from "@shopify/shopify-app-react-router/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server";
import { requireEnv } from "./services/config/env.server";
import { EncryptedPrismaSessionStorage } from "./services/session/encryptedSessionStorage.server";
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
apiKey: requireEnv("SHOPIFY_API_KEY"),
apiSecretKey: requireEnv("SHOPIFY_API_SECRET"),
apiVersion: ApiVersion.October25,
scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL || "",
appUrl: requireEnv("SHOPIFY_APP_URL"),
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
sessionStorage: new EncryptedPrismaSessionStorage(prisma),
distribution: AppDistribution.SingleMerchant,
future: {
expiringOfflineAccessTokens: true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+39
View File
@@ -0,0 +1,39 @@
# DEV environment for linumiq-invoice (custom app installed on linumiq-dev.myshopify.com).
# Copy to `.env.dev` on the server (in /docker/linumiq-invoice/dev/) and fill in real values.
# NEVER commit the real file.
# --- Shopify app credentials ---
# Partner Dashboard → Apps → linumiq-invoice-dev → API credentials.
SHOPIFY_API_KEY=fbc263e6cc28e8de031878d2a0f17444
SHOPIFY_API_SECRET=REPLACE_ME
# Public URL Shopify uses for OAuth, webhooks and admin embedding. Must match shopify.app.dev.toml.
SHOPIFY_APP_URL=https://invoice-app-dev.linumiq.com
# Single-merchant lock-in: only this myshopify domain may install the app.
ALLOWED_SHOP=linumiq-dev.myshopify.com
# Must match `scopes` in shopify.app.dev.toml.
SCOPES=read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files
# --- Secrets at rest ---
# Field-level encryption key for secrets stored in the DB (SMTP password,
# Shopify session access/refresh tokens). Must be base64 of exactly 32 bytes.
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
DATA_ENCRYPTION_KEY=REPLACE_ME_BASE64_32_BYTES
# Dedicated HMAC key for signing public GiroCode URLs. base64 of 32 bytes.
# If unset, the app falls back to SHOPIFY_API_SECRET (kept for backward compat).
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
GIROCODE_SIGNING_KEY=REPLACE_ME_BASE64_32_BYTES
# --- Runtime ---
NODE_ENV=production
PORT=3000
# DATABASE_URL is set in docker-compose.dev.yml (file:/data/prod.sqlite on the bind mount).
# --- Email (optional) ---
# Archival BCC for every invoice email. Off by default for privacy/GDPR.
# Set to a single address or a comma-separated list to opt in.
# INVOICE_BCC=archive@example.com
+23
View File
@@ -0,0 +1,23 @@
# PROD environment for linumiq-invoice (custom app installed on shop.linumiq.com / 5aiizq-ti.myshopify.com).
# Copy to `.env.prod` on the server (in /docker/linumiq-invoice/prod/) and fill in real values.
# NEVER commit the real file.
# --- Shopify app credentials ---
# Partner Dashboard → Apps → linumiq-invoice (prod) → API credentials.
SHOPIFY_API_KEY=REPLACE_ME
SHOPIFY_API_SECRET=REPLACE_ME
# Public URL Shopify uses for OAuth, webhooks and admin embedding. Must match shopify.app.prod.toml.
SHOPIFY_APP_URL=https://invoice-app.linumiq.com
# Single-merchant lock-in: only this myshopify domain may install the app.
ALLOWED_SHOP=5aiizq-ti.myshopify.com
# Must match `scopes` in shopify.app.prod.toml.
SCOPES=read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files
# --- Runtime ---
NODE_ENV=production
PORT=3000
# DATABASE_URL is set in docker-compose.prod.yml (file:/data/prod.sqlite on the bind mount).
+22 -5
View File
@@ -1,10 +1,27 @@
# Append to your existing Caddyfile (or include via `import`).
# DNS A/AAAA record for invoice-app.linumiq.com must point to this server first,
# otherwise Caddy will fail to obtain a Let's Encrypt certificate.
# DNS A/AAAA records for both subdomains must point to this server first
# (a wildcard *.linumiq.com record is sufficient).
#
# Caddy runs in Docker on the `caddy_net` network and reaches each app by
# container name (the apps do not publish host ports).
# Caddy runs in Docker on the `caddy_net` network and reaches the app by
# container name (the app does not publish a host port).
# DEV — installed on linumiq-dev.myshopify.com
invoice-app-dev.linumiq.com {
encode zstd gzip
# Security response headers. NOTE: deliberately no X-Frame-Options here —
# this is an embedded Shopify app, and framing is governed by the
# Content-Security-Policy `frame-ancestors` directive that the Shopify
# library injects via addDocumentResponseHeaders (see app/entry.server.tsx).
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
reverse_proxy linumiq-invoice-dev:3000
}
# PROD — installed on shop.linumiq.com (5aiizq-ti.myshopify.com)
invoice-app.linumiq.com {
encode zstd gzip
reverse_proxy linumiq-invoice:3000
reverse_proxy linumiq-invoice-prod:3000
}
+94
View File
@@ -0,0 +1,94 @@
# Deployment
Two independent deployments share the same codebase and Docker image build:
| env | container | backend domain | install target | partner-dashboard app | shopify config |
| ---- | --------------------- | ------------------------------- | ------------------------------------------------ | ----------------------- | ------------------------ |
| dev | `linumiq-invoice-dev` | `invoice-app-dev.linumiq.com` | `linumiq-dev.myshopify.com` | `linumiq-invoice-dev` | `shopify.app.dev.toml` |
| prod | `linumiq-invoice-prod`| `invoice-app.linumiq.com` | `5aiizq-ti.myshopify.com` (= `shop.linumiq.com`) | `linumiq-invoice` (prod)| `shopify.app.prod.toml` |
## Server layout (root server)
```
/docker/linumiq-invoice/
├── git/ # checkout of this repo (git pull here)
├── dev/
│ ├── docker-compose.yml # symlink → ../git/deploy/docker-compose.dev.yml
│ ├── .env.dev # secrets (NOT in git)
│ └── data/ # bind-mounted SQLite + cached assets
└── prod/
├── docker-compose.yml # symlink → ../git/deploy/docker-compose.prod.yml
├── .env.prod # secrets (NOT in git)
└── data/ # bind-mounted SQLite + cached assets
```
Both containers attach to the external `caddy_net` Docker network. Caddy reverse-proxies each subdomain to the correct container by name (see `Caddyfile.snippet`).
## First-time setup on the server
```bash
sudo mkdir -p /docker/linumiq-invoice/{git,dev/data,prod/data}
sudo chown -R "$USER" /docker/linumiq-invoice
cd /docker/linumiq-invoice
git clone git@git.linumiq.com:LinumIQ/linumiq-invoice.git git
# DEV
cd /docker/linumiq-invoice/dev
ln -s ../git/deploy/docker-compose.dev.yml docker-compose.yml
cp ../git/deploy/.env.dev.example .env.dev # then edit secrets
docker compose up -d --build
# PROD
cd /docker/linumiq-invoice/prod
ln -s ../git/deploy/docker-compose.prod.yml docker-compose.yml
cp ../git/deploy/.env.prod.example .env.prod # then edit secrets
docker compose up -d --build
```
Append `Caddyfile.snippet` to your Caddy config and `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
## Container runs as a non-root user (uid 1000)
The image runs as the unprivileged `node` user (uid/gid **1000**), not root. The
SQLite database is written to the `/data` bind mount, so the **host** directory
mounted at `/data` (e.g. `/docker/linumiq-invoice/dev/data` and
`…/prod/data`) must be writable by uid 1000, otherwise `prisma migrate deploy`
and DB writes fail on startup:
```bash
sudo chown -R 1000:1000 /docker/linumiq-invoice/dev/data
sudo chown -R 1000:1000 /docker/linumiq-invoice/prod/data
```
The dev container additionally runs with a **read-only root filesystem**
(`read_only: true` + `tmpfs: /tmp`), `no-new-privileges`, all Linux capabilities
dropped, and memory/pids/cpu limits. The app only writes to the `/data` bind
mount and the tmpfs `/tmp`, so this is safe. (The prod compose is intentionally
left unchanged.)
## Day-to-day redeploy
```bash
cd /docker/linumiq-invoice/git && git pull
cd /docker/linumiq-invoice/dev && docker compose up -d --build # update dev
cd /docker/linumiq-invoice/prod && docker compose up -d --build # update prod
```
Run only the env you want to update.
## Pushing config / extension changes to Shopify
From your dev machine (after `git pull` to keep configs in sync):
```bash
# DEV → linumiq-dev.myshopify.com
npx shopify app config use shopify.app.dev.toml
npx shopify app deploy --allow-updates
# PROD → shop.linumiq.com
npx shopify app config use shopify.app.prod.toml
npx shopify app deploy --allow-updates
```
The currently selected config is stored in `.shopify/project.json` (gitignored), so each developer machine remembers its own choice.
+47
View File
@@ -0,0 +1,47 @@
services:
app:
build:
context: /docker/linumiq-invoice/git/
dockerfile: Dockerfile
image: linumiq-invoice:dev
container_name: linumiq-invoice-dev
restart: unless-stopped
# --- Container hardening (DEV) ---------------------------------------
# Prevent privilege escalation and drop all Linux capabilities (the app
# is a plain Node HTTP server — it needs none).
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
# Read-only root filesystem: the app never writes to the image at runtime
# (Prisma client is baked at build; the SQLite DB lives on the /data bind
# mount; logo/image caches live in the DB or in-memory). npm/Prisma
# incidental writes are redirected to the tmpfs /tmp (see Dockerfile env).
read_only: true
tmpfs:
- /tmp
# Resource limits (Compose v2 / docker compose, non-swarm).
mem_limit: 512m
pids_limit: 256
cpus: 1.5
env_file:
- .env.dev
environment:
# SQLite file lives on a bind mount so it survives image rebuilds.
DATABASE_URL: "file:/data/prod.sqlite"
NODE_ENV: production
PORT: "3000"
volumes:
- /docker/linumiq-invoice/dev/data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
interval: 30s
timeout: 5s
retries: 3
networks:
- caddy_net
networks:
caddy_net:
name: caddy_net
external: true
@@ -1,22 +1,22 @@
services:
app:
build:
context: .
context: /docker/linumiq-invoice/git/
dockerfile: Dockerfile
image: linumiq-invoice:latest
container_name: linumiq-invoice
image: linumiq-invoice:prod
container_name: linumiq-invoice-prod
restart: unless-stopped
env_file:
- .env.production
- .env.prod
environment:
# SQLite file lives on a named volume so it survives image rebuilds.
# SQLite file lives on a bind mount so it survives image rebuilds.
DATABASE_URL: "file:/data/prod.sqlite"
NODE_ENV: production
PORT: "3000"
volumes:
- /docker/linumiq-invoice/data:/data
- /docker/linumiq-invoice/prod/data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz", "||", "exit", "0"]
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
interval: 30s
timeout: 5s
retries: 3
@@ -0,0 +1,11 @@
{
"name": "customer-account-payment",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@preact/signals": "^1.3.0",
"@shopify/ui-extensions": "^2026.1.0",
"preact": "^10.22.0",
"typescript": "^5.6.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
import '@shopify/ui-extensions';
//@ts-ignore
declare module './src/CustomerAccount.tsx' {
const shopify: import('@shopify/ui-extensions/customer-account.order-status.payment-details.render-after').Api;
const globalThis: { shopify: typeof shopify };
}
@@ -0,0 +1,15 @@
api_version = "2026-01"
[[extensions]]
name = "Invoice payment instructions (account)"
handle = "customer-account-payment"
type = "ui_extension"
uid = "linumiq-customer-account-payment"
[[extensions.targeting]]
target = "customer-account.order-status.payment-details.render-after"
module = "./src/CustomerAccount.tsx"
[extensions.capabilities]
network_access = true
api_access = false
@@ -0,0 +1,110 @@
import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";
const APP_URL_PROD = "https://invoice-app.linumiq.com";
const APP_URL_DEV = "https://invoice-app-dev.linumiq.com";
const DEV_SHOPS = new Set(["linumiq-dev.myshopify.com"]);
function resolveAppUrl(shopify: any): string {
const shop: string | undefined =
shopify?.shop?.myshopifyDomain ?? shopify?.shop?.value?.myshopifyDomain;
if (shop && DEV_SHOPS.has(shop)) return APP_URL_DEV;
return APP_URL_PROD;
}
interface PaymentInstructions {
language: "de" | "en";
heading: string;
giroCodeUrl: string;
recipient: string;
bankName: string;
iban: string;
bic: string;
amountFormatted: string;
reference: string;
dueDateFormatted: string | null;
instructions: string;
labels: {
recipient: string;
bank: string;
iban: string;
bic: string;
amount: string;
reference: string;
};
}
export default async () => {
render(<Extension />, document.body);
};
function Extension() {
const shopify = (globalThis as any).shopify;
const [data, setData] = useState<PaymentInstructions | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const orderId: string | undefined = shopify?.order?.value?.id;
if (!orderId) {
setDone(true);
return;
}
const token: string = await shopify.sessionToken.get();
const appUrl = resolveAppUrl(shopify);
const res = await fetch(
`${appUrl}/api/public/payment-info?orderId=${encodeURIComponent(orderId)}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) {
setDone(true);
return;
}
const json = (await res.json()) as {
showPaymentInstructions: boolean;
payload?: PaymentInstructions;
};
if (cancelled) return;
if (json.showPaymentInstructions && json.payload) {
setData(json.payload);
}
} catch {
// swallow; render nothing
} finally {
if (!cancelled) setDone(true);
}
}
load();
return () => {
cancelled = true;
};
}, []);
if (!done || !data) {
return null;
}
return (
<s-section heading={data.heading}>
<s-paragraph>{data.instructions}</s-paragraph>
<s-grid gridTemplateColumns="200px 1fr" gap="base" alignItems="start">
<s-image src={data.giroCodeUrl} alt="GiroCode" inlineSize="fill" aspectRatio="1" />
<s-stack direction="block" gap="small-200">
<s-text>{data.labels.recipient}: {data.recipient}</s-text>
{data.bankName ? (
<s-text>{data.labels.bank}: {data.bankName}</s-text>
) : null}
<s-text>{data.labels.iban}: {data.iban}</s-text>
{data.bic ? (
<s-text>{data.labels.bic}: {data.bic}</s-text>
) : null}
<s-text>{data.labels.amount}: {data.amountFormatted}</s-text>
<s-text>{data.labels.reference}: {data.reference}</s-text>
</s-stack>
</s-grid>
</s-section>
);
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
@@ -6,11 +6,11 @@ name = "Generate invoice"
type = "flow_action"
handle = "flow-generate-invoice"
description = "Generates an Austria-compliant PDF invoice for the given order and uploads it to Shopify Files."
runtime_url = "https://example.com/api/flow/generate-invoice"
runtime_url = "https://invoice-app.linumiq.com/api/flow/generate-invoice"
[extensions.settings]
[settings]
[[extensions.settings.fields]]
[[settings.fields]]
type = "single_line_text_field"
key = "order_id"
name = "Order ID"
@@ -6,18 +6,18 @@ name = "Send invoice email"
type = "flow_action"
handle = "flow-send-invoice-email"
description = "Sends the generated PDF invoice via email to the order's customer (or an override address)."
runtime_url = "https://example.com/api/flow/send-invoice-email"
runtime_url = "https://invoice-app.linumiq.com/api/flow/send-invoice-email"
[extensions.settings]
[settings]
[[extensions.settings.fields]]
[[settings.fields]]
type = "single_line_text_field"
key = "order_id"
name = "Order ID"
description = "The order's GID (use Liquid: {{order.id}} when configuring the workflow)."
required = true
[[extensions.settings.fields]]
[[settings.fields]]
type = "single_line_text_field"
key = "recipient_email_override"
name = "Recipient email (optional)"
@@ -30,11 +30,16 @@ function Extension() {
const [payload, setPayload] = useState<Payload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<null | "generate" | "send" | "cancel_reissue">(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionInfo, setActionInfo] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
if (!orderId) return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const res = await fetch(`/api/orders/${orderId}/invoice`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -49,7 +54,36 @@ function Extension() {
return () => {
cancelled = true;
};
}, [orderId]);
}, [orderId, reloadKey]);
async function trigger(action: "generate" | "send" | "cancel_reissue") {
if (!orderId) return;
setBusy(action);
setActionError(null);
setActionInfo(null);
try {
const body = new URLSearchParams({ action });
const res = await fetch(`/api/orders/${orderId}/invoice`, { method: "POST", body });
const txt = await res.text();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${txt.slice(0, 200)}`);
setActionInfo(
action === "send"
? "Invoice email sent."
: action === "cancel_reissue"
? "Cancelled and reissued."
: "Invoice generated.",
);
setReloadKey((k) => k + 1);
} catch (e: any) {
setActionError(e?.message ?? "Action failed");
} finally {
setBusy(null);
}
}
const latest = payload?.latest;
const hasInvoice = !!latest && !latest.cancelledAt;
const sent = !!latest?.sentAt;
return (
<s-admin-block heading="Invoice">
@@ -57,21 +91,53 @@ function Extension() {
<s-text>Loading</s-text>
) : error ? (
<s-banner tone="critical">{error}</s-banner>
) : !payload?.latest ? (
<s-text>No invoice yet for this order.</s-text>
) : (
<s-stack gap="200">
<s-text weight="bold">{payload.latest.invoiceNumber} (v{payload.latest.version})</s-text>
<s-text>Issued {new Date(payload.latest.issuedAt).toLocaleDateString()}</s-text>
<s-badge tone={payload.latest.sentAt ? "success" : "info"}>
{payload.latest.sentAt ? "Sent" : "Not sent"}
</s-badge>
{payload.latest.pdfUrl ? (
<s-link href={payload.latest.pdfUrl} target="_blank">View PDF</s-link>
) : null}
{payload.history.length > 1 ? (
<s-text tone="subdued">{payload.history.length} versions in history</s-text>
) : null}
{latest ? (
<s-stack gap="100">
<s-text weight="bold">
{latest.invoiceNumber} (v{latest.version})
</s-text>
<s-text>Issued {new Date(latest.issuedAt).toLocaleDateString()}</s-text>
<s-badge tone={sent ? "success" : "info"}>{sent ? "Sent" : "Not sent"}</s-badge>
{latest.pdfUrl ? (
<s-link href={latest.pdfUrl} target="_blank">
View PDF
</s-link>
) : null}
{payload!.history.length > 1 ? (
<s-text tone="subdued">{payload!.history.length} versions in history</s-text>
) : null}
</s-stack>
) : (
<s-text>No invoice yet for this order.</s-text>
)}
{actionError ? <s-banner tone="critical">{actionError}</s-banner> : null}
{actionInfo ? <s-banner tone="success">{actionInfo}</s-banner> : null}
<s-stack direction="inline" gap="100">
{!hasInvoice ? (
<s-button onClick={() => trigger("generate")} disabled={busy !== null}>
{busy === "generate" ? "Generating…" : "Generate"}
</s-button>
) : !sent ? (
<s-button onClick={() => trigger("generate")} disabled={busy !== null}>
{busy === "generate" ? "Working…" : "Regenerate"}
</s-button>
) : (
<s-button
onClick={() => trigger("cancel_reissue")}
disabled={busy !== null}
tone="critical"
>
{busy === "cancel_reissue" ? "Working…" : "Cancel & reissue"}
</s-button>
)}
<s-button onClick={() => trigger("send")} disabled={busy !== null}>
{busy === "send" ? "Sending…" : sent ? "Re-send" : "Send"}
</s-button>
</s-stack>
</s-stack>
)}
</s-admin-block>
@@ -0,0 +1,11 @@
{
"name": "invoice-thank-you-payment",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@preact/signals": "^1.3.0",
"@shopify/ui-extensions": "^2026.1.0",
"preact": "^10.22.0",
"typescript": "^5.6.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
import '@shopify/ui-extensions';
//@ts-ignore
declare module './src/Checkout.tsx' {
const shopify: import('@shopify/ui-extensions/purchase.thank-you.block.render').Api;
const globalThis: { shopify: typeof shopify };
}
@@ -0,0 +1,15 @@
api_version = "2026-01"
[[extensions]]
name = "Invoice payment instructions"
handle = "invoice-thank-you-payment"
type = "ui_extension"
uid = "linumiq-invoice-thank-you-payment"
[[extensions.targeting]]
target = "purchase.thank-you.block.render"
module = "./src/Checkout.tsx"
[extensions.capabilities]
network_access = true
api_access = false
@@ -0,0 +1,110 @@
import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";
const APP_URL_PROD = "https://invoice-app.linumiq.com";
const APP_URL_DEV = "https://invoice-app-dev.linumiq.com";
const DEV_SHOPS = new Set(["linumiq-dev.myshopify.com"]);
function resolveAppUrl(shopify: any): string {
const shop: string | undefined =
shopify?.shop?.myshopifyDomain ?? shopify?.shop?.value?.myshopifyDomain;
if (shop && DEV_SHOPS.has(shop)) return APP_URL_DEV;
return APP_URL_PROD;
}
interface PaymentInstructions {
language: "de" | "en";
heading: string;
giroCodeUrl: string;
recipient: string;
bankName: string;
iban: string;
bic: string;
amountFormatted: string;
reference: string;
dueDateFormatted: string | null;
instructions: string;
labels: {
recipient: string;
bank: string;
iban: string;
bic: string;
amount: string;
reference: string;
};
}
export default async () => {
render(<Extension />, document.body);
};
function Extension() {
const shopify = (globalThis as any).shopify;
const [data, setData] = useState<PaymentInstructions | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const orderId: string | undefined = shopify?.orderConfirmation?.value?.order?.id;
if (!orderId) {
setDone(true);
return;
}
const token: string = await shopify.sessionToken.get();
const appUrl = resolveAppUrl(shopify);
const res = await fetch(
`${appUrl}/api/public/payment-info?orderId=${encodeURIComponent(orderId)}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) {
setDone(true);
return;
}
const json = (await res.json()) as {
showPaymentInstructions: boolean;
payload?: PaymentInstructions;
};
if (cancelled) return;
if (json.showPaymentInstructions && json.payload) {
setData(json.payload);
}
} catch {
// swallow; render nothing
} finally {
if (!cancelled) setDone(true);
}
}
load();
return () => {
cancelled = true;
};
}, []);
if (!done || !data) {
return null;
}
return (
<s-section heading={data.heading}>
<s-paragraph>{data.instructions}</s-paragraph>
<s-grid gridTemplateColumns="200px 1fr" gap="base" alignItems="start">
<s-image src={data.giroCodeUrl} alt="GiroCode" inlineSize="fill" aspectRatio="1" />
<s-stack direction="block" gap="small-200">
<s-text>{data.labels.recipient}: {data.recipient}</s-text>
{data.bankName ? (
<s-text>{data.labels.bank}: {data.bankName}</s-text>
) : null}
<s-text>{data.labels.iban}: {data.iban}</s-text>
{data.bic ? (
<s-text>{data.labels.bic}: {data.bic}</s-text>
) : null}
<s-text>{data.labels.amount}: {data.amountFormatted}</s-text>
<s-text>{data.labels.reference}: {data.reference}</s-text>
</s-stack>
</s-grid>
</s-section>
);
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
+795 -40
View File
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -9,15 +9,16 @@
"deploy": "shopify app deploy",
"config:use": "shopify app config use",
"env": "shopify app env",
"start": "react-router-serve ./build/server/index.js",
"docker-start": "npm run setup && npm run start",
"start": "node ./server.js",
"docker-start": "prisma migrate deploy && npm run start",
"setup": "prisma generate && prisma migrate deploy",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"shopify": "shopify",
"prisma": "prisma",
"graphql-codegen": "graphql-codegen",
"vite": "vite",
"typecheck": "react-router typegen && tsc --noEmit"
"typecheck": "react-router typegen && tsc --noEmit",
"test": "tsx --test tests"
},
"type": "module",
"engines": {
@@ -27,15 +28,29 @@
"@prisma/client": "^6.16.3",
"@react-pdf/renderer": "^4.5.1",
"@react-router/dev": "^7.12.0",
"@react-router/express": "^7.14.2",
"@react-router/fs-routes": "^7.12.0",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@shopify/app-bridge-react": "^4.2.4",
"@shopify/shopify-app-react-router": "^1.1.0",
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
"@tiptap/extension-color": "^3.23.1",
"@tiptap/extension-image": "^3.23.1",
"@tiptap/extension-link": "^3.23.1",
"@tiptap/extension-text-style": "^3.23.1",
"@tiptap/pm": "^3.23.1",
"@tiptap/react": "^3.23.1",
"@tiptap/starter-kit": "^3.23.1",
"@types/nodemailer": "^8.0.0",
"compression": "^1.8.1",
"express": "^4.22.1",
"express-rate-limit": "^8.5.2",
"ipaddr.js": "^2.4.0",
"isbot": "^5.1.31",
"morgan": "^1.10.1",
"nodemailer": "^8.0.7",
"p-limit": "^3.1.0",
"prisma": "^6.16.3",
"qrcode": "^1.5.4",
"react": "^18.3.1",
@@ -0,0 +1,55 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ShopSettings" (
"id" TEXT NOT NULL PRIMARY KEY,
"shopDomain" TEXT NOT NULL,
"companyName" TEXT NOT NULL DEFAULT '',
"legalForm" TEXT NOT NULL DEFAULT '',
"ownerName" TEXT NOT NULL DEFAULT '',
"addressLine1" TEXT NOT NULL DEFAULT '',
"addressLine2" TEXT NOT NULL DEFAULT '',
"postalCode" TEXT NOT NULL DEFAULT '',
"city" TEXT NOT NULL DEFAULT '',
"countryCode" TEXT NOT NULL DEFAULT 'AT',
"phone" TEXT NOT NULL DEFAULT '',
"email" TEXT NOT NULL DEFAULT '',
"website" TEXT NOT NULL DEFAULT '',
"vatId" TEXT NOT NULL DEFAULT '',
"taxNumber" TEXT NOT NULL DEFAULT '',
"registrationNo" TEXT NOT NULL DEFAULT '',
"registrationCourt" TEXT NOT NULL DEFAULT '',
"bankName" TEXT NOT NULL DEFAULT '',
"iban" TEXT NOT NULL DEFAULT '',
"bic" TEXT NOT NULL DEFAULT '',
"giroCodeEnabled" BOOLEAN NOT NULL DEFAULT true,
"numberingMode" TEXT NOT NULL DEFAULT 'shopify_order_number',
"invoicePrefix" TEXT NOT NULL DEFAULT 'RE-',
"invoiceSeed" INTEGER NOT NULL DEFAULT 1000,
"defaultLanguage" TEXT NOT NULL DEFAULT 'de',
"paymentTermDays" INTEGER NOT NULL DEFAULT 14,
"footerNote" TEXT NOT NULL DEFAULT '',
"footerNoteEn" TEXT NOT NULL DEFAULT '',
"kleinunternehmer" BOOLEAN NOT NULL DEFAULT false,
"logoUrl" TEXT NOT NULL DEFAULT '',
"smtpHost" TEXT NOT NULL DEFAULT '',
"smtpPort" INTEGER NOT NULL DEFAULT 587,
"smtpSecure" BOOLEAN NOT NULL DEFAULT false,
"smtpUser" TEXT NOT NULL DEFAULT '',
"smtpPassword" TEXT NOT NULL DEFAULT '',
"smtpFromName" TEXT NOT NULL DEFAULT '',
"smtpFromEmail" TEXT NOT NULL DEFAULT '',
"smtpReplyTo" TEXT NOT NULL DEFAULT '',
"emailSubjectDe" TEXT NOT NULL DEFAULT '',
"emailBodyHtmlDe" TEXT NOT NULL DEFAULT '',
"emailSubjectEn" TEXT NOT NULL DEFAULT '',
"emailBodyHtmlEn" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_ShopSettings" ("addressLine1", "addressLine2", "bankName", "bic", "city", "companyName", "countryCode", "createdAt", "defaultLanguage", "email", "footerNote", "footerNoteEn", "giroCodeEnabled", "iban", "id", "invoicePrefix", "invoiceSeed", "kleinunternehmer", "legalForm", "logoUrl", "numberingMode", "ownerName", "paymentTermDays", "phone", "postalCode", "registrationCourt", "registrationNo", "shopDomain", "smtpFromEmail", "smtpFromName", "smtpHost", "smtpPassword", "smtpPort", "smtpReplyTo", "smtpSecure", "smtpUser", "taxNumber", "updatedAt", "vatId", "website") SELECT "addressLine1", "addressLine2", "bankName", "bic", "city", "companyName", "countryCode", "createdAt", "defaultLanguage", "email", "footerNote", "footerNoteEn", "giroCodeEnabled", "iban", "id", "invoicePrefix", "invoiceSeed", "kleinunternehmer", "legalForm", "logoUrl", "numberingMode", "ownerName", "paymentTermDays", "phone", "postalCode", "registrationCourt", "registrationNo", "shopDomain", "smtpFromEmail", "smtpFromName", "smtpHost", "smtpPassword", "smtpPort", "smtpReplyTo", "smtpSecure", "smtpUser", "taxNumber", "updatedAt", "vatId", "website" FROM "ShopSettings";
DROP TABLE "ShopSettings";
ALTER TABLE "new_ShopSettings" RENAME TO "ShopSettings";
CREATE UNIQUE INDEX "ShopSettings_shopDomain_key" ON "ShopSettings"("shopDomain");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "ShopSettings" ADD COLUMN "autoEmailOnWireTransferPlaced" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "ShopSettings" ADD COLUMN "autoEmailOnFulfilledNonWireTransfer" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "ShopSettings" ADD COLUMN "wireTransferGatewayNames" TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,4 @@
-- Drop the column added by the previous migration; we now classify
-- wire-transfer orders by querying OrderTransaction.manualPaymentGateway
-- via the GraphQL Admin API instead of by configurable name match.
ALTER TABLE "ShopSettings" DROP COLUMN "wireTransferGatewayNames";
@@ -0,0 +1,13 @@
-- Idempotency table for inbound Shopify webhooks. We insert a row keyed on
-- the X-Shopify-Webhook-Id header at the start of webhook processing; a
-- duplicate insert (P2002) means Shopify retried a delivery we've already
-- seen, so we short-circuit and return 200 without doing the work twice.
CREATE TABLE "ProcessedWebhook" (
"webhookId" TEXT NOT NULL PRIMARY KEY,
"topic" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"receivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "ProcessedWebhook_shopDomain_topic_idx"
ON "ProcessedWebhook"("shopDomain", "topic");
@@ -0,0 +1,16 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ProcessedWebhook" (
"webhookId" TEXT NOT NULL PRIMARY KEY,
"topic" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'done',
"receivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO "new_ProcessedWebhook" ("receivedAt", "shopDomain", "topic", "webhookId") SELECT "receivedAt", "shopDomain", "topic", "webhookId" FROM "ProcessedWebhook";
DROP TABLE "ProcessedWebhook";
ALTER TABLE "new_ProcessedWebhook" RENAME TO "ProcessedWebhook";
CREATE INDEX "ProcessedWebhook_shopDomain_topic_idx" ON "ProcessedWebhook"("shopDomain", "topic");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+31
View File
@@ -93,6 +93,19 @@ model ShopSettings {
smtpFromEmail String @default("")
smtpReplyTo String @default("")
// Email templates (HTML, with {{var}} placeholders). Empty = use defaults.
emailSubjectDe String @default("")
emailBodyHtmlDe String @default("")
emailSubjectEn String @default("")
emailBodyHtmlEn String @default("")
// Automations (webhook-driven, as a fallback to Shopify Flow which only
// exposes custom-app actions on Plus stores).
// 1) Wire-transfer order is placed → auto-email the invoice immediately.
autoEmailOnWireTransferPlaced Boolean @default(false)
// 2) Order is fulfilled and is NOT a wire-transfer order → auto-email.
autoEmailOnFulfilledNonWireTransfer Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -169,6 +182,24 @@ model EmailLog {
@@index([shopDomain, invoiceId])
}
// Idempotency table for inbound Shopify webhooks. See
// `app/services/webhooks/dedupe.server.ts` for details.
model ProcessedWebhook {
webhookId String @id
topic String
shopDomain String
// Reserve/commit lifecycle for at-least-once side-effect processing:
// "processing" — reserved, side-effect work in flight (acts as the lock)
// "done" — work completed successfully; future retries are dropped
// A "processing" row older than the stale lease (see dedupe.server.ts) is
// treated as a crashed reservation and may be reclaimed. Existing rows
// migrated from the old (record-before-work) design default to "done".
status String @default("done")
receivedAt DateTime @default(now())
@@index([shopDomain, topic])
}
// Per-shop logo bytes cache. Avoids fetching the logo from Shopify Files on
// every PDF render.
model LogoCache {
+482 -12
View File
@@ -134,7 +134,10 @@ function buildAtB2BOrder(): RawOrderForInvoice {
processedAt: "2026-04-15T10:00:00Z",
currencyCode: "EUR",
displayFinancialStatus: "PENDING",
paymentGatewayNames: ["manual"],
taxesIncluded: false,
requiresShipping: true,
discountCodes: [],
customer: {
firstName: "Lukas",
lastName: "Schmidhofer",
@@ -151,13 +154,48 @@ function buildAtB2BOrder(): RawOrderForInvoice {
province: null,
countryCode: "AT",
},
shippingAddress: null,
shippingAddress: {
name: "Lukas Schmidhofer",
company: "Schmidhofer Dienstleistungen",
address1: "Lagerweg 4",
address2: null,
zip: "8020",
city: "Graz",
province: null,
countryCode: "AT",
},
shippingLine: {
title: "Standardversand",
code: "STD",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: "shipping",
originalPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } },
taxLines: [
{
title: "USt 20%",
rate: 0.2,
ratePercentage: 20,
priceSet: { shopMoney: { amount: "1.00", currencyCode: "EUR" } },
},
],
},
fulfillments: [
{
createdAt: "2026-05-13T10:30:00.000Z",
trackingInfo: [
{ number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" },
],
},
],
lineItems: [
{
title: "Bluetooth Tracker",
sku: "BT-TRK-001",
quantity: qty,
originalUnitPriceSet: { shopMoney: { amount: unitNet.toFixed(2), currencyCode: "EUR" } },
discountedUnitPriceSet: null,
imageUrl: "file://product-image", // placeholder; the smoke script inlines a real data: URL on the composed line below.
taxLines: [
{
@@ -178,8 +216,9 @@ function buildAtB2BOrder(): RawOrderForInvoice {
},
],
subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } },
totalTaxSet: { shopMoney: { amount: lineTax.toFixed(2), currencyCode: "EUR" } },
totalPriceSet: { shopMoney: { amount: lineGross.toFixed(2), currencyCode: "EUR" } },
totalTaxSet: { shopMoney: { amount: (lineTax + 1.0).toFixed(2), currencyCode: "EUR" } },
totalPriceSet: { shopMoney: { amount: (lineGross + 6.0).toFixed(2), currencyCode: "EUR" } },
totalRefundedSet: null,
purchasingEntity: {
company: {
name: "Schmidhofer Dienstleistungen",
@@ -202,6 +241,10 @@ function buildEuB2BReverseChargeOrder(): RawOrderForInvoice {
o.purchasingEntity!.company!.vatId = "DE123456789";
o.lineItems[0].taxLines = [];
o.taxLines = [];
// No VAT for reverse-charge; clear shipping VAT too.
o.shippingLine = null;
o.fulfillments = [];
o.shippingAddress = null;
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
return o;
@@ -216,12 +259,100 @@ function buildExportOrder(): RawOrderForInvoice {
o.billingAddress!.city = "New York";
o.lineItems[0].taxLines = [];
o.taxLines = [];
o.shippingLine = null;
o.fulfillments = [];
o.shippingAddress = null;
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
o.customer!.locale = "en";
return o;
}
/**
* Variant of the AT B2B order with a per-line discount: unit price stays
* 7.19 EUR gross (5.99 net) but Shopify allocated a 1.00 EUR/unit discount,
* so the discounted unit price is 6.19 gross (5.16 net). Also adds an
* order-level discount code ("SUMMER10") for the meta-block render.
*/
function buildDiscountedOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.discountCodes = ["SUMMER10"];
// Discount of 1.00 EUR/unit applied: net unit drops from 5.99 to 4.99,
// qty 6 → 29.94 net, tax (20%) = 5.99.
o.lineItems[0].discountedUnitPriceSet = {
shopMoney: { amount: "4.99", currencyCode: "EUR" },
};
o.lineItems[0].taxLines = [
{
title: "USt 20%",
rate: 0.2,
ratePercentage: 20,
priceSet: { shopMoney: { amount: "5.99", currencyCode: "EUR" } },
},
];
return o;
}
/**
* Variant of the AT B2B order whose shipping line is local pickup. The
* "shipping address" still carries the pickup-location address (as Shopify
* does), but the composer should detect the pickup and suppress it.
*/
function buildPickupOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.shippingLine = {
title: "Lager Graz",
code: "Pickup",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: "pickup",
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
taxLines: [],
};
return o;
}
/** Pickup variant where neither title/code nor source mention "pickup" —
* detection must rely purely on `deliveryCategory`. Mirrors what we
* observed on a real Shopify Local Pickup install. */
function buildCategoryOnlyPickupOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.shippingLine = {
title: "Lager Graz",
code: "Standard",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: "pickup",
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
taxLines: [],
};
return o;
}
/** Pickup variant matching a REAL observed order on
* linumiq-dev.myshopify.com (#1032): Shopify's built-in "Shop location"
* rate. NO "pickup" string anywhere, deliveryCategory is `null`, and
* shippingAddress is also `null` — detection must rely on
* `requiresShipping && shippingAddress == null`. */
function buildShopLocationPickupOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.shippingLine = {
title: "Shop location",
code: "Shop location",
source: "shopify",
carrierIdentifier: null,
deliveryCategory: null,
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
taxLines: [],
};
o.shippingAddress = null;
o.requiresShipping = true;
return o;
}
// ------------------------------------------------------------------
// Run assertions
// ------------------------------------------------------------------
@@ -264,20 +395,33 @@ async function main() {
assertEq("currency", vm.currency, "EUR");
assert("isB2B detected", vm.isB2B);
assertEq("recipientVatId", vm.recipientVatId, "ATU57680511");
assertEq("line count", vm.lines.length, 1);
assertEq("line count (1 product + 1 shipping)", vm.lines.length, 2);
const ln = vm.lines[0];
assertEq("line title", ln.title, "Bluetooth Tracker");
assertEq("line qty", ln.quantity, 6);
assertNear("line unit net", ln.unitPriceNet, 5.99);
assertNear("line total net", ln.totalNet, 35.94);
assertNear("net total", vm.totals.net, 35.94);
const shipLine = vm.lines[1];
assert("shipping line title prefixed", shipLine.title.startsWith("Versand"),
`got "${shipLine.title}"`);
assertNear("shipping line net", shipLine.totalNet, 5.0);
assertNear("net total (incl. shipping)", vm.totals.net, 40.94);
assertEq("vat breakdown rows", vm.totals.vatBreakdown.length, 1);
assertNear("vat amount", vm.totals.vatBreakdown[0].tax, 7.19);
assertNear("vat amount (incl. shipping VAT)", vm.totals.vatBreakdown[0].tax, 8.19);
assertEq("vat rate %", vm.totals.vatBreakdown[0].ratePct, 20);
assertNear("gross", vm.totals.gross, 43.13);
assertNear("gross (incl. shipping)", vm.totals.gross, 49.13);
assertEq("no notices for AT B2B with VAT charged", vm.notices.length, 0);
assert("due date 14 days after invoice date", !!vm.dueDate
&& Math.round((vm.dueDate.getTime() - vm.invoiceDate.getTime()) / 86400000) === 14);
assertEq("paymentGatewayNames propagated", vm.paymentGatewayNames.join(","), "manual");
assertEq("paymentStatus derived from displayFinancialStatus=PENDING", vm.paymentStatus, "unpaid");
assertEq("orderName propagated", vm.orderName, "#1004");
assertEq("shippingMethod propagated", vm.shippingMethod, "Standardversand");
assertEq("tracking entries", vm.tracking.length, 1);
assertEq("tracking number", vm.tracking[0].number, "JJD0099887766");
assertEq("tracking carrier", vm.tracking[0].company, "DHL");
assert("separateShippingAddress detected (differs from billing)",
vm.separateShippingAddress?.addressLine1 === "Lagerweg 4");
console.log("• EU B2B reverse-charge notice");
const euOrder = buildEuB2BReverseChargeOrder();
@@ -343,15 +487,16 @@ async function main() {
assertEq("kind = storno", storno.kind, "storno");
assertEq("cancelsNumber populated", storno.cancelsNumber, "RE-1004");
assert("dueDate suppressed for storno", storno.dueDate == null);
assertEq("line count preserved", storno.lines.length, 1);
assertEq("line count preserved", storno.lines.length, 2);
assertNear("line qty preserved (only money negated)", storno.lines[0].quantity, 6);
assertNear("line unit price negated", storno.lines[0].unitPriceNet, -5.99);
assertNear("line totalNet negated", storno.lines[0].totalNet, -35.94);
assertNear("totals.net negated", storno.totals.net, -35.94);
assertNear("totals.totalVat negated", storno.totals.totalVat, -7.19);
assertNear("totals.gross negated", storno.totals.gross, -43.13);
assertNear("shipping line totalNet negated", storno.lines[1].totalNet, -5.0);
assertNear("totals.net negated", storno.totals.net, -40.94);
assertNear("totals.totalVat negated", storno.totals.totalVat, -8.19);
assertNear("totals.gross negated", storno.totals.gross, -49.13);
assertEq("vat breakdown row count preserved", storno.totals.vatBreakdown.length, 1);
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -7.19);
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -8.19);
console.log("• Render storno PDF");
storno.issuer.logoDataUrl = vm.issuer.logoDataUrl;
@@ -363,6 +508,156 @@ async function main() {
assert("storno PDF > 5 KB", stornoBuf.length > 5_000, `actual ${stornoBuf.length}`);
console.log(` → wrote ${stornoOut} (${stornoBuf.length} bytes)`);
// ----------------------------------------------------------------
// Refunded order: GiroCode + payment-terms must be suppressed
// ----------------------------------------------------------------
console.log("• Refunded order (REFUNDED) suppresses GiroCode + payment terms");
{
const baseRefunded = buildAtB2BOrder();
const refundedOrder = {
...baseRefunded,
displayFinancialStatus: "REFUNDED",
// Mirror the full gross as refunded so the new "Offener Betrag"
// row should print 0,00 \u20ac.
totalRefundedSet: baseRefunded.totalPriceSet,
};
const refundedVm = composeInvoice({
order: refundedOrder, settings: settings as never, invoiceNumber: "RE-1014",
});
assertEq("paymentStatus=refunded", refundedVm.paymentStatus, "refunded");
assert("requiresPayment=false for refunded", refundedVm.requiresPayment === false);
assertNear("refundedAmount mirrors totalRefundedSet", refundedVm.refundedAmount, refundedVm.totals.gross);
refundedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
// The orchestrator gates GiroCode generation on requiresPayment too \u2014
// simulate a stale QR data URL anyway and verify the PDF render-gate
// independently refuses to render it.
refundedVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName,
iban: settings.iban,
bic: settings.bic,
amount: refundedVm.totals.gross,
remittance: refundedVm.number,
});
const refundedText = await pdfToText(await renderInvoicePdf(refundedVm));
assert("Refunded PDF does NOT show GiroCode caption",
!refundedText.includes("GiroCode"));
assert("Refunded PDF does NOT show DE payment terms",
!refundedText.includes("Bitte \u00fcberweise"));
assert("Refunded PDF still shows the 'Erstattet' status row",
refundedText.includes("Erstattet"));
assert("Refunded PDF shows the 'Zur\u00fcckerstattet' totals row",
refundedText.includes("Zur\u00fcckerstattet"));
assert("Refunded PDF labels the final row 'Endbetrag' (nothing is outstanding)",
refundedText.includes("Endbetrag") && !refundedText.includes("Offener Betrag"));
assert("Refunded PDF shows 0,00 EUR as outstanding",
refundedText.includes("0,00 EUR"));
}
// Same gating must apply to PAID orders.
console.log("• Paid order (PAID) suppresses GiroCode + payment terms");
{
const paidOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "PAID" };
const paidVm = composeInvoice({
order: paidOrder, settings: settings as never, invoiceNumber: "RE-1015",
});
assert("requiresPayment=false for paid", paidVm.requiresPayment === false);
paidVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
paidVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName,
iban: settings.iban,
bic: settings.bic,
amount: paidVm.totals.gross,
remittance: paidVm.number,
});
const paidText = await pdfToText(await renderInvoicePdf(paidVm));
assert("Paid PDF does NOT show GiroCode caption", !paidText.includes("GiroCode"));
assert("Paid PDF does NOT show DE payment terms", !paidText.includes("Bitte überweise"));
assert("Paid PDF shows the 'Bezahlt' status row", paidText.includes("Bezahlt"));
}
// ----------------------------------------------------------------
// Partial refund on a paid order: status stays "Bezahlt", the final
// row is labelled "Endbetrag" (not "Offener Betrag"), and the kept
// amount is shown.
// ----------------------------------------------------------------
console.log("• Partial refund on a paid order (PARTIALLY_REFUNDED)");
{
const basePartial = buildAtB2BOrder();
const grossStr = basePartial.totalPriceSet?.shopMoney.amount ?? "0";
const grossNum = parseFloat(grossStr);
const partialRefund = +(grossNum * 0.25).toFixed(2);
const partialOrder = {
...basePartial,
displayFinancialStatus: "PARTIALLY_REFUNDED",
totalRefundedSet: { shopMoney: { amount: partialRefund.toFixed(2), currencyCode: "EUR" } },
};
const partialVm = composeInvoice({
order: partialOrder, settings: settings as never, invoiceNumber: "RE-1016",
});
assertEq("paymentStatus reclassified to paid (partial refund < gross)",
partialVm.paymentStatus, "paid");
assert("requiresPayment=false for partially refunded paid order",
partialVm.requiresPayment === false);
assertNear("refundedAmount mirrors partial refund",
partialVm.refundedAmount, partialRefund);
partialVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const partialText = await pdfToText(await renderInvoicePdf(partialVm));
assert("Partial-refund PDF shows 'Bezahlt' status row (not Erstattet)",
partialText.includes("Bezahlt") && !partialText.includes("Erstattet"));
assert("Partial-refund PDF shows 'Zurückerstattet' totals row",
partialText.includes("Zurückerstattet"));
assert("Partial-refund PDF labels the final row 'Endbetrag' (not 'Offener Betrag')",
partialText.includes("Endbetrag") && !partialText.includes("Offener Betrag"));
assert("Partial-refund PDF does NOT show GiroCode caption",
!partialText.includes("GiroCode"));
assert("Partial-refund PDF does NOT show DE payment terms",
!partialText.includes("Bitte überweise"));
}
// ----------------------------------------------------------------
// Defensive: PARTIALLY_REFUNDED where the refund equals the gross
// (Shopify hasn't flipped to REFUNDED yet) must still classify as
// refunded.
// ----------------------------------------------------------------
console.log("• PARTIALLY_REFUNDED with refund==gross stays 'refunded'");
{
const baseFull = buildAtB2BOrder();
const fullRefundOrder = {
...baseFull,
displayFinancialStatus: "PARTIALLY_REFUNDED",
totalRefundedSet: baseFull.totalPriceSet,
};
const vmFull = composeInvoice({
order: fullRefundOrder, settings: settings as never, invoiceNumber: "RE-1017",
});
assertEq("paymentStatus stays refunded when refund==gross",
vmFull.paymentStatus, "refunded");
assert("requiresPayment still false", vmFull.requiresPayment === false);
}
// ----------------------------------------------------------------
// VOIDED: authorisation cancelled before capture. No money received,
// none owed. Must classify as "voided" (not "unpaid") and suppress
// GiroCode + payment terms.
// ----------------------------------------------------------------
console.log("• Voided order (VOIDED) classifies as voided, no GiroCode");
{
const voidedOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "VOIDED" };
const voidedVm = composeInvoice({
order: voidedOrder, settings: settings as never, invoiceNumber: "RE-1018",
});
assertEq("paymentStatus=voided for VOIDED", voidedVm.paymentStatus, "voided");
assert("requiresPayment=false for voided", voidedVm.requiresPayment === false);
voidedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const voidedText = await pdfToText(await renderInvoicePdf(voidedVm));
assert("Voided PDF shows the 'Annulliert' status row",
voidedText.includes("Annulliert"));
assert("Voided PDF does NOT show GiroCode caption",
!voidedText.includes("GiroCode"));
assert("Voided PDF does NOT show DE payment terms",
!voidedText.includes("Bitte überweise"));
}
// ----------------------------------------------------------------
// Footer note translation
// ----------------------------------------------------------------
@@ -398,6 +693,181 @@ async function main() {
!/Thank you for your purchase\.[\s\S]{0,40}Gerhard Berger/.test(enText),
);
// Informal German tone (du/dein) — make sure no formal "Sie/Ihren" remains
// in the strings we control (footer / signature lines come from settings).
// The PDF intentionally has NO salutation — this is an invoice, not a
// letter. Both formal ("Sehr geehrte …") and informal ("Hallo,") are
// suppressed.
assert("DE PDF has no 'Hallo,' salutation", !deText.includes("Hallo,"));
assert("DE PDF has no 'Sehr geehrte Damen und Herren' salutation", !deText.includes("Sehr geehrte Damen und Herren"));
assert("DE PDF uses informal 'deine Bestellung'", deText.includes("deine Bestellung"));
assert(
"DE PDF payment-terms uses informal 'überweise … für dich'",
deText.includes("Bitte überweise") && deText.includes("für dich"),
);
assert("DE PDF shows payment status row", deText.includes("Zahlstatus"));
assert("DE PDF shows payment status value 'Offen' for PENDING", deText.includes("Offen"));
assert("DE PDF shows payment method row", deText.includes("Zahlart"));
// The Shopify Admin GraphQL API returns the *English* template name for
// built-in manual payment gateways even on German-locale shops — we
// localize it ourselves via i18n.paymentGatewayLabels so the PDF matches
// what the customer saw on the order-confirmation page.
assert("DE PDF localizes 'manual' gateway to 'Manuelle Zahlung'",
deText.includes("Manuelle Zahlung"));
assert("DE PDF no longer shows raw English 'Manual' as gateway label",
!/Zahlart[\s\S]{0,20}Manual\b/.test(deText));
assert("EN PDF shows payment status row", enText.includes("Payment status"));
assert("EN PDF shows payment status value 'Outstanding' for PENDING", enText.includes("Outstanding"));
// Shipment + order-number block.
assert("DE PDF shows order number row 'Bestellnummer'", deText.includes("Bestellnummer"));
assert("DE PDF shows Shopify order name '#1004'", deText.includes("#1004"));
assert("DE PDF shows shipping method row 'Versandart'", deText.includes("Versandart"));
assert("DE PDF shows shipping method value 'Standardversand'", deText.includes("Standardversand"));
assert("DE PDF shows tracking row 'Sendungsnummer'", deText.includes("Sendungsnummer"));
assert("DE PDF shows tracking number", deText.includes("JJD0099887766"));
assert("DE PDF shows shipping line item with prefix", deText.includes("Versand"));
assert("DE PDF shows separate delivery address heading", deText.includes("Lieferadresse"));
assert("DE PDF shows shipping address line", deText.includes("Lagerweg 4"));
assert("EN PDF shows order number row 'Order no.'", enText.includes("Order no."));
assert("EN PDF shows shipping method row 'Shipping method'", enText.includes("Shipping method"));
assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no."));
assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address"));
// Order-number suppression: when the invoice number's trailing digits
// match the Shopify order name (default numbering mode), the redundant
// "· Bestellnummer: #1004" suffix should be dropped from the title.
const sameNumVm = composeInvoice({
order, settings: settings as never, invoiceNumber: "RE-1004",
});
sameNumVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const sameNumText = await pdfToText(await renderInvoicePdf(sameNumVm));
assert("PDF suppresses 'Bestellnummer' suffix when invoice# matches order#",
!sameNumText.includes("Bestellnummer"));
assert("PDF still shows the invoice number itself when suppressed",
sameNumText.includes("RE-1004"));
// ----------------------------------------------------------------
// Delivery date follows latest fulfillment, not processedAt
// ----------------------------------------------------------------
console.log("• Delivery date is taken from latest fulfillment");
// The AT B2B fixture has processedAt 2026-04-15 and fulfillment.createdAt
// 2026-05-13 — the composer must pick the fulfillment.
assertEq(
"vm.deliveryDate matches fulfillment.createdAt",
vm.deliveryDate.toISOString().slice(0, 10),
"2026-05-13",
);
// EU/Export variants have no fulfillments, so delivery date == invoice date.
assertEq(
"EU vm.deliveryDate falls back to invoiceDate when unfulfilled",
euVm.deliveryDate.toISOString().slice(0, 10),
euVm.invoiceDate.toISOString().slice(0, 10),
);
// ----------------------------------------------------------------
// Discount: per-line strikethrough + cart code
// ----------------------------------------------------------------
console.log("• Discount (per-line + cart-level)");
const discOrder = buildDiscountedOrder();
const discVm = composeInvoice({
order: discOrder,
settings: settings as never,
invoiceNumber: "RE-1020",
});
assertEq("discountCodes propagated", discVm.discountCodes.join(","), "SUMMER10");
assertNear("discounted unit net (~4.99)", discVm.lines[0].unitPriceNet, 4.99);
assert(
"originalUnitPriceNet populated when discounted differs",
discVm.lines[0].originalUnitPriceNet != null,
);
assertNear(
"originalUnitPriceNet matches pre-discount net (~5.99)",
discVm.lines[0].originalUnitPriceNet ?? 0,
5.99,
);
discVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const discPdf = await renderInvoicePdf(discVm);
const discText = await pdfToText(discPdf);
assert("DE PDF shows discount-code label", discText.includes("Rabattcode"));
assert("DE PDF shows discount code value", discText.includes("SUMMER10"));
const discEnVm = composeInvoice({
order: discOrder,
settings: settings as never,
invoiceNumber: "RE-1021",
forceLanguage: "en",
});
discEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const discEnText = await pdfToText(await renderInvoicePdf(discEnVm));
assert("EN PDF shows discount-code label", discEnText.includes("Discount code"));
// ----------------------------------------------------------------
// Pickup: separate shipping address suppressed; method localized
// ----------------------------------------------------------------
console.log("• Local pickup");
const pickupOrder = buildPickupOrder();
const pickupVm = composeInvoice({
order: pickupOrder,
settings: settings as never,
invoiceNumber: "RE-1030",
});
assert("isPickup detected via shippingLine heuristic", pickupVm.isPickup);
assertEq("pickupLocationName propagated from shippingLine.title", pickupVm.pickupLocationName, "Lager Graz");
assert("shippingMethod cleared for pickup (renderer uses pickup row instead)",
pickupVm.shippingMethod == null);
assert(
"separateShippingAddress suppressed for pickup",
pickupVm.separateShippingAddress == null,
);
pickupVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const pickupText = await pdfToText(await renderInvoicePdf(pickupVm));
assert("DE pickup PDF shows 'Abholort' label", pickupText.includes("Abholort"));
assert("DE pickup PDF shows location name", pickupText.includes("Lager Graz"));
assert("DE pickup PDF does NOT show 'Versandart'", !pickupText.includes("Versandart"));
assert(
"DE pickup PDF does NOT render pickup-location address as delivery address",
!pickupText.includes("Lieferadresse"),
);
// EN translation
const pickupEnVm = composeInvoice({
order: pickupOrder,
settings: settings as never,
invoiceNumber: "RE-1031",
forceLanguage: "en",
});
pickupEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
const pickupEnText = await pdfToText(await renderInvoicePdf(pickupEnVm));
assert("EN pickup PDF shows 'Pick-up location' label", pickupEnText.includes("Pick-up location"));
// Real-world pickup variant: shippingLine has no "pickup" keyword in
// title/code/source — only `deliveryCategory` says it's pickup.
const categoryPickupVm = composeInvoice({
order: buildCategoryOnlyPickupOrder(),
settings: settings as never,
invoiceNumber: "RE-1033",
});
assert("isPickup detected from deliveryCategory alone", categoryPickupVm.isPickup);
assertEq("pickupLocationName from title when category-only",
categoryPickupVm.pickupLocationName, "Lager Graz");
assert("shippingMethod cleared in category-only pickup",
categoryPickupVm.shippingMethod == null);
// Real-world "Shop location" pickup (matches dev order #1032): no
// "pickup" keyword anywhere, deliveryCategory null, shippingAddress null.
// The only signal is `requiresShipping && !shippingAddress`.
const shopLocPickupVm = composeInvoice({
order: buildShopLocationPickupOrder(),
settings: settings as never,
invoiceNumber: "RE-1034",
});
assert("isPickup detected from missing shippingAddress (Shop location rate)",
shopLocPickupVm.isPickup);
assertEq("pickupLocationName from shippingLine.title for Shop location",
shopLocPickupVm.pickupLocationName, "Shop location");
assert("shippingMethod cleared for Shop location pickup",
shopLocPickupVm.shippingMethod == null);
// Fallback: when footerNoteEn is empty, English uses the German note.
console.log("• Footer note fallback (en → de when EN empty)");
const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;
+126
View File
@@ -0,0 +1,126 @@
// Custom production server for the React Router build.
//
// Replaces `react-router-serve` so we can:
// - prefix every console line with an ISO timestamp,
// - use a richer morgan format (with timestamp + content-length),
// - skip access logs for successful /healthz probes (they would otherwise
// drown out everything useful — Docker/Caddy poll them every couple of
// seconds).
//
// Behaviour is otherwise intentionally identical to `@react-router/serve`'s
// CLI (compression, /assets immutable cache, public/ static, SIGTERM/SIGINT
// handling).
import { createRequestHandler } from "@react-router/express";
import compression from "compression";
import express from "express";
import morgan from "morgan";
import rateLimit from "express-rate-limit";
// ---------------------------------------------------------------------------
// Console timestamps — patch BEFORE anything else logs.
// ---------------------------------------------------------------------------
const ts = () => `[${new Date().toISOString()}]`;
for (const level of ["log", "info", "warn", "error", "debug"]) {
const original = console[level].bind(console);
console[level] = (...args) => original(ts(), ...args);
}
const PORT = Number(process.env.PORT || 3000);
const HOST = process.env.HOST;
const buildModule = await import("./build/server/index.js");
const build = buildModule.default ?? buildModule;
const app = express();
app.disable("x-powered-by");
// The app runs behind a single reverse proxy (Caddy) that sets
// X-Forwarded-For. Trust ONLY the first proxy hop so req.ip reflects the real
// client IP for rate limiting, without trusting arbitrary client-supplied
// forwarding headers (which `trust proxy: true` would).
app.set("trust proxy", 1);
app.use(compression());
// Static assets emitted by the React Router build.
app.use(
"/assets",
express.static("build/client/assets", { immutable: true, maxAge: "1y" }),
);
app.use(express.static("build/client", { maxAge: "1h" }));
app.use(express.static("public", { maxAge: "1h" }));
// Access log: ISO timestamp + standard request info; suppress healthy
// /healthz polls so real traffic stays visible.
morgan.token("isotime", () => new Date().toISOString());
// Redacted URL: strip sensitive query parameters (the GiroCode HMAC `sig`
// and any `token`) from the logged URL so signed-URL secrets / bearer tokens
// never land in access logs. Other query params are preserved.
morgan.token("safeurl", (req) => {
const raw = req.originalUrl || req.url || "";
const qIdx = raw.indexOf("?");
if (qIdx === -1) return raw;
const path = raw.slice(0, qIdx);
const params = new URLSearchParams(raw.slice(qIdx + 1));
for (const key of ["sig", "token"]) {
if (params.has(key)) params.set(key, "REDACTED");
}
const qs = params.toString();
return qs ? `${path}?${qs}` : path;
});
app.use(
morgan(
":isotime :method :safeurl :status :res[content-length] - :response-time ms",
{
skip: (req, res) => req.url === "/healthz" && res.statusCode < 400,
},
),
);
// Per-IP rate limiting for the public, unauthenticated-at-the-edge API
// surface only (/api/public/*). Shopify webhooks (/webhooks/*) and the
// embedded admin are intentionally NOT rate limited here — webhooks can burst
// legitimately and are already HMAC-verified.
const publicApiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
limit: 60, // 60 requests / minute / IP
standardHeaders: true, // RateLimit-* headers
legacyHeaders: false,
message: { error: "rate-limited" },
});
app.use("/api/public", publicApiLimiter);
app.all(
"*",
createRequestHandler({ build, mode: process.env.NODE_ENV }),
);
const onListen = () => {
console.log(
`[server] listening on http://${HOST ?? "localhost"}:${PORT}`,
);
};
const server = HOST
? app.listen(PORT, HOST, onListen)
: app.listen(PORT, onListen);
for (const signal of ["SIGTERM", "SIGINT"]) {
process.once(signal, async () => {
console.log(`[server] received ${signal}, shutting down`);
// Stop accepting new connections.
server.close((err) => {
if (err) console.error("[server] close error:", err);
});
// Drain in-flight background webhook work (PDF render / SMTP send) before
// exiting so a container stop doesn't lose invoice work mid-send. The
// background queue exposes this bridge because server.js loads only the
// bundled build and can't import the module directly.
try {
const drain = globalThis.__linumiqWebhookDrain;
if (typeof drain === "function") await drain();
} catch (err) {
console.error("[server] webhook drain error:", err);
}
process.exit(0);
});
}
+46
View File
@@ -0,0 +1,46 @@
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
client_id = "fbc263e6cc28e8de031878d2a0f17444"
application_url = "https://invoice-app-dev.linumiq.com"
embedded = true
name = "linumiq-invoice-dev"
[access_scopes]
# Read orders + customers + companies (B2B) for invoice data.
# read_files / write_files for the generated PDFs uploaded to Shopify Files.
# write_orders required to write the order metafield linking the latest PDF.
# read_all_orders allows access to orders older than 60 days for backfill.
scopes = "read_orders,write_orders,read_all_orders,read_draft_orders,read_customers,read_companies,read_files,write_files"
[webhooks]
api_version = "2026-07"
[[webhooks.subscriptions]]
uri = "/webhooks/app/uninstalled"
topics = [ "app/uninstalled" ]
[[webhooks.subscriptions]]
uri = "/webhooks/app/scopes_update"
topics = [ "app/scopes_update" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/create"
topics = [ "orders/create" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/updated"
topics = [ "orders/updated" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/fulfilled"
topics = [ "orders/fulfilled" ]
[auth]
redirect_urls = [
"https://invoice-app-dev.linumiq.com/auth/callback",
"https://invoice-app-dev.linumiq.com/auth/shopify/callback",
"https://invoice-app-dev.linumiq.com/api/auth/callback",
]
[build]
automatically_update_urls_on_dev = true
+46
View File
@@ -0,0 +1,46 @@
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
client_id = "c5cb7360d17d3a4643ece1eb5f4ca417"
application_url = "https://invoice-app.linumiq.com"
embedded = true
name = "linumiq-invoice"
[access_scopes]
# Read orders + customers + companies (B2B) for invoice data.
# read_files / write_files for the generated PDFs uploaded to Shopify Files.
# write_orders required to write the order metafield linking the latest PDF.
# read_all_orders allows access to orders older than 60 days for backfill.
scopes = "read_orders,write_orders,read_all_orders,read_draft_orders,read_customers,read_companies,read_files,write_files"
[webhooks]
api_version = "2026-07"
[[webhooks.subscriptions]]
uri = "/webhooks/app/uninstalled"
topics = [ "app/uninstalled" ]
[[webhooks.subscriptions]]
uri = "/webhooks/app/scopes_update"
topics = [ "app/scopes_update" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/create"
topics = [ "orders/create" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/updated"
topics = [ "orders/updated" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/fulfilled"
topics = [ "orders/fulfilled" ]
[auth]
redirect_urls = [
"https://invoice-app.linumiq.com/auth/callback",
"https://invoice-app.linumiq.com/auth/shopify/callback",
"https://invoice-app.linumiq.com/api/auth/callback",
]
[build]
automatically_update_urls_on_dev = false
+5 -1
View File
@@ -10,7 +10,7 @@ name = "linumiq-invoice"
# read_files / write_files for the generated PDFs uploaded to Shopify Files.
# write_orders required to write the order metafield linking the latest PDF.
# read_all_orders allows access to orders older than 60 days for backfill.
scopes = "read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files"
scopes = "read_orders,write_orders,read_all_orders,read_draft_orders,read_customers,read_companies,read_files,write_files"
[webhooks]
api_version = "2026-07"
@@ -31,6 +31,10 @@ api_version = "2026-07"
uri = "/webhooks/orders/updated"
topics = [ "orders/updated" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/fulfilled"
topics = [ "orders/fulfilled" ]
[auth]
redirect_urls = [
"https://invoice-app.linumiq.com/auth/callback",
+145
View File
@@ -0,0 +1,145 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import { pickLanguage } from "../app/services/invoice/i18n";
import { reserveWebhook, type DedupeDeps } from "../app/services/webhooks/dedupe.server";
describe("pickLanguage", () => {
it("returns 'de' only for explicit German locales", () => {
assert.equal(pickLanguage("de"), "de");
assert.equal(pickLanguage("de-AT"), "de");
assert.equal(pickLanguage("de-DE"), "de");
assert.equal(pickLanguage("de_CH"), "de");
assert.equal(pickLanguage("DE-AT"), "de"); // case-insensitive
});
it("returns 'en' for non-German locales (regression: it/fr/es no longer fall back to de)", () => {
assert.equal(pickLanguage("en"), "en");
assert.equal(pickLanguage("en-US"), "en");
assert.equal(pickLanguage("it"), "en");
assert.equal(pickLanguage("it-IT"), "en");
assert.equal(pickLanguage("fr"), "en");
assert.equal(pickLanguage("fr-FR"), "en");
assert.equal(pickLanguage("es"), "en");
assert.equal(pickLanguage("hu-HU"), "en");
});
it("falls back to 'de' for empty/unknown input so the per-shop default chain still works", () => {
assert.equal(pickLanguage(undefined), "de");
assert.equal(pickLanguage(null), "de");
assert.equal(pickLanguage(""), "de");
});
});
function makeRequest(headers: Record<string, string> = {}): Request {
return new Request("https://example.com/webhooks/test", {
method: "POST",
headers,
});
}
type ExistingRow = { webhookId: string; status: string; receivedAt: Date } | null;
/**
* Build a DedupeDeps stub.
* - "ok" : create() succeeds (fresh reservation).
* - "p2002" : create() conflicts; findUnique() returns `existing`.
* - "boom" : create() throws a non-P2002 error (fail-open).
*/
function makeDeps(
behaviour: "ok" | "p2002" | "boom",
existing: ExistingRow = null,
): DedupeDeps & { calls: { commit: number; release: number; update: number } } {
const calls = { commit: 0, release: 0, update: 0 };
return {
calls,
db: {
processedWebhook: {
create: async () => {
if (behaviour === "ok") return {};
if (behaviour === "p2002") {
const err = new Error("Unique constraint failed") as Error & { code?: string };
err.code = "P2002";
throw err;
}
throw new Error("DB unavailable");
},
findUnique: async () => existing,
update: async () => {
calls.update += 1;
calls.commit += 1;
return {};
},
delete: async () => {
calls.release += 1;
return {};
},
},
},
};
}
describe("reserveWebhook", () => {
it("returns a reservation on first delivery (insert succeeds)", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok"));
assert.ok(res, "expected a reservation");
assert.equal(res!.webhookId, "abc-123");
});
it("returns null for an already-processed (done) delivery", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
const deps = makeDeps("p2002", {
webhookId: "abc-123",
status: "done",
receivedAt: new Date(),
});
assert.equal(await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps), null);
});
it("returns null for a fresh in-flight (processing) delivery", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
const deps = makeDeps("p2002", {
webhookId: "abc-123",
status: "processing",
receivedAt: new Date(), // fresh lease
});
assert.equal(await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps), null);
});
it("reclaims a stale (crashed) processing reservation", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
const deps = makeDeps("p2002", {
webhookId: "abc-123",
status: "processing",
receivedAt: new Date(Date.now() - 10 * 60 * 1000), // 10 min ago > 5 min lease
});
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps);
assert.ok(res, "expected to reclaim the stale reservation");
assert.equal(deps.calls.update, 1, "stale reclaim should renew the lease via update()");
});
it("commit() flips the row to done; release() deletes it", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
const deps = makeDeps("ok");
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps);
await res!.commit();
assert.equal(deps.calls.commit, 1);
await res!.release();
assert.equal(deps.calls.release, 1);
});
it("fails open (returns a no-op reservation) when the dedupe table errors", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("boom"));
assert.ok(res, "fail-open must still process — never silently drop a webhook");
assert.equal(res!.webhookId, "abc-123");
});
it("returns a no-op reservation when the X-Shopify-Webhook-Id header is missing", async () => {
const req = makeRequest();
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok"));
assert.ok(res, "missing id => process without dedupe");
assert.equal(res!.webhookId, null);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
buildRepresentativeInvoiceMap,
type RepresentativeInvoiceRow,
} from "../app/services/invoice/representativeInvoice";
const d = (iso: string) => new Date(iso);
describe("buildRepresentativeInvoiceMap", () => {
it("prefers the active invoice even when a cancelled row has a higher version (regression: order #1032)", () => {
// Rows as returned by Prisma: version desc, then createdAt desc.
// This mirrors the real #1032 state: a cancelled v8 sorted ahead of the
// live, issued v7. The naive "first row wins" picked v8 and the order
// rendered as if it had no invoice.
const rows: RepresentativeInvoiceRow[] = [
{ orderId: "gid://shopify/Order/1032", version: 8, cancelledAt: d("2026-05-31T08:59:05Z") },
{ orderId: "gid://shopify/Order/1032", version: 7, cancelledAt: null },
{ orderId: "gid://shopify/Order/1032", version: 1, cancelledAt: d("2026-05-15T13:05:14Z") },
];
const map = buildRepresentativeInvoiceMap(rows);
const rep = map.get("gid://shopify/Order/1032");
assert.ok(rep, "expected a representative invoice");
assert.equal(rep!.version, 7, "should select the active v7, not the cancelled v8");
assert.equal(rep!.cancelledAt, null);
});
it("falls back to the latest cancelled invoice when none are active", () => {
const rows: RepresentativeInvoiceRow[] = [
{ orderId: "gid://shopify/Order/1", version: 3, cancelledAt: d("2026-05-31T10:00:00Z") },
{ orderId: "gid://shopify/Order/1", version: 1, cancelledAt: d("2026-05-15T10:00:00Z") },
];
const rep = buildRepresentativeInvoiceMap(rows).get("gid://shopify/Order/1");
assert.ok(rep);
assert.equal(rep!.version, 3, "highest-version cancelled wins when nothing is active");
});
it("keeps the highest-version active invoice when multiple are active", () => {
const rows: RepresentativeInvoiceRow[] = [
{ orderId: "gid://shopify/Order/2", version: 5, cancelledAt: null },
{ orderId: "gid://shopify/Order/2", version: 4, cancelledAt: null },
];
const rep = buildRepresentativeInvoiceMap(rows).get("gid://shopify/Order/2");
assert.equal(rep!.version, 5);
});
it("handles multiple orders independently", () => {
const rows: RepresentativeInvoiceRow[] = [
{ orderId: "A", version: 9, cancelledAt: d("2026-05-31T00:00:00Z") },
{ orderId: "A", version: 2, cancelledAt: null },
{ orderId: "B", version: 1, cancelledAt: null },
];
const map = buildRepresentativeInvoiceMap(rows);
assert.equal(map.get("A")!.version, 2);
assert.equal(map.get("B")!.version, 1);
});
});