Commit Graph

36 Commits

Author SHA1 Message Date
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 8a40bcbee6 fix(invoice): use shippingLine.deliveryCategory as primary pickup signal
Order 1032 on dev still rendered as 'Versandart: Lager Graz' because the
shipping line's title/code/source contained no 'pickup' keyword — only
`shippingLine.deliveryCategory == "pickup"` flagged it as a pickup.

`shippingLine.deliveryCategory` only requires `read_orders` (already
granted), so query and use it as the primary signal. Keep the regex on
title/code/source/carrier as a fallback for custom rates without a proper
pickup category.
2026-05-15 15:12:59 +02:00
Gerhard Scheikl f16ef4e103 fix(invoice): drop fulfillmentOrders query (scope-denied), keep shippingLine pickup heuristic
Querying Order.fulfillmentOrders.deliveryMethod requires the
read_merchant_managed_fulfillment_orders scope (not read_orders, despite
what shopify.dev claims) and was failing with 'Access denied for
fulfillmentOrders field' against real stores.

Adding that scope would force every install to re-grant permissions, so
instead we rely on shippingLine alone:

- Shopify Local Pickup app: shippingLine.code = 'Pickup' (caught by the
  regex) and shippingLine.title is the chosen location name itself   (e.g. 'Lager Graz') \u2014 perfect as pickupLocationName.
- Custom-rate pickup ('Abholung im Lager'): regex matches title/code,
  title is used as the location hint.

Removes RawDeliveryMethod, the deliveryMethods field on RawOrderForInvoice,
and the fulfillmentOrders edges from RawAdminResponse.
2026-05-15 15:04:16 +02:00
Gerhard Scheikl d742e75419 fix(invoice): detect pickup via DeliveryMethodType and show 'Abholort: <location>' meta row
- Use Order.fulfillmentOrders.deliveryMethod.methodType === 'PICK_UP' as the
  primary signal (Shopify Local Pickup app exposes this reliably; the
  shippingLine title is just the location name with no 'pickup' keyword).
  Keep the legacy shippingLine string heuristic as a fallback for custom
  shipping rates merchants name 'Abholung'/'Pickup'.
- Surface assignedLocation.name as pickupLocationName on the view model.
- Replace the 'Versandart: <location name>' row with 'Abholort: <location>'
  (DE) / 'Pick-up location: <location>' (EN); falls back to plain
  'Abholung'/'Pick-up' when the location name is unavailable.
2026-05-15 14:46:55 +02:00
Gerhard Scheikl 415a9dd462 feat(invoice): per-line + cart discounts, fulfillment delivery date, pickup label, header layout refresh
- discounts: read discountedUnitPriceSet (per-line) and discountCode/discountCodes
  (order-level) from Shopify; render discounted unit price with strikethrough
  original on the invoice line and add a 'Rabattcode'/'Discount code' meta row
  when codes were used.
- delivery date: pick the latest fulfillment.createdAt for §11 UStG instead of
  hard-coding processedAt; fall back to invoice date when unfulfilled.
- pickup: detect Shopify Local Pickup (and 'Abholung'/'Pickup' custom rates) via
  shippingLine.source/code/title; suppress the pickup-location 'shipping address'
  block and render localized 'Abholung'/'Pick-up' as the shipping method.
- layout: move the company logo to the top-left and the meta block to the
  top-right, putting recipient (and any separate delivery address) on its own
  row below; drop the standalone invoice-/order-number meta rows and surface
  them inside the title (e.g. 'Rechnung Nr. RE-1004 · Bestellnummer: #1004') to
  recover vertical space.
- tests: smoke fixtures cover discount, pickup, and fulfillment-date variants
  without disturbing the AT B2B totals.
2026-05-15 13:59:08 +02:00
Gerhard Scheikl 8780b4a68a feat(invoice): add Shopify order #, shipping address/method/cost and tracking
- Query Order.shippingLine and Order.fulfillments.trackingInfo from Admin GraphQL.
- Surface orderName (#1004) so customers recognise their order alongside the sequential invoice number.
- Render shipping cost as a synthetic line item (folds into the VAT breakdown).
- Show shipping method (Versandart / Shipping method) and tracking numbers (clickable when URL present) in the meta block.
- Render a separate delivery-address block when the shipping address differs from billing.
- DE strings stay informal (Versandart / Sendungsnummer / Lieferadresse / Versand).
2026-05-15 13:41:53 +02:00
Gerhard Scheikl 55a0dd03f2 feat(invoice): informal German tone + show payment method and status
- i18n.de: switch Sie/Ihren to du/dein for salutation, thank-you line,
  customer-VAT label and payment-terms paragraph. Closing line was
  already informal.
- i18n: add paymentMethodLabel/paymentStatusLabel + per-status labels
  (paid/unpaid/partial/refunded) for both DE and EN, plus
  derivePaymentStatus helper that condenses Shopify's
  displayFinancialStatus (PAID, PARTIALLY_PAID, REFUNDED, …) into a
  4-value enum.
- loadOrderForInvoice: query Order.paymentGatewayNames and propagate it
  on the raw view-model.
- composeInvoice + types: expose paymentStatus + paymentGatewayNames on
  InvoiceViewModel (filtered/trimmed). loadDraftOrderForOffer keeps
  paymentGatewayNames empty (drafts have no gateway yet).
- InvoiceDocument: render two new meta rows on real invoices —
  'Zahlart / Payment method' (joined, prettified gateway names) and
  'Zahlstatus / Payment status' (translated label). Storno + offer kinds
  intentionally omit them.
- scripts/render-sample.ts: extend smoke checks to assert the informal
  DE wording, the new payment-method/status rows and the
  paymentStatus/paymentGatewayNames composer outputs.
2026-05-15 11:26:26 +02:00
Gerhard Scheikl 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 3fb8600402 fix(thank-you): serve GiroCode as signed PNG URL instead of data URL 2026-05-09 21:14:47 +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 5061dbb3d5 remove unwanted characters in file name 2026-05-09 19:48:18 +02:00
Gerhard Scheikl 6224597497 feat(offers): generate Angebot/Offer PDFs for draft orders 2026-05-09 19:26:33 +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 d454843856 fix(email): use invoice language so email matches PDF attachment 2026-05-09 17:14:20 +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 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 093db30b6c feat(email): always BCC shop@linumiq.com on outgoing invoice mails 2026-05-08 22:44:21 +02:00
Gerhard Scheikl 58cfc30cd7 fix(build): extract STORED_LOGO_SENTINEL to non-server module
Vite/React Router refused to bundle the client because
app/routes/app.settings.tsx imported the constant from a .server file
and used it inside the route component (not just loader/action), so it
could not be tree-shaken out.

Move the sentinel to logoCache.constants.ts, re-export from
logoCache.server.ts for backwards compatibility, and import the constant
from constants in the route while keeping the server-only functions
(deleteStoredLogo, storeUploadedLogo) imported from .server (they are
only referenced inside the action and get tree-shaken correctly).
2026-05-08 14:41:48 +02:00
Gerhard Scheikl 770c6fd16a many updates :-) 2026-05-08 10:40:19 +02:00
Gerhard Scheikl 5b2aa5d62b first version 2026-04-28 21:56:11 +02:00