@hartl-services/medusa-base 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.medusa/server/src/admin/index.js +117 -1
  2. package/.medusa/server/src/admin/index.mjs +118 -2
  3. package/.medusa/server/src/documents/plugin-features.js +15 -1
  4. package/.medusa/server/src/documents/templates/financial-document.js +69 -51
  5. package/.medusa/server/src/documents/templates/format.js +44 -2
  6. package/.medusa/server/src/documents/templates/html-factory.js +7 -5
  7. package/.medusa/server/src/documents/templates/html-utils.js +12 -2
  8. package/.medusa/server/src/documents/templates/layout.js +115 -70
  9. package/.medusa/server/src/documents/templates/packing-list.js +10 -8
  10. package/.medusa/server/src/documents/utils/document-merchant.js +5 -5
  11. package/.medusa/server/src/documents/utils/document-render-settings.js +59 -0
  12. package/.medusa/server/src/email/application/handle-invite-event.js +8 -2
  13. package/.medusa/server/src/email/application/handle-password-reset-event.js +16 -4
  14. package/.medusa/server/src/email/domain/order-summary.js +103 -0
  15. package/.medusa/server/src/email/domain/refund-summary.js +43 -0
  16. package/.medusa/server/src/email/domain/return-summary.js +118 -0
  17. package/.medusa/server/src/email/domain/shipment-summary.js +74 -0
  18. package/.medusa/server/src/email/plugin-features.js +9 -1
  19. package/.medusa/server/src/email/rendering/default-templates.js +693 -65
  20. package/.medusa/server/src/email/rendering/load-email-shop-details.js +36 -0
  21. package/.medusa/server/src/email/rendering/render-email.js +16 -3
  22. package/.medusa/server/src/email/rendering/resolve-shop-configuration.js +20 -1
  23. package/.medusa/server/src/email/types/template-data.js +155 -2
  24. package/.medusa/server/src/modules/documents/domain/merchant.js +2 -33
  25. package/.medusa/server/src/modules/documents/domain/options.js +1 -7
  26. package/.medusa/server/src/modules/plugin-features/domain/plugin-settings.js +4 -3
  27. package/.medusa/server/src/public/descriptor.js +3 -1
  28. package/.medusa/server/src/public/shop-master-data.js +7 -7
  29. package/.medusa/server/src/shop-config/build-food-shop-config.js +6 -6
  30. package/.medusa/server/src/shop-config/options.js +5 -15
  31. package/.medusa/server/src/workflows/documents/issue-cancellation-after-refund.js +2 -3
  32. package/.medusa/server/src/workflows/documents/issue-document.js +6 -5
  33. package/.medusa/server/src/workflows/documents/issue-packing-list.js +9 -3
  34. package/.medusa/server/src/workflows/email/dispatch-email-message.js +4 -2
  35. package/.medusa/server/src/workflows/email/prepare-order-canceled-email.js +42 -4
  36. package/.medusa/server/src/workflows/email/prepare-order-received-email.js +31 -5
  37. package/.medusa/server/src/workflows/email/prepare-refund-email.js +25 -1
  38. package/.medusa/server/src/workflows/email/prepare-return-email.js +64 -3
  39. package/.medusa/server/src/workflows/email/prepare-shipment-email.js +36 -3
  40. package/README.md +180 -43
  41. package/dist/admin-components/features/plugin-features/components/ImageSettingInput.d.ts +15 -0
  42. package/dist/admin-components/features/plugin-features/components/ImageSettingInput.js +42 -0
  43. package/dist/admin-components/features/plugin-features/components/PluginSettingsForm.js +4 -2
  44. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatureEnabled.d.ts +1 -1
  45. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatures.d.ts +4 -4
  46. package/dist/admin-components/features/plugin-features/hooks/usePluginSettingsForm.d.ts +1 -1
  47. package/dist/admin-components/features/plugin-features/lib/image-upload.d.ts +6 -0
  48. package/dist/admin-components/features/plugin-features/lib/image-upload.js +17 -0
  49. package/dist/admin-components/features/plugin-features/lib/plugin-features-admin-client.d.ts +4 -4
  50. package/dist/admin-components/i18n/texts.d.ts +6 -0
  51. package/dist/admin-components/i18n/texts.js +6 -0
  52. package/dist/documents/templates/financial-document.d.ts +3 -3
  53. package/dist/documents/templates/financial-document.js +68 -50
  54. package/dist/documents/templates/format.d.ts +14 -0
  55. package/dist/documents/templates/format.js +43 -1
  56. package/dist/documents/templates/html-factory.d.ts +6 -2
  57. package/dist/documents/templates/html-factory.js +6 -4
  58. package/dist/documents/templates/html-utils.d.ts +6 -0
  59. package/dist/documents/templates/html-utils.js +11 -1
  60. package/dist/documents/templates/layout.d.ts +21 -3
  61. package/dist/documents/templates/layout.js +114 -69
  62. package/dist/documents/templates/packing-list.js +9 -7
  63. package/dist/documents/types/documents.d.ts +11 -16
  64. package/dist/documents/types/html-factory.d.ts +1 -0
  65. package/dist/email/types/shop-configuration.d.ts +25 -0
  66. package/dist/email/types/template-data.d.ts +509 -0
  67. package/dist/email/types/template-data.js +154 -1
  68. package/dist/modules/documents/domain/options.d.ts +1 -9
  69. package/dist/modules/documents/domain/options.js +0 -6
  70. package/dist/public/descriptor.js +2 -0
  71. package/dist/public/shop-master-data.d.ts +5 -6
  72. package/dist/public/shop-master-data.js +6 -6
  73. package/dist/public/types.d.ts +2 -1
  74. package/dist/shop-config/build-food-shop-config.js +5 -5
  75. package/dist/shop-config/options.d.ts +13 -8
  76. package/dist/shop-config/options.js +4 -14
  77. package/package.json +2 -2
  78. package/dist/modules/documents/domain/merchant.d.ts +0 -22
  79. package/dist/modules/documents/domain/merchant.js +0 -85
package/README.md CHANGED
@@ -68,7 +68,7 @@ plugins: [
68
68
  // E-Mail, Documents and the /files gateway: see the sections below
69
69
  // (or let the Shop-Config-Preset fill them in).
70
70
  // email: { jwtSecret, shop },
71
- // documents: { merchant, numbering, pdf, htmlFactory },
71
+ // documents: { numbering, pdf, htmlFactory },
72
72
  // files: { gateway },
73
73
  },
74
74
  },
@@ -83,7 +83,7 @@ every module of the plugin; each module reads only its own namespace:
83
83
  |---|---|---|
84
84
  | `settingsSecret` | `plugin_features` module | Key material for stored secret settings. Set it from an environment variable of the shop; unset or empty, the base uses `projectConfig.http.cookieSecret`. |
85
85
  | `email` | `email` module | `{ jwtSecret, shop }`: branding, URLs and the JWT secret of the e-mail links (see "E-Mail"). Without `email.shop` the module boots but sends nothing. |
86
- | `documents` | `documents` module | `{ merchant?, numbering?, pdf?, htmlFactory? }` (see "Dokumente"). Defaults: numbering time zone `Europe/Vienna`, PDF executable `google-chrome`. |
86
+ | `documents` | `documents` module | `{ numbering?, pdf?, htmlFactory? }` (see "Dokumente"). Defaults: numbering time zone `Europe/Vienna`, PDF executable `google-chrome`. |
87
87
  | `files` | `/files` routes | `{ gateway: { signingSecret } }`, returned by `createS3CacheStorageConfig` (see "Dateispeicher (S3)"). |
88
88
 
89
89
  `options: {}` is valid: the base then only provides the feature toggles,
@@ -318,7 +318,10 @@ class DemoModuleService extends MedusaService({ /* models */ }) {
318
318
 
319
319
  - Types: `string`, `url` (http/https), `number` (finite, optional inclusive
320
320
  `min`/`max`), `boolean`, `secret`, `select` (`options: [{ value, label }]`,
321
- non-empty, unique values; the stored value must be one of them). Keys
321
+ non-empty, unique values; the stored value must be one of them), `image`
322
+ (the http(s) URL of a PNG/JPEG of at most 2 MiB that the Admin uploads
323
+ through Medusa's `/admin/uploads` into the public File Module storage and
324
+ previews; replacing or removing it leaves the old file in storage). Keys
322
325
  follow the feature key format and are unique. A `secret` has no default;
323
326
  any other default must match its type, options and bounds. `options` exist
324
327
  only on `select`, `min`/`max` only on `number`. `required` defaults to
@@ -477,15 +480,13 @@ settings like any plugin's:
477
480
  | `iban`, `bic` | string | no |
478
481
  | `company_register_number` (Firmenbuchnummer), `company_register_court` (Firmenbuchgericht) | string | no |
479
482
 
480
- Server-side code reads them with a plugin's own medusa-config values as
481
- fallback (stored in the Admin wins per key):
483
+ Server-side code reads them exactly as stored in the Admin; there is no
484
+ medusa-config fallback:
482
485
 
483
486
  ```ts
484
487
  import { getShopMasterData } from "@hartl-services/medusa-base"
485
488
 
486
- const { values, configured, missing } = await getShopMasterData(container, {
487
- legal_name: options.merchant?.legalName,
488
- })
489
+ const { values, configured, missing } = await getShopMasterData(container)
489
490
  if (!configured) {
490
491
  throw new MedusaError(
491
492
  MedusaError.Types.UNEXPECTED_STATE,
@@ -891,6 +892,10 @@ meantime, such as a password-reset link, is not sent). A toggle read
891
892
  failure fails the subscriber with its fixed sentinel (the Event Bus retries
892
893
  it) or fails the job run.
893
894
 
895
+ The plugin declares one setting, edited in the Einstellungen section of its
896
+ Admin page: `logo` (type `image`, optional), the PNG/JPG shown on top of every
897
+ mail. It is not a Shop-Stammdaten field; Documents has its own `logo`.
898
+
894
899
  Admin surfaces of a disabled feature are hidden. The E-Mail settings page
895
900
  keeps `PluginFeatureSections` visible (so the plugin can be switched back on)
896
901
  and gates the Terms PDF versions and mail log sections below it on the
@@ -1060,20 +1065,130 @@ has at most 35 characters. Email runtime-validates every contract before use.
1060
1065
  - Password-reset messages cannot be retried by the generic retry job or resent
1061
1066
  by an Admin.
1062
1067
  - Unknown transport outcomes are never automatically resent.
1063
- - Shop branding is only the configured `displayName`; a shop overrides the
1064
- default layout or single renderers in its own configuration, not in the
1065
- plugin.
1068
+ - Shop branding is the configured `displayName` plus the `logo` setting of
1069
+ the E-Mail plugin and the company details of the Shop-Stammdaten; a shop overrides the default layout or
1070
+ single renderers in its own configuration, not in the plugin.
1066
1071
 
1067
1072
  Read this section and the central design before changing Email domain behavior.
1068
1073
 
1069
1074
  ### Shop configuration and default templates
1070
1075
 
1071
- The E-Mail module ships a neutral German layout and one renderer per accepted kind.
1072
- Both show the shop only as the configured `displayName` (escaped in HTML; the
1073
- layout header and footer, the password-reset and invite subjects). A shop
1076
+ The E-Mail module ships a neutral German layout in grey tones (table-based,
1077
+ inline styles) and one renderer per accepted kind. The layout shows, from top
1078
+ to bottom: the `logo` centered (an `image` setting of the E-Mail plugin,
1079
+ edited on its Admin page; without one, the configured `displayName`), a white card with the subject as heading and the
1080
+ renderer's body, a contact line ("Fragen? Dann schreib uns an … oder ruf uns
1081
+ an unter …", only with `email`/`phone`), and a footer with the company details
1082
+ (`legal_name` or `displayName`, address, UID, Firmenbuchnummer and
1083
+ Firmenbuchgericht, website) plus "Diese E-Mail wurde automatisch versendet.".
1084
+ Missing master data fields are left out. Email reads the logo and the
1085
+ Shop-Stammdaten when it sends a mail, so a change applies to the next mail; if
1086
+ a read fails the mail is sent without that part (logo or company details)
1087
+ instead of failing. Besides
1088
+ the layout, `displayName` appears in the password-reset and invite subjects.
1089
+ A custom `layout` receives the same data as `shop` (`EmailShopDetails`, every
1090
+ field optional) next to `bodyHtml`, `preheader` and `subject`. A shop
1074
1091
  therefore needs only `displayName`, `affiliatePortalUrl`, `inviteUrl`,
1075
1092
  `passwordResetUrl` and `orderNotificationEmail`.
1076
1093
 
1094
+ The order confirmation (`order.received`) receives an `OrderSummary` as
1095
+ `summary`: the display ID, the items with quantity and gross amount, the
1096
+ shipping method names, subtotal, shipping, discount, included tax and total,
1097
+ plus the billing and shipping address. Email freezes it when the order is
1098
+ placed, so a resend shows the order as it was placed. `summary` is absent for
1099
+ messages stored before the summary existed; a custom renderer must handle
1100
+ that case.
1101
+
1102
+ The shop's order notification (`order.received.admin`) receives the same
1103
+ `summary` (with the order's `email` and `placed_at`). Its subject names the
1104
+ order number and the customer ("Neue Bestellung #1042 von Ada Beispiel"),
1105
+ an info box lists number, customer, e-mail and order time, followed by an
1106
+ "Im Admin öffnen" button and the items, totals and addresses. Two optional
1107
+ `ShopEmailConfiguration` fields drive it: `adminOrderUrl({ orderId })` (the
1108
+ button is left out without it) and `timeZone` (IANA; the order time is left
1109
+ out without it). `buildFoodShopConfig` sets both (`<MEDUSA_BACKEND_URL>/app/
1110
+ orders/<id>` and its `timeZone` option). The layout receives `audience`
1111
+ (`"shop"` for this kind and `user.invite`, `"customer"` for all others) and
1112
+ shows the contact line only to customers.
1113
+
1114
+ The shipping confirmation (`order.shipped`, one per fulfillment) receives a
1115
+ `ShipmentSummary` as `shipment`, frozen when the shipment fact arrives: the
1116
+ display ID, the shipping option name, the tracking numbers and URLs entered
1117
+ at shipping, the parcel's items (title and variant of the order line,
1118
+ shipped quantity), the delivery address and `more_to_follow` (some ordered
1119
+ quantity is not shipped yet). `invoice_attached` is `true` when Documents chose
1120
+ this shipment for the invoice PDF. Its subject is "Deine Bestellung bei
1121
+ <displayName> ist unterwegs"; a single http(s) tracking URL becomes a
1122
+ "Sendung verfolgen" button, several become one link each. A fulfillment that
1123
+ cannot be read is confirmed without `shipment`, like a message stored before
1124
+ the snapshot existed.
1125
+
1126
+ The cancellation confirmation (`order.canceled`) receives the same
1127
+ `OrderSummary` as `summary` plus `canceled_at`, frozen when the cancellation
1128
+ fact arrives, and `cancellation_attached` (`true` when Documents attached at
1129
+ least one cancellation document). Its subject is "Deine Bestellung #1042 bei
1130
+ <displayName> wurde storniert"; an info box lists the order number and the
1131
+ cancellation date (left out without `timeZone`), followed by a fixed refund
1132
+ note and the canceled items and totals without addresses. An order that
1133
+ cannot be summarized is confirmed without `summary` and without its internal
1134
+ ID.
1135
+
1136
+ The return confirmation (`order.return_requested`) receives a `ReturnSummary`
1137
+ as `summary`, frozen when the return is requested: return and order numbers,
1138
+ the return shipping method, the items to send back (title and variant of the
1139
+ order line, quantity, return reason label) and the return address. It is
1140
+ the stock location's address, addressed to the location's company or else
1141
+ the master data's `legal_name`; without a location address it falls back to
1142
+ the master data's company address. Its subject is "Deine Rücksendung zur
1143
+ Bestellung #1042 bei <displayName>"; the mail asks the customer to enclose
1144
+ the return number (`#R5`). A return that cannot be read is confirmed without
1145
+ `summary` and without internal IDs.
1146
+
1147
+ The return receipt (`order.return_received`, sent once the return is
1148
+ completely received) receives a `ReturnReceivedSummary` as `summary`: return
1149
+ and order numbers, `received_at` and the received items (title and variant
1150
+ of the order line, received quantity and the damaged part of it). Its subject
1151
+ is "Deine Rücksendung zur Bestellung #1042 bei <displayName> ist
1152
+ eingetroffen"; the arrival date needs `timeZone`, damaged units are noted per
1153
+ item, and a fixed sentence says that an eventual refund follows on the
1154
+ original payment method, without promising a separate mail (`order.refunded`
1155
+ only exists with Documents).
1156
+
1157
+ The refund confirmation (`order.refunded`, one per non-cancellation refund,
1158
+ Documents only) receives a `RefundSummary` as `summary`, frozen when the
1159
+ Documents refund fact arrives and read through the refund's payment: order
1160
+ number, refunded gross amount and currency, `refunded_at` and the Admin's
1161
+ refund reason label. `cancellation_attached` is `true` when Documents attached
1162
+ a cancellation document. Its subject is "Deine Erstattung zur Bestellung #1042
1163
+ bei <displayName>"; the info box shows number, amount, date (needs
1164
+ `timeZone`) and reason, followed by a fixed note on bank processing times.
1165
+ Without `summary` the mail keeps the intro and the attachment note.
1166
+
1167
+ The password reset (`auth.password_reset`) has the subject "Neues Passwort
1168
+ für dein Kundenkonto bei <displayName>", a "Neues Passwort festlegen" button,
1169
+ the link as copyable text below it, the link's lifetime ("Der Link ist 15
1170
+ Minuten gültig.", computed from the token's `exp` − `iat` and left out
1171
+ without `iat`) and a note that the mail can be ignored. The lifetime is
1172
+ passed as the ephemeral `valid_minutes` next to `reset_url` and is never
1173
+ persisted.
1174
+
1175
+ The affiliate reminder (`affiliate.consignment_report_due`) greets the
1176
+ affiliate by `affiliate_display_name`, names the report month ("September
1177
+ 2026", Austrian month names) and the due date ("10.10.2026") as calendar
1178
+ values formatted in UTC so no time zone shifts them, explains that the counted
1179
+ stock determines the billed sales, links `affiliatePortalUrl` as a "Zum
1180
+ Partnerbereich" button and says the mail can be ignored once the report is
1181
+ submitted. The button leads to a 404 while the affiliate plugin's
1182
+ `partner_portal` toggle is off.
1183
+
1184
+ The Admin invite (`user.invite`) has the subject "Einladung ins Admin-Team von
1185
+ <displayName>", an "Einladung annehmen" button with the link as copyable text,
1186
+ the invite's current expiry ("Die Einladung ist gültig bis 02.10.2026, 01:40
1187
+ Uhr.", needs `timeZone`; read from the invite at send time, so a resend shows
1188
+ its new expiry) and a note that the mail can be ignored. The expiry is passed
1189
+ as the ephemeral `expires_at` next to `invite_url`. The invite goes to the
1190
+ shop's team, so its layout audience is `"shop"` (no contact line).
1191
+
1077
1192
  - `layout` (optional) replaces the default layout completely.
1078
1193
  - `renderers` (optional, `Partial<EmailRendererMap>`) replaces only the listed
1079
1194
  kinds; every other kind keeps its default renderer.
@@ -1344,12 +1459,7 @@ export default defineConfig({
1344
1459
  },
1345
1460
  {
1346
1461
  resolve: "@hartl-services/medusa-tax-compliance",
1347
- options: {
1348
- merchant: {
1349
- countryCode: process.env.MERCHANT_COUNTRY_CODE!,
1350
- vatNumber: process.env.MERCHANT_VAT_NUMBER!,
1351
- },
1352
- },
1462
+ options: {},
1353
1463
  },
1354
1464
  ],
1355
1465
  });
@@ -1370,10 +1480,9 @@ Options of the `documents` namespace (all optional; `{}` is valid):
1370
1480
  rejected at startup when invalid.
1371
1481
  - `pdf.executablePath` — Chromium/Chrome executable path or command name.
1372
1482
  Defaults to `"google-chrome"`.
1373
- - `merchant` — optional fallback for the shop master data (see "Merchant
1374
- data" below), shape `DocumentMerchantOption`:
1375
- `{ countryCode, legalName, registeredAddress: { address1, address2?, city,
1376
- countryCode, postalCode }, vatNumber? }`.
1483
+
1484
+ The sender (company, address, UID, contact, bank) is not an option: it comes
1485
+ only from the Shop-Stammdaten (see "Merchant data" below).
1377
1486
 
1378
1487
  The `documents` module is always registered with the base (no `documentsEnabled`
1379
1488
  switch); the Admin switches it off at run time through the feature toggles
@@ -1384,8 +1493,9 @@ below. `medusa db:migrate` creates its tables.
1384
1493
  `@hartl-services/medusa-base/config` exports, next to the environment helpers,
1385
1494
  build-time values for `medusa-config.ts` and tests:
1386
1495
 
1387
- - `createDefaultDocumentHtmlFactory({ timeZone })` — the built-in factory, to
1388
- use as it is or to wrap (for example to add a header to the default HTML).
1496
+ - `createDefaultDocumentHtmlFactory({ timeZone, theme? })` — the built-in
1497
+ factory, to use as it is, with a shop theme (see "Layout, logo, payment
1498
+ terms and shop theme" below) or to wrap.
1389
1499
  - `DOCUMENTS_BUILD_PLACEHOLDERS` — `{ DOCUMENT_PDF_EXECUTABLE: "google-chrome" }`.
1390
1500
  `medusa build` evaluates `medusa-config.ts` without the runtime-only
1391
1501
  environment, so spread it under the real environment while building.
@@ -1393,6 +1503,44 @@ build-time values for `medusa-config.ts` and tests:
1393
1503
  The document types (`DocumentMerchantOption`, `DocumentHtmlFactory`,
1394
1504
  `DocumentRenderInput`, ...) come from `@hartl-services/medusa-base/types`.
1395
1505
 
1506
+ ### Layout, logo, payment terms and shop theme
1507
+
1508
+ The default markup prints an A4 letterhead: logo top left, document title and
1509
+ key data (number, order, date, customer UID for exempt B2B supplies) on the
1510
+ right, the billing address on the left, the item table with batch and
1511
+ best-before date per line, the totals below the price columns and a
1512
+ three-column footer (sender with UID and company register, contact, bank) on
1513
+ every page. Packing lists show only the sender in the footer. Every page
1514
+ carries "Seite x von y" bottom right in an 8 mm bottom page margin
1515
+ (`@page { @bottom-right }`; the page count exists only in page margin boxes).
1516
+
1517
+ Settings under Settings → "Hartl Services Plugins" → **Dokumente**, read on
1518
+ every render and passed as `input.settings` (`DocumentRenderSettings`). Like
1519
+ the merchant data they are not part of the snapshot: a regenerated document
1520
+ shows the current values.
1521
+
1522
+ - **Logo** (`logo`, PNG or JPG): each render downloads it (10 s timeout, at
1523
+ most 2 MiB) and passes it inline as `settings.logo_src` (`data:` URL), so
1524
+ Chromium never waits on the network. Without a logo, or when the download
1525
+ fails (logged as a warning), the document shows the merchant's legal name;
1526
+ a logo never blocks issuance.
1527
+ - **Zahlungskonditionen** (`payment_terms`, text): printed below the totals
1528
+ of invoices only (not on cancellations or credit notes); blank means none.
1529
+ Line breaks in the text are kept.
1530
+
1531
+ **Theme:** `createDefaultDocumentHtmlFactory({ timeZone, theme })` keeps the
1532
+ content and markup and changes only the look. `theme.stylesheet` is CSS
1533
+ appended after the default stylesheet (later rules win; the defaults use the
1534
+ custom properties `--accent`, `--text`, `--muted`, `--line`, `--table-head`,
1535
+ `--page-x`, `--page-top`, `--footer-height`). Put a watermark or other
1536
+ full-page image into `@page { background: ... / 210mm 297mm }`: it covers the
1537
+ whole sheet on every page, whereas Chromium clips fixed elements at the page
1538
+ margin and sizes an inline `<svg>` in them wrongly from the second page on.
1539
+ Restyle the page number with `@page { @bottom-right { ... } }`. Embed web
1540
+ fonts as `data:` URLs; the container's Chromium has only its system fonts. A
1541
+ shop passes its factory through the preset option `documentHtmlFactory` (see
1542
+ "Shop-Config-Preset").
1543
+
1396
1544
  ### Merchant data
1397
1545
 
1398
1546
  The legal sender printed on every document comes from the base plugin's shop
@@ -1401,8 +1549,8 @@ Each render reads it and passes it to the `htmlFactory` as `input.merchant`
1401
1549
  (`DocumentMerchant`: `legal_name`, `address_line1`, `address_line2`,
1402
1550
  `postal_code`, `city`, `country_code`, `vat_number`, `email`, `phone`,
1403
1551
  `website`, `iban`, `bic`, `company_register_number`,
1404
- `company_register_court`; unset optional fields are `null`). Per field, a
1405
- value stored in the Admin wins over the `merchant` option.
1552
+ `company_register_court`; unset optional fields are `null`). There is no
1553
+ medusa-config fallback: what is stored in the Admin is what is printed.
1406
1554
 
1407
1555
  - `merchant` is not part of the stored snapshot: a regenerated document
1408
1556
  prints the master data current at regeneration time.
@@ -1482,8 +1630,8 @@ a toggle.
1482
1630
  ### Shop integration
1483
1631
 
1484
1632
  The demo shop (`apps/backend`) gets everything from the preset (see
1485
- "Shop-Config-Preset"): `documents.merchant` is its `merchant` option,
1486
- `documents.numbering.timeZone` its `timeZone` option and
1633
+ "Shop-Config-Preset"): `documents.numbering.timeZone` is its `timeZone`
1634
+ option, `documents.htmlFactory` its optional `documentHtmlFactory` and
1487
1635
  `documents.pdf.executablePath` the `DOCUMENT_PDF_EXECUTABLE` variable. The
1488
1636
  development UID `ATU00000000` is a placeholder: Documents never prints it,
1489
1637
  and Tax Compliance does not transmit it to FinanzOnline. Enter the real
@@ -2151,17 +2299,6 @@ pnpm add @hartl-services/medusa-base \
2151
2299
  import { defineFoodShopConfig } from "@hartl-services/medusa-base/shop-config"
2152
2300
 
2153
2301
  module.exports = defineFoodShopConfig({
2154
- merchant: {
2155
- countryCode: "at",
2156
- legalName: "Example Shop GmbH",
2157
- registeredAddress: {
2158
- address1: "Musterstrasse 1",
2159
- city: "Wien",
2160
- countryCode: "at",
2161
- postalCode: "1010",
2162
- },
2163
- vatNumber: "ATU12345678",
2164
- },
2165
2302
  displayName: "EXAMPLE SHOP",
2166
2303
  orderNotificationEmail: "orders@example.com",
2167
2304
  timeZone: "Europe/Vienna",
@@ -2202,11 +2339,11 @@ validate the environment; a missing variable fails with the variable's name.
2202
2339
 
2203
2340
  | Option | Meaning |
2204
2341
  | --- | --- |
2205
- | `merchant` | `DocumentMerchantOption` (`countryCode`, `legalName`, `registeredAddress`, optional `vatNumber`). Fallback for the Shop-Stammdaten (Admin, "Hartl Services Plugins") that documents and tax compliance read per field; `legalName` is also the seeded store name. Country codes are 2-letter lowercase. |
2206
- | `displayName` | Shop name in the default e-mail templates. |
2342
+ | `displayName` | Shop name in the default e-mail templates (a shop's initial seed may use it as store name). Company name, address and UID are no options: documents and tax compliance read them only from the Shop-Stammdaten (Admin, "Hartl Services Plugins"). |
2207
2343
  | `orderNotificationEmail` | Receives order notification e-mails; must look like an e-mail address. |
2208
2344
  | `timeZone` | IANA time zone (validated with `Intl`). Chooses the annual document-number period, the printed dates on documents and the affiliate consignment reminder time zone. |
2209
2345
  | `storefrontCountryCode` | 2-letter lowercase country code of the storefront route. Links: `<STOREFRONT_URL>/<cc>/partnerportal` (affiliate portal), `<STOREFRONT_URL>/<cc>/reset-password` (the e-mail plugin appends `#token=`), referral base `<STOREFRONT_URL>/<cc>`. The Admin invite link is `<MEDUSA_BACKEND_URL>/app/invite?token=`. |
2346
+ | `documentHtmlFactory` | Optional `DocumentHtmlFactory` that replaces the default document markup, typically `createDefaultDocumentHtmlFactory({ timeZone, theme })` from `/config` with the shop's CSS and watermark (see "Layout, logo, payment terms and shop theme"). Must be a function. |
2210
2347
  | `loadEnvFile` | Default `true`: calls `loadEnv(NODE_ENV \|\| "development", process.cwd())`. Pass `false` when the environment is already complete (tests, isolated child processes). |
2211
2348
 
2212
2349
  ### Environment variables
@@ -0,0 +1,15 @@
1
+ type ImageSettingInputProps = {
2
+ id: string;
3
+ hintId: string;
4
+ label: string;
5
+ /** Public URL of the current image; `""` = none. */
6
+ value: string;
7
+ disabled: boolean;
8
+ onChange: (url: string) => void;
9
+ };
10
+ /**
11
+ * Uploads an image to the public File Module storage and hands its URL to
12
+ * the settings draft; the form stores it on save like any other value.
13
+ */
14
+ export declare const ImageSettingInput: ({ id, hintId, label, value, disabled, onChange, }: ImageSettingInputProps) => import("react/jsx-runtime").JSX.Element;
15
+ export {};
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, Hint, Text } from "@medusajs/ui";
3
+ import { useMutation } from "@tanstack/react-query";
4
+ import { useRef, useState } from "react";
5
+ import { texts } from "../../../i18n/texts";
6
+ import { sdk } from "../../../lib/sdk";
7
+ import { validateSettingImage } from "../lib/image-upload";
8
+ /**
9
+ * Uploads an image to the public File Module storage and hands its URL to
10
+ * the settings draft; the form stores it on save like any other value.
11
+ */
12
+ export const ImageSettingInput = ({ id, hintId, label, value, disabled, onChange, }) => {
13
+ const fileInput = useRef(null);
14
+ const [error, setError] = useState(null);
15
+ const upload = useMutation({
16
+ mutationFn: async (file) => {
17
+ const { files } = await sdk.admin.upload.create({ files: [file] });
18
+ const url = files[0]?.url;
19
+ if (!url) {
20
+ throw new Error("upload returned no file");
21
+ }
22
+ return url;
23
+ },
24
+ onSuccess: (url) => onChange(url),
25
+ onError: () => setError(texts.settings.imageUploadFailed),
26
+ });
27
+ const busy = disabled || upload.isPending;
28
+ const selectFile = (file) => {
29
+ if (!file) {
30
+ return;
31
+ }
32
+ const invalid = validateSettingImage(file);
33
+ setError(invalid);
34
+ if (!invalid) {
35
+ upload.mutate(file);
36
+ }
37
+ };
38
+ return (_jsxs("div", { className: "grid gap-2", children: [value && (_jsx("div", { className: "bg-ui-bg-subtle flex items-center justify-center rounded-md border p-4", children: _jsx("img", { src: value, alt: texts.settings.imagePreviewAlt(label), className: "max-h-24 max-w-full object-contain" }) })), _jsx("input", { ref: fileInput, id: id, "aria-describedby": hintId, type: "file", accept: "image/png,image/jpeg", className: "hidden", onChange: (event) => {
39
+ selectFile(event.target.files?.[0]);
40
+ event.target.value = "";
41
+ } }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", size: "small", variant: "secondary", isLoading: upload.isPending, disabled: busy, onClick: () => fileInput.current?.click(), children: value ? texts.settings.imageReplace : texts.settings.imageUpload }), value && (_jsx(Button, { type: "button", size: "small", variant: "transparent", disabled: busy, onClick: () => onChange(""), children: texts.settings.imageRemove })), !value && (_jsx(Text, { size: "small", className: "text-ui-fg-muted", children: texts.settings.imageNone }))] }), error && _jsx(Hint, { variant: "error", children: error })] }));
42
+ };
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Alert, Button, Container, Heading, Hint, Input, Label, Select, Switch, Text, } from "@medusajs/ui";
3
3
  import { texts } from "../../../i18n/texts";
4
4
  import { usePluginSettingsForm } from "../hooks/usePluginSettingsForm";
5
+ import { ImageSettingInput } from "./ImageSettingInput";
5
6
  const fieldId = (pluginKey, key) => `plugin-setting-${pluginKey}-${key}`;
6
7
  export const missingLabels = (plugin) => plugin.missing
7
8
  .map((key) => plugin.settings.find((setting) => setting.key === key)?.label ?? key)
@@ -27,13 +28,14 @@ const SettingField = ({ pluginKey, setting, invalidatesLabels, value, invalid, d
27
28
  ? texts.settings.secretClearUndoLabel(setting.label)
28
29
  : texts.settings.secretClearLabel(setting.label), onClick: () => onChange(clearPending ? "" : null), children: clearPending
29
30
  ? texts.settings.secretClearUndo
30
- : texts.settings.secretClear }))] })) : setting.type === "select" ? (_jsxs(Select, { value: typeof value === "string" ? value : "", disabled: disabled, onValueChange: (next) => onChange(next), children: [_jsx(Select.Trigger, { id: id, "aria-describedby": hintId, children: _jsx(Select.Value, { placeholder: texts.settings.selectPlaceholder }) }), _jsx(Select.Content, { children: setting.options.map((option) => (_jsx(Select.Item, { value: option.value, children: option.label }, option.value))) })] })) : setting.type === "number" ? (_jsx(Input, { id: id, "aria-describedby": hintId, "aria-invalid": invalid, type: "number", step: "any", min: setting.min ?? undefined, max: setting.max ?? undefined, value: typeof value === "string" ? value : "", disabled: disabled, onChange: (event) => onChange(event.target.value, event.target.validity.badInput) })) : (_jsx(Input, { id: id, "aria-describedby": hintId, "aria-invalid": invalid, type: setting.type === "url" ? "url" : "text", value: typeof value === "string" ? value : "", disabled: disabled, onChange: (event) => onChange(event.target.value) })), _jsxs("div", { id: hintId, className: "grid gap-1", children: [setting.description && (_jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: setting.description })), clearPending && (_jsx(Hint, { variant: "info", children: texts.settings.secretClearPending })), invalidatesLabels && (_jsx(Text, { size: "small", className: "text-ui-fg-muted", children: texts.settings.invalidates(invalidatesLabels) })), bounds && !invalid && (_jsx(Text, { size: "small", className: "text-ui-fg-muted", children: bounds })), invalid && (_jsx(Hint, { variant: "error", children: bounds ?? texts.settings.invalidNumber }))] })] }));
31
+ : texts.settings.secretClear }))] })) : setting.type === "select" ? (_jsxs(Select, { value: typeof value === "string" ? value : "", disabled: disabled, onValueChange: (next) => onChange(next), children: [_jsx(Select.Trigger, { id: id, "aria-describedby": hintId, children: _jsx(Select.Value, { placeholder: texts.settings.selectPlaceholder }) }), _jsx(Select.Content, { children: setting.options.map((option) => (_jsx(Select.Item, { value: option.value, children: option.label }, option.value))) })] })) : setting.type === "image" ? (_jsx(ImageSettingInput, { id: id, hintId: hintId, label: setting.label, value: typeof value === "string" ? value : "", disabled: disabled, onChange: (url) => onChange(url) })) : setting.type === "number" ? (_jsx(Input, { id: id, "aria-describedby": hintId, "aria-invalid": invalid, type: "number", step: "any", min: setting.min ?? undefined, max: setting.max ?? undefined, value: typeof value === "string" ? value : "", disabled: disabled, onChange: (event) => onChange(event.target.value, event.target.validity.badInput) })) : (_jsx(Input, { id: id, "aria-describedby": hintId, "aria-invalid": invalid, type: setting.type === "url" ? "url" : "text", value: typeof value === "string" ? value : "", disabled: disabled, onChange: (event) => onChange(event.target.value) })), _jsxs("div", { id: hintId, className: "grid gap-1", children: [setting.description && (_jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: setting.description })), clearPending && (_jsx(Hint, { variant: "info", children: texts.settings.secretClearPending })), invalidatesLabels && (_jsx(Text, { size: "small", className: "text-ui-fg-muted", children: texts.settings.invalidates(invalidatesLabels) })), bounds && !invalid && (_jsx(Text, { size: "small", className: "text-ui-fg-muted", children: bounds })), invalid && (_jsx(Hint, { variant: "error", children: bounds ?? texts.settings.invalidNumber }))] })] }));
31
32
  };
32
33
  export const PluginSettingsForm = ({ plugin }) => {
33
34
  const form = usePluginSettingsForm(plugin);
34
35
  const pending = form.mutation.isPending;
35
36
  const hasTextFields = plugin.settings.some((setting) => setting.type !== "boolean" &&
36
37
  setting.type !== "secret" &&
37
- setting.type !== "select");
38
+ setting.type !== "select" &&
39
+ setting.type !== "image");
38
40
  return (_jsxs(Container, { className: "divide-y p-0", "aria-busy": pending, children: [_jsxs("div", { className: "px-6 py-4", children: [_jsx(Heading, { level: "h2", children: texts.sections.settings }), hasTextFields && (_jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: texts.settings.clearHint }))] }), _jsxs("form", { onSubmit: form.submit, className: "grid gap-5 px-6 py-5", children: [!plugin.configured && (_jsx(Alert, { variant: "warning", children: texts.settings.missing(missingLabels(plugin)) })), plugin.settings.map((setting) => (_jsx(SettingField, { pluginKey: plugin.plugin_key, setting: setting, invalidatesLabels: invalidatesLabels(plugin, setting), value: form.draft[setting.key], invalid: form.invalid.includes(setting.key), disabled: pending, onChange: (value, badInput) => form.setValue(setting.key, value, badInput) }, setting.key))), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "submit", size: "small", isLoading: pending, disabled: !form.dirty || form.invalid.length > 0 || pending, children: texts.settings.save }) })] })] }));
39
41
  };
@@ -74,7 +74,7 @@ export declare const pluginFeatureEnabledQueryOptions: (pluginKey: string, featu
74
74
  source: "default" | "stored" | "config" | "none";
75
75
  invalidates: string[];
76
76
  } | {
77
- type: "string" | "boolean" | "url";
77
+ type: "string" | "boolean" | "url" | "image";
78
78
  default: string | number | boolean | null;
79
79
  value: string | number | boolean | null;
80
80
  key: string;
@@ -54,7 +54,7 @@ export declare function usePluginFeatures(): import("@tanstack/react-query").Use
54
54
  source: "default" | "stored" | "config" | "none";
55
55
  invalidates: string[];
56
56
  } | {
57
- type: "string" | "boolean" | "url";
57
+ type: "string" | "boolean" | "url" | "image";
58
58
  default: string | number | boolean | null;
59
59
  value: string | number | boolean | null;
60
60
  key: string;
@@ -141,7 +141,7 @@ export declare function usePluginFeature(pluginKey: string | undefined): import(
141
141
  source: "default" | "stored" | "config" | "none";
142
142
  invalidates: string[];
143
143
  } | {
144
- type: "string" | "boolean" | "url";
144
+ type: "string" | "boolean" | "url" | "image";
145
145
  default: string | number | boolean | null;
146
146
  value: string | number | boolean | null;
147
147
  key: string;
@@ -222,7 +222,7 @@ export declare function useUpdatePluginFeatures(): import("@tanstack/react-query
222
222
  source: "default" | "stored" | "config" | "none";
223
223
  invalidates: string[];
224
224
  } | {
225
- type: "string" | "boolean" | "url";
225
+ type: "string" | "boolean" | "url" | "image";
226
226
  default: string | number | boolean | null;
227
227
  value: string | number | boolean | null;
228
228
  key: string;
@@ -307,7 +307,7 @@ export declare function useUpdatePluginSettings(pluginKey: string): import("@tan
307
307
  source: "default" | "stored" | "config" | "none";
308
308
  invalidates: string[];
309
309
  } | {
310
- type: "string" | "boolean" | "url";
310
+ type: "string" | "boolean" | "url" | "image";
311
311
  default: string | number | boolean | null;
312
312
  value: string | number | boolean | null;
313
313
  key: string;
@@ -63,7 +63,7 @@ export declare function usePluginSettingsForm(plugin: AdminPluginFeatures): {
63
63
  source: "default" | "stored" | "config" | "none";
64
64
  invalidates: string[];
65
65
  } | {
66
- type: "string" | "boolean" | "url";
66
+ type: "string" | "boolean" | "url" | "image";
67
67
  default: string | number | boolean | null;
68
68
  value: string | number | boolean | null;
69
69
  key: string;
@@ -0,0 +1,6 @@
1
+ /** Largest image an `image` setting accepts in the Admin. */
2
+ export declare const SETTING_IMAGE_MAX_BYTES: number;
3
+ type ImageSelection = Pick<File, "size" | "type">;
4
+ /** Why `file` cannot be uploaded for an `image` setting; `null` = valid. */
5
+ export declare const validateSettingImage: (file: ImageSelection) => string | null;
6
+ export {};
@@ -0,0 +1,17 @@
1
+ /** Largest image an `image` setting accepts in the Admin. */
2
+ export const SETTING_IMAGE_MAX_BYTES = 2 * 1024 * 1024;
3
+ // Gmail and Outlook do not display SVG or WebP in e-mails.
4
+ const IMAGE_TYPES = new Set(["image/png", "image/jpeg"]);
5
+ /** Why `file` cannot be uploaded for an `image` setting; `null` = valid. */
6
+ export const validateSettingImage = (file) => {
7
+ if (!IMAGE_TYPES.has(file.type)) {
8
+ return "Bitte eine PNG- oder JPG-Datei auswählen.";
9
+ }
10
+ if (file.size === 0) {
11
+ return "Die Bilddatei ist leer.";
12
+ }
13
+ if (file.size > SETTING_IMAGE_MAX_BYTES) {
14
+ return "Die Bilddatei darf maximal 2 MiB groß sein.";
15
+ }
16
+ return null;
17
+ };
@@ -55,7 +55,7 @@ export declare const listPluginFeatures: () => Promise<{
55
55
  source: "default" | "stored" | "config" | "none";
56
56
  invalidates: string[];
57
57
  } | {
58
- type: "string" | "boolean" | "url";
58
+ type: "string" | "boolean" | "url" | "image";
59
59
  default: string | number | boolean | null;
60
60
  value: string | number | boolean | null;
61
61
  key: string;
@@ -135,7 +135,7 @@ export declare const retrievePluginFeatures: (pluginKey: string) => Promise<{
135
135
  source: "default" | "stored" | "config" | "none";
136
136
  invalidates: string[];
137
137
  } | {
138
- type: "string" | "boolean" | "url";
138
+ type: "string" | "boolean" | "url" | "image";
139
139
  default: string | number | boolean | null;
140
140
  value: string | number | boolean | null;
141
141
  key: string;
@@ -215,7 +215,7 @@ export declare const updatePluginFeatures: (payload: UpdatePluginFeaturesPayload
215
215
  source: "default" | "stored" | "config" | "none";
216
216
  invalidates: string[];
217
217
  } | {
218
- type: "string" | "boolean" | "url";
218
+ type: "string" | "boolean" | "url" | "image";
219
219
  default: string | number | boolean | null;
220
220
  value: string | number | boolean | null;
221
221
  key: string;
@@ -295,7 +295,7 @@ export declare const updatePluginSettings: (pluginKey: string, payload: UpdatePl
295
295
  source: "default" | "stored" | "config" | "none";
296
296
  invalidates: string[];
297
297
  } | {
298
- type: "string" | "boolean" | "url";
298
+ type: "string" | "boolean" | "url" | "image";
299
299
  default: string | number | boolean | null;
300
300
  value: string | number | boolean | null;
301
301
  key: string;
@@ -59,6 +59,12 @@ export declare const texts: {
59
59
  numberMax: (max: number) => string;
60
60
  numberRange: (min: number, max: number) => string;
61
61
  selectPlaceholder: string;
62
+ imageUpload: string;
63
+ imageReplace: string;
64
+ imageRemove: string;
65
+ imageNone: string;
66
+ imageUploadFailed: string;
67
+ imagePreviewAlt: (label: string) => string;
62
68
  clearHint: string;
63
69
  source: {
64
70
  stored: string;
@@ -59,6 +59,12 @@ export const texts = {
59
59
  numberMax: (max) => `Höchstens ${max}.`,
60
60
  numberRange: (min, max) => `Zwischen ${min} und ${max}.`,
61
61
  selectPlaceholder: "Bitte auswählen",
62
+ imageUpload: "Bild hochladen",
63
+ imageReplace: "Anderes Bild hochladen",
64
+ imageRemove: "Entfernen",
65
+ imageNone: "Kein Bild hinterlegt",
66
+ imageUploadFailed: "Das Bild konnte nicht hochgeladen werden.",
67
+ imagePreviewAlt: (label) => `Vorschau ${label}`,
62
68
  clearHint: "Ein leeres Feld speichern entfernt den gespeicherten Wert (dann gilt Konfiguration oder Standard).",
63
69
  source: {
64
70
  stored: "gespeichert",
@@ -1,3 +1,3 @@
1
- import type { DocumentTemplateInput } from "../types";
2
- import type { DocumentLayout } from "./layout";
3
- export declare const renderFinancialDocument: (input: DocumentTemplateInput, layout: DocumentLayout, timeZone: string) => string;
1
+ import type { FinancialDocumentRenderInput } from "../types";
2
+ import { type DocumentLayout } from "./layout";
3
+ export declare const renderFinancialDocument: (input: FinancialDocumentRenderInput, layout: DocumentLayout, timeZone: string) => string;