@hartl-services/medusa-base 0.4.0 → 1.1.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 (258) hide show
  1. package/.medusa/server/src/admin/index.js +1657 -111
  2. package/.medusa/server/src/admin/index.mjs +1647 -101
  3. package/.medusa/server/src/api/admin/email/messages/[id]/resends/route.js +24 -0
  4. package/.medusa/server/src/api/admin/email/messages/[id]/route.js +12 -0
  5. package/.medusa/server/src/api/admin/email/messages/route.js +12 -0
  6. package/.medusa/server/src/api/admin/email/terms-versions/[id]/download/route.js +16 -0
  7. package/.medusa/server/src/api/admin/email/terms-versions/route.js +47 -0
  8. package/.medusa/server/src/api/admin/orders/[id]/documents/[document_id]/download/route.js +21 -0
  9. package/.medusa/server/src/api/admin/orders/[id]/documents/[document_id]/regenerate/route.js +38 -0
  10. package/.medusa/server/src/api/admin/orders/[id]/documents/regenerate/route.js +32 -0
  11. package/.medusa/server/src/api/admin/orders/[id]/documents/route.js +36 -0
  12. package/.medusa/server/src/api/admin/orders/[id]/packing-lists/[fulfillment_id]/download/route.js +24 -0
  13. package/.medusa/server/src/api/admin/orders/[id]/packing-lists/[fulfillment_id]/retry/route.js +44 -0
  14. package/.medusa/server/src/api/admin/orders/[id]/packing-lists/route.js +82 -0
  15. package/.medusa/server/src/api/document-download-response.js +16 -0
  16. package/.medusa/server/src/api/document-response.js +23 -0
  17. package/.medusa/server/src/api/files/[asset]/route.js +24 -0
  18. package/.medusa/server/src/api/files/file-response.js +43 -0
  19. package/.medusa/server/src/api/files/gateway-options.js +31 -0
  20. package/.medusa/server/src/api/files/private/[token]/route.js +29 -0
  21. package/.medusa/server/src/api/files/upload/[token]/route.js +66 -0
  22. package/.medusa/server/src/api/middlewares.js +168 -1
  23. package/.medusa/server/src/api/packing-list-response.js +22 -0
  24. package/.medusa/server/src/api/route.js +27 -0
  25. package/.medusa/server/src/api/store/documents/[id]/download/route.js +21 -0
  26. package/.medusa/server/src/api/store/documents/[id]/route.js +15 -0
  27. package/.medusa/server/src/api/store/documents/route.js +11 -0
  28. package/.medusa/server/src/api/terms-download-response.js +12 -0
  29. package/.medusa/server/src/documents/config/index.js +18 -0
  30. package/.medusa/server/src/documents/paths/documents.js +6 -0
  31. package/.medusa/server/src/documents/paths/index.js +18 -0
  32. package/.medusa/server/src/documents/plugin-features.js +38 -0
  33. package/.medusa/server/src/documents/schemas/documents.js +18 -0
  34. package/.medusa/server/src/documents/schemas/index.js +18 -0
  35. package/.medusa/server/src/documents/scripts/verify-documents-file-storage.js +55 -0
  36. package/.medusa/server/src/documents/templates/financial-document.js +160 -0
  37. package/.medusa/server/src/documents/templates/format.js +73 -0
  38. package/.medusa/server/src/documents/templates/html-factory.js +42 -0
  39. package/.medusa/server/src/documents/templates/html-utils.js +23 -0
  40. package/.medusa/server/src/documents/templates/layout.js +142 -0
  41. package/.medusa/server/src/documents/templates/packing-list.js +52 -0
  42. package/.medusa/server/src/documents/types/admin-api.js +3 -0
  43. package/.medusa/server/src/documents/types/document-summary.js +3 -0
  44. package/.medusa/server/src/documents/types/documents.js +3 -0
  45. package/.medusa/server/src/documents/types/html-factory.js +6 -0
  46. package/.medusa/server/src/documents/types/index.js +22 -0
  47. package/.medusa/server/src/documents/types/store-api.js +3 -0
  48. package/.medusa/server/src/documents/utils/document-merchant.js +22 -0
  49. package/.medusa/server/src/documents/utils/document-render-settings.js +59 -0
  50. package/.medusa/server/src/documents/utils/html-to-pdf.js +61 -0
  51. package/.medusa/server/src/documents/utils/private-file-storage.js +64 -0
  52. package/.medusa/server/src/documents/utils/resolve-services.js +7 -0
  53. package/.medusa/server/src/email/application/handle-invite-event.js +318 -0
  54. package/.medusa/server/src/email/application/handle-password-reset-event.js +366 -0
  55. package/.medusa/server/src/email/config/index.js +84 -0
  56. package/.medusa/server/src/email/config/notification-module.js +20 -0
  57. package/.medusa/server/src/email/domain/business-keys.js +50 -0
  58. package/.medusa/server/src/email/domain/documents-fact-fingerprint.js +7 -0
  59. package/.medusa/server/src/email/domain/event-schemas.js +36 -0
  60. package/.medusa/server/src/email/domain/failure-classification.js +53 -0
  61. package/.medusa/server/src/email/domain/order-summary.js +103 -0
  62. package/.medusa/server/src/email/domain/refund-summary.js +43 -0
  63. package/.medusa/server/src/email/domain/return-summary.js +118 -0
  64. package/.medusa/server/src/email/domain/shipment-summary.js +74 -0
  65. package/.medusa/server/src/email/plugin-features.js +39 -0
  66. package/.medusa/server/src/email/rendering/default-templates.js +719 -0
  67. package/.medusa/server/src/email/rendering/load-email-shop-details.js +36 -0
  68. package/.medusa/server/src/email/rendering/render-email.js +50 -0
  69. package/.medusa/server/src/email/rendering/resolve-shop-configuration.js +67 -0
  70. package/.medusa/server/src/email/types/email-kinds.js +18 -0
  71. package/.medusa/server/src/email/types/index.js +21 -0
  72. package/.medusa/server/src/email/types/shop-configuration.js +3 -0
  73. package/.medusa/server/src/email/types/smtp-configuration.js +3 -0
  74. package/.medusa/server/src/email/types/template-data.js +232 -0
  75. package/.medusa/server/src/files/config/index.js +256 -0
  76. package/.medusa/server/src/files/gateway/index.js +115 -0
  77. package/.medusa/server/src/files/types/index.js +6 -0
  78. package/.medusa/server/src/files/types/public.js +6 -0
  79. package/.medusa/server/src/jobs/document-fact-publication-reconciliation.js +18 -0
  80. package/.medusa/server/src/jobs/document-resolution-reconciliation.js +20 -0
  81. package/.medusa/server/src/jobs/packing-list-reconciliation.js +49 -0
  82. package/.medusa/server/src/jobs/reconcile-email-attempts.js +63 -0
  83. package/.medusa/server/src/jobs/reconcile-pending-email-messages.js +41 -0
  84. package/.medusa/server/src/jobs/retry-email-messages.js +27 -0
  85. package/.medusa/server/src/migration-scripts/dismiss-orphaned-cart-promotion-links.js +26 -0
  86. package/.medusa/server/src/modules/documents/domain/cancellation-snapshot.js +97 -0
  87. package/.medusa/server/src/modules/documents/domain/document-number.js +36 -0
  88. package/.medusa/server/src/modules/documents/domain/document-resolution.js +24 -0
  89. package/.medusa/server/src/modules/documents/domain/issuance-rules.js +37 -0
  90. package/.medusa/server/src/modules/documents/domain/merchant.js +86 -0
  91. package/.medusa/server/src/modules/documents/domain/options.js +40 -0
  92. package/.medusa/server/src/modules/documents/domain/packing-list.js +90 -0
  93. package/.medusa/server/src/modules/documents/domain/snapshot-normalization.js +17 -0
  94. package/.medusa/server/src/modules/documents/domain/tax-breakdown.js +66 -0
  95. package/.medusa/server/src/modules/documents/index.js +15 -0
  96. package/.medusa/server/src/modules/documents/loaders/assert-private-file-storage.js +20 -0
  97. package/.medusa/server/src/modules/documents/migrations/Migration20260827061749.js +75 -0
  98. package/.medusa/server/src/modules/documents/migrations/Migration20260830111207.js +28 -0
  99. package/.medusa/server/src/modules/documents/models/document-artifact.js +25 -0
  100. package/.medusa/server/src/modules/documents/models/document-number-sequence.js +29 -0
  101. package/.medusa/server/src/modules/documents/models/document.js +82 -0
  102. package/.medusa/server/src/modules/documents/models/order-cancellation-resolution.js +51 -0
  103. package/.medusa/server/src/modules/documents/models/packing-list-revision.js +36 -0
  104. package/.medusa/server/src/modules/documents/models/packing-list.js +27 -0
  105. package/.medusa/server/src/modules/documents/models/refund-document-resolution.js +66 -0
  106. package/.medusa/server/src/modules/documents/models/shipment-invoice-assignment.js +51 -0
  107. package/.medusa/server/src/modules/documents/models/shipment-invoice-coordinator.js +44 -0
  108. package/.medusa/server/src/modules/documents/service.js +1701 -0
  109. package/.medusa/server/src/modules/email/domain/attachment.js +25 -0
  110. package/.medusa/server/src/modules/email/domain/message-state.js +69 -0
  111. package/.medusa/server/src/modules/email/index.js +13 -0
  112. package/.medusa/server/src/modules/email/migrations/Migration20260826173422.js +32 -0
  113. package/.medusa/server/src/modules/email/migrations/Migration20260827172945.js +16 -0
  114. package/.medusa/server/src/modules/email/migrations/Migration20260827194804.js +22 -0
  115. package/.medusa/server/src/modules/email/migrations/Migration20260906195205.js +16 -0
  116. package/.medusa/server/src/modules/email/models/email-attempt.js +56 -0
  117. package/.medusa/server/src/modules/email/models/email-message.js +71 -0
  118. package/.medusa/server/src/modules/email/models/email-terms-version.js +37 -0
  119. package/.medusa/server/src/modules/email/service.js +1285 -0
  120. package/.medusa/server/src/modules/plugin-features/domain/plugin-settings.js +4 -3
  121. package/.medusa/server/src/providers/s3-cache/cache/atomic-write.js +25 -0
  122. package/.medusa/server/src/providers/s3-cache/cache/cache-manager.js +115 -0
  123. package/.medusa/server/src/providers/s3-cache/cache/keyed-lock.js +28 -0
  124. package/.medusa/server/src/providers/s3-cache/index.js +8 -0
  125. package/.medusa/server/src/providers/s3-cache/object-key.js +53 -0
  126. package/.medusa/server/src/providers/s3-cache/s3-cache-file.js +229 -0
  127. package/.medusa/server/src/providers/s3-cache/s3-object-store.js +86 -0
  128. package/.medusa/server/src/providers/smtp/index.js +8 -0
  129. package/.medusa/server/src/providers/smtp/service.js +236 -0
  130. package/.medusa/server/src/providers/smtp/types.js +11 -0
  131. package/.medusa/server/src/public/config.js +186 -0
  132. package/.medusa/server/src/public/descriptor.js +3 -1
  133. package/.medusa/server/src/public/gateway.js +19 -0
  134. package/.medusa/server/src/public/instrumentation.js +56 -0
  135. package/.medusa/server/src/public/shop-config.js +12 -0
  136. package/.medusa/server/src/public/shop-master-data.js +2 -2
  137. package/.medusa/server/src/public/shop-types.js +24 -0
  138. package/.medusa/server/src/shop-config/build-food-shop-config.js +252 -0
  139. package/.medusa/server/src/shop-config/options.js +75 -0
  140. package/.medusa/server/src/subscribers/affiliate-consignment-reminder-due.js +26 -0
  141. package/.medusa/server/src/subscribers/documents-order-canceled.js +24 -0
  142. package/.medusa/server/src/subscribers/documents-order-cancellation-ready.js +26 -0
  143. package/.medusa/server/src/subscribers/documents-refund-ready.js +24 -0
  144. package/.medusa/server/src/subscribers/documents-shipment-created.js +36 -0
  145. package/.medusa/server/src/subscribers/documents-shipment-invoice-assignment-decided.js +26 -0
  146. package/.medusa/server/src/subscribers/email-order-canceled.js +28 -0
  147. package/.medusa/server/src/subscribers/email-shipment-created.js +32 -0
  148. package/.medusa/server/src/subscribers/order-placed.js +27 -0
  149. package/.medusa/server/src/subscribers/packing-list-allocation-revised.js +32 -0
  150. package/.medusa/server/src/subscribers/packing-list-fulfillment-canceled.js +54 -0
  151. package/.medusa/server/src/subscribers/packing-list-fulfillment-created.js +31 -0
  152. package/.medusa/server/src/subscribers/password-reset.js +23 -0
  153. package/.medusa/server/src/subscribers/payment-refunded.js +38 -0
  154. package/.medusa/server/src/subscribers/return-received.js +27 -0
  155. package/.medusa/server/src/subscribers/return-requested.js +27 -0
  156. package/.medusa/server/src/subscribers/user-invite.js +23 -0
  157. package/.medusa/server/src/utils/cart-promotion-links.js +101 -0
  158. package/.medusa/server/src/workflows/documents/issue-cancellation-after-refund.js +246 -0
  159. package/.medusa/server/src/workflows/documents/issue-document.js +165 -0
  160. package/.medusa/server/src/workflows/documents/issue-invoice-after-shipment.js +521 -0
  161. package/.medusa/server/src/workflows/documents/issue-packing-list.js +390 -0
  162. package/.medusa/server/src/workflows/documents/publish-document-facts.js +53 -0
  163. package/.medusa/server/src/workflows/documents/reconcile-document-resolutions.js +48 -0
  164. package/.medusa/server/src/workflows/documents/reconcile-order-cancellation.js +92 -0
  165. package/.medusa/server/src/workflows/documents/regenerate-issued-document.js +61 -0
  166. package/.medusa/server/src/workflows/email/dispatch-email-message.js +301 -0
  167. package/.medusa/server/src/workflows/email/documents-required-facts.js +30 -0
  168. package/.medusa/server/src/workflows/email/prepare-affiliate-reminder-email.js +58 -0
  169. package/.medusa/server/src/workflows/email/prepare-order-canceled-email.js +102 -0
  170. package/.medusa/server/src/workflows/email/prepare-order-received-email.js +82 -0
  171. package/.medusa/server/src/workflows/email/prepare-refund-email.js +71 -0
  172. package/.medusa/server/src/workflows/email/prepare-return-email.js +136 -0
  173. package/.medusa/server/src/workflows/email/prepare-shipment-email.js +115 -0
  174. package/.medusa/server/src/workflows/email/retry-email-messages.js +31 -0
  175. package/.medusa/server/src/workflows/hooks/dismiss-deleted-promotion-cart-links.js +23 -0
  176. package/README.md +1777 -8
  177. package/dist/admin-components/features/plugin-features/components/ImageSettingInput.d.ts +15 -0
  178. package/dist/admin-components/features/plugin-features/components/ImageSettingInput.js +42 -0
  179. package/dist/admin-components/features/plugin-features/components/PluginSettingsForm.js +4 -2
  180. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatureEnabled.d.ts +1 -1
  181. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatures.d.ts +4 -4
  182. package/dist/admin-components/features/plugin-features/hooks/usePluginSettingsForm.d.ts +1 -1
  183. package/dist/admin-components/features/plugin-features/lib/image-upload.d.ts +6 -0
  184. package/dist/admin-components/features/plugin-features/lib/image-upload.js +17 -0
  185. package/dist/admin-components/features/plugin-features/lib/plugin-features-admin-client.d.ts +4 -4
  186. package/dist/admin-components/i18n/texts.d.ts +6 -0
  187. package/dist/admin-components/i18n/texts.js +6 -0
  188. package/dist/documents/config/index.d.ts +11 -0
  189. package/dist/documents/config/index.js +17 -0
  190. package/dist/documents/templates/financial-document.d.ts +3 -0
  191. package/dist/documents/templates/financial-document.js +159 -0
  192. package/dist/documents/templates/format.d.ts +30 -0
  193. package/dist/documents/templates/format.js +72 -0
  194. package/dist/documents/templates/html-factory.d.ts +15 -0
  195. package/dist/documents/templates/html-factory.js +41 -0
  196. package/dist/documents/templates/html-utils.d.ts +8 -0
  197. package/dist/documents/templates/html-utils.js +22 -0
  198. package/dist/documents/templates/layout.d.ts +35 -0
  199. package/dist/documents/templates/layout.js +141 -0
  200. package/dist/documents/templates/packing-list.d.ts +3 -0
  201. package/dist/documents/templates/packing-list.js +51 -0
  202. package/dist/documents/types/admin-api.d.ts +1 -0
  203. package/dist/documents/types/admin-api.js +2 -0
  204. package/dist/documents/types/document-summary.d.ts +1 -0
  205. package/dist/documents/types/document-summary.js +2 -0
  206. package/dist/documents/types/documents.d.ts +191 -0
  207. package/dist/documents/types/documents.js +2 -0
  208. package/dist/documents/types/html-factory.d.ts +8 -0
  209. package/dist/documents/types/html-factory.js +5 -0
  210. package/dist/documents/types/index.d.ts +5 -0
  211. package/dist/documents/types/index.js +21 -0
  212. package/dist/documents/types/store-api.d.ts +1 -0
  213. package/dist/documents/types/store-api.js +2 -0
  214. package/dist/email/config/index.d.ts +17 -0
  215. package/dist/email/config/index.js +83 -0
  216. package/dist/email/config/notification-module.d.ts +22 -0
  217. package/dist/email/config/notification-module.js +19 -0
  218. package/dist/email/types/email-kinds.d.ts +15 -0
  219. package/dist/email/types/email-kinds.js +17 -0
  220. package/dist/email/types/index.d.ts +4 -0
  221. package/dist/email/types/index.js +20 -0
  222. package/dist/email/types/shop-configuration.d.ts +68 -0
  223. package/dist/email/types/shop-configuration.js +2 -0
  224. package/dist/email/types/smtp-configuration.d.ts +9 -0
  225. package/dist/email/types/smtp-configuration.js +2 -0
  226. package/dist/email/types/template-data.d.ts +609 -0
  227. package/dist/email/types/template-data.js +231 -0
  228. package/dist/files/config/index.d.ts +32 -0
  229. package/dist/files/config/index.js +255 -0
  230. package/dist/files/gateway/index.d.ts +30 -0
  231. package/dist/files/gateway/index.js +114 -0
  232. package/dist/files/types/index.d.ts +2 -0
  233. package/dist/files/types/index.js +5 -0
  234. package/dist/files/types/public.d.ts +90 -0
  235. package/dist/files/types/public.js +5 -0
  236. package/dist/modules/documents/domain/merchant.d.ts +22 -0
  237. package/dist/modules/documents/domain/merchant.js +85 -0
  238. package/dist/modules/documents/domain/options.d.ts +34 -0
  239. package/dist/modules/documents/domain/options.js +39 -0
  240. package/dist/public/config.d.ts +59 -0
  241. package/dist/public/config.js +185 -0
  242. package/dist/public/descriptor.js +2 -0
  243. package/dist/public/gateway.d.ts +2 -0
  244. package/dist/public/gateway.js +18 -0
  245. package/dist/public/instrumentation.d.ts +12 -0
  246. package/dist/public/instrumentation.js +55 -0
  247. package/dist/public/shop-config.d.ts +8 -0
  248. package/dist/public/shop-config.js +11 -0
  249. package/dist/public/shop-master-data.d.ts +1 -1
  250. package/dist/public/shop-master-data.js +1 -1
  251. package/dist/public/shop-types.d.ts +7 -0
  252. package/dist/public/shop-types.js +23 -0
  253. package/dist/public/types.d.ts +19 -2
  254. package/dist/shop-config/build-food-shop-config.d.ts +44 -0
  255. package/dist/shop-config/build-food-shop-config.js +251 -0
  256. package/dist/shop-config/options.d.ts +48 -0
  257. package/dist/shop-config/options.js +74 -0
  258. package/package.json +48 -5
package/README.md CHANGED
@@ -4,7 +4,10 @@ Medusa v2.19 base plugin for Hartl Services shops. Every shop installs it. It
4
4
  provides per-plugin feature toggles, runtime settings and actions: a generic
5
5
  Admin hub under **Settings → Hartl Services Plugins** with one page per
6
6
  plugin, a Store endpoint for storefronts and helpers that plugins use to
7
- enforce their toggles and read their settings.
7
+ enforce their toggles and read their settings. Since 1.0.0 it also contains
8
+ the shop's transactional E-Mail, order Documents (invoices, cancellations,
9
+ packing lists) and S3 file storage, and the turnkey `medusa-config.ts` preset
10
+ (see "E-Mail", "Dokumente", "Dateispeicher (S3)" and "Shop-Config-Preset").
8
11
 
9
12
  ## What it does
10
13
 
@@ -33,6 +36,19 @@ enforce their toggles and read their settings.
33
36
  - Owns the `completeCartWorkflow` hooks `validate` and `orderCreated` and runs
34
37
  the workflows that plugins declare for them (see "Complete-cart hook
35
38
  composition").
39
+ - Redirects `GET /` to the admin dashboard and repairs dangling
40
+ `cart_promotion` links (see "Root redirect" and "Cart-promotion link
41
+ repair").
42
+ - Exports the shop's `medusa-config.ts` helpers from
43
+ `@hartl-services/medusa-base/config` and Sentry's `register()` from
44
+ `@hartl-services/medusa-base/instrumentation` (see "Config helpers" and
45
+ "Sentry instrumentation").
46
+ - Sends the shop's transactional e-mails over SMTP (module `email`, see
47
+ "E-Mail"), issues invoices, cancellation documents and packing lists as
48
+ private PDFs (module `documents`, see "Dokumente") and stores files in
49
+ S3-compatible storage behind the `/files` gateway (see "Dateispeicher (S3)").
50
+ - Builds a food shop's complete `medusa-config.ts` from a few shop facts
51
+ (`@hartl-services/medusa-base/shop-config`, see "Shop-Config-Preset").
36
52
 
37
53
  ## Installation in a shop
38
54
 
@@ -49,18 +65,46 @@ plugins: [
49
65
  options: {
50
66
  // Optional: key material for secret settings (see "Secrets").
51
67
  settingsSecret: process.env.PLUGIN_SETTINGS_SECRET,
68
+ // E-Mail, Documents and the /files gateway: see the sections below
69
+ // (or let the Shop-Config-Preset fill them in).
70
+ // email: { jwtSecret, shop },
71
+ // documents: { merchant, numbering, pdf, htmlFactory },
72
+ // files: { gateway },
52
73
  },
53
74
  },
54
75
  // … all other plugins
55
76
  ]
56
77
  ```
57
78
 
58
- The only option is `settingsSecret` (`BasePluginOptions`, optional): the key
59
- material for stored secret settings. Set it from an environment variable of
60
- the shop; unset or empty, the base uses `projectConfig.http.cookieSecret`.
61
- Run `medusa db:migrate` after installing it (and
62
- after upgrading: 0.4.0 adds the tables `plugin_setting_state` and
63
- `plugin_action_run`).
79
+ The options are `BasePluginOptions`. Medusa hands the same options object to
80
+ every module of the plugin; each module reads only its own namespace:
81
+
82
+ | Option | Read by | Meaning |
83
+ |---|---|---|
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
+ | `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`. |
87
+ | `files` | `/files` routes | `{ gateway: { signingSecret } }`, returned by `createS3CacheStorageConfig` (see "Dateispeicher (S3)"). |
88
+
89
+ `options: {}` is valid: the base then only provides the feature toggles,
90
+ settings, actions and hooks, the e-mail module cannot dispatch, documents use
91
+ the defaults and the `/files` routes answer that signing is not configured.
92
+ Run `medusa db:migrate` after installing it (and after upgrading: 0.4.0 adds
93
+ the tables `plugin_setting_state` and `plugin_action_run`; 1.0.0 brings the
94
+ `email` and `documents` tables of the former `@hartl-services/medusa-email` and
95
+ `@hartl-services/medusa-documents` packages with unchanged table and
96
+ migration names, so a shop that used them needs no data migration).
97
+
98
+ Upgrading from the separate packages: remove `@hartl-services/medusa-email`,
99
+ `@hartl-services/medusa-documents`, `@hartl-services/medusa-s3-cache-storage`
100
+ and `@hartl-services/medusa-food-shop-config` from `package.json` and
101
+ `medusa-config.ts`, move their options into the namespaces above, change the
102
+ provider resolve paths to `@hartl-services/medusa-base/providers/smtp` and
103
+ `@hartl-services/medusa-base/providers/s3-cache` (the provider ids `smtp` and
104
+ `s3-cache` are unchanged) and import their `/config`, `/types` and `/gateway`
105
+ exports from the matching `@hartl-services/medusa-base` subpath. The
106
+ `documentsEnabled` option is gone; Documents is always installed, and the
107
+ e-mail module follows the Documents `invoices` toggle.
64
108
 
65
109
  Peer dependencies are the host's Medusa 2.19 packages (`@medusajs/framework`,
66
110
  `@medusajs/medusa`, `@medusajs/admin-sdk`, `@medusajs/js-sdk`, `@medusajs/ui`)
@@ -71,6 +115,7 @@ dashboard uses.
71
115
 
72
116
  | Method | Path | Auth | Response |
73
117
  |---|---|---|---|
118
+ | `GET` | `/` | none | 302 redirect to the configured `admin.path` (default `/app`) |
74
119
  | `GET` | `/store/plugin-features` | publishable key | `StorePluginFeaturesListResponse` |
75
120
  | `GET` | `/store/plugin-features/:plugin_key` | publishable key | `StorePluginFeaturesResponse`, 404 for an unknown plugin |
76
121
  | `GET` | `/admin/plugin-features` | Admin user | `AdminPluginFeaturesListResponse` |
@@ -273,7 +318,10 @@ class DemoModuleService extends MedusaService({ /* models */ }) {
273
318
 
274
319
  - Types: `string`, `url` (http/https), `number` (finite, optional inclusive
275
320
  `min`/`max`), `boolean`, `secret`, `select` (`options: [{ value, label }]`,
276
- 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
277
325
  follow the feature key format and are unique. A `secret` has no default;
278
326
  any other default must match its type, options and bounds. `options` exist
279
327
  only on `select`, `min`/`max` only on `number`. `required` defaults to
@@ -634,3 +682,1724 @@ module.exports = defineConfig({
634
682
 
635
683
  Initialize Sentry in `instrumentation.ts` as usual; without a `SENTRY_DSN` the
636
684
  wrapper is inert.
685
+
686
+ ## Root redirect
687
+
688
+ Medusa serves nothing at `/`, so a deployed domain answers it with a 404. The
689
+ base registers `GET /` and answers with a `302` to the shop's `admin.path`
690
+ (read from the config module, fallback `/app`), so opening the bare domain
691
+ lands on the admin dashboard in every shop and in local development without a
692
+ reverse-proxy rule. The redirect is temporary on purpose: browsers cache a
693
+ `301` on the bare domain essentially forever, which would be painful to undo
694
+ should `/` ever get real content. A shop that needs its own `/` defines
695
+ `src/api/route.ts` itself.
696
+
697
+ ## Cart-promotion link repair
698
+
699
+ Medusa's `deletePromotionsWorkflow` only soft-deletes a promotion and leaves
700
+ its `cart_promotion` link rows behind, so every cart that had it applied
701
+ reads back a `null` entry inside `cart.promotions`. The base removes those
702
+ links itself:
703
+
704
+ - The `deletePromotionsWorkflow.hooks.promotionsDeleted` hook
705
+ (`workflows/hooks/dismiss-deleted-promotion-cart-links`) dismisses the
706
+ links of the deleted promotions and restores them if a later step fails.
707
+ - The migration script `dismiss-orphaned-cart-promotion-links` runs once in
708
+ `medusa db:migrate` and removes the links that already dangle from earlier
709
+ deletions. Medusa tracks migration scripts by file name in
710
+ `script_migrations`, so the name must not change.
711
+
712
+ Nothing to configure; `medusa db:migrate` after installing or upgrading to
713
+ 0.4.1 is enough.
714
+
715
+ ## Config helpers
716
+
717
+ `@hartl-services/medusa-base/config` holds the generic environment helpers a
718
+ shop's `medusa-config.ts` needs, so every shop fails at startup with the name
719
+ of the missing variable instead of a runtime crash. The environment helpers
720
+ have no dependency on Medusa; the subpath does not load `@sentry/node`.
721
+
722
+ | Export | Purpose |
723
+ |---|---|
724
+ | `ConfigurationPhase` | `"build" \| "runtime"` |
725
+ | `MEDUSA_BUILD_PLACEHOLDERS` | Placeholders for `ADMIN_CORS`, `AUTH_CORS`, `COOKIE_SECRET`, `DATABASE_URL`, `JWT_SECRET`, `MEDUSA_BACKEND_URL`, `STOREFRONT_URL`, `STORE_CORS`, `STRIPE_API_KEY`, `STRIPE_WEBHOOK_SECRET` |
726
+ | `requireEnvironment(environment, names, phase, buildPlaceholders)` | Returns every named value (trimmed) or throws one error listing all missing names; in the build phase `buildPlaceholders` fill in for deployment values |
727
+ | `optionalEnvironment`, `integerEnvironment`, `booleanEnvironment` | Trimmed optional string, range-checked integer, strict `"true"`/`"false"` boolean |
728
+ | `allowsInsecureLocalServices(environment, phase)` | `true` for build, development and test |
729
+ | `resolvePublicOrigin(value, name, allowHttp)`, `shopUrl(origin, path)` | Validate a bare HTTP(S) origin; build an absolute URL that cannot leave the origin |
730
+ | `resolveStripe(values)` | Checks the `sk_` key and the `whsec_` webhook secret |
731
+ | `resolveRedisEventBus(environment, phase)` | `{ url } \| undefined`: optional in development, required at runtime elsewhere, never used in a build, authenticated `rediss://` in production |
732
+ | `createSmtpNotificationModuleConfig`, `resolveSmtp`, `SMTP_ENVIRONMENT_VARIABLES`, `SMTP_BUILD_PLACEHOLDERS`, `defineShopEmailConfiguration` | SMTP notification provider and E-Mail configuration (see "E-Mail") |
733
+ | `createS3CacheStorageConfig`, `createS3CacheFileModuleConfig`, `resolveS3Credentials`, `resolveExpirationStrategy`, `S3_BUILD_PLACEHOLDERS` | S3 file storage (see "Dateispeicher (S3)") |
734
+ | `createDefaultDocumentHtmlFactory`, `DOCUMENTS_BUILD_PLACEHOLDERS` | Documents markup and build placeholder (see "Dokumente") |
735
+
736
+ `medusa build` loads the configuration without deployment secrets. Each
737
+ plugin's own placeholders (SMTP, S3, PDF executable, ...) live with that
738
+ plugin; the shop merges them:
739
+
740
+ ```ts
741
+ // medusa-config.ts
742
+ import {
743
+ MEDUSA_BUILD_PLACEHOLDERS,
744
+ requireEnvironment,
745
+ resolveRedisEventBus,
746
+ type ConfigurationPhase,
747
+ } from "@hartl-services/medusa-base/config"
748
+
749
+ const phase: ConfigurationPhase =
750
+ process.env.MEDUSA_BUILD_PHASE === "true" ? "build" : "runtime"
751
+ const env = requireEnvironment(
752
+ process.env,
753
+ ["DATABASE_URL", "JWT_SECRET", "COOKIE_SECRET"],
754
+ phase,
755
+ { ...MEDUSA_BUILD_PLACEHOLDERS /* , ...other plugins' placeholders */ },
756
+ )
757
+ const redis = resolveRedisEventBus(process.env, phase)
758
+ ```
759
+
760
+ ## Sentry instrumentation
761
+
762
+ `@hartl-services/medusa-base/instrumentation` exports `register()`, the hook
763
+ Medusa's start command calls from the shop's `instrumentation.ts`. It runs
764
+ `Sentry.init` with `SENTRY_DSN`, `SENTRY_ENVIRONMENT` (fallback `NODE_ENV`)
765
+ and `tracesSampleRate: 0`; without a `SENTRY_DSN` Sentry stays inert. The
766
+ shop's `instrumentation.ts` is one line:
767
+
768
+ ```ts
769
+ export { register } from "@hartl-services/medusa-base/instrumentation"
770
+ ```
771
+
772
+ `@sentry/node` stays an optional peer dependency of the base: a shop that uses
773
+ this export (or `sentry-logger`) lists `@sentry/node` in its own
774
+ `dependencies`.
775
+
776
+ ## E-Mail
777
+
778
+ The base owns transactional e-mail for the shop: logical email messages,
779
+ attempts, rendering, the Medusa Notification integration, SMTP transport,
780
+ retries and e-mail-specific Admin operations. It is the `email` module, its
781
+ SMTP notification provider (`providers/smtp`), its subscribers, jobs and
782
+ workflows, and the Admin page `/settings/hartl-services/email`. There is no
783
+ separate e-mail package since 1.0.0.
784
+
785
+ ### Configuration
786
+
787
+ The SMTP notification provider is a `modules` entry built by
788
+ `createSmtpNotificationModuleConfig` (from
789
+ `@hartl-services/medusa-base/config`); the branding and rendering
790
+ configuration goes into the `email` namespace of the base options:
791
+
792
+ ```ts
793
+ import {
794
+ createSmtpNotificationModuleConfig,
795
+ resolveSmtp,
796
+ SMTP_ENVIRONMENT_VARIABLES,
797
+ } from "@hartl-services/medusa-base/config";
798
+
799
+ // Validates the seven SMTP_* variables (see "SMTP environment helpers").
800
+ const smtp = resolveSmtp(
801
+ Object.fromEntries(
802
+ SMTP_ENVIRONMENT_VARIABLES.map((name) => [name, process.env[name] ?? ""]),
803
+ ) as Record<(typeof SMTP_ENVIRONMENT_VARIABLES)[number], string>,
804
+ process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test",
805
+ );
806
+
807
+ export default defineConfig({
808
+ modules: [createSmtpNotificationModuleConfig(smtp)],
809
+ plugins: [
810
+ {
811
+ resolve: "@hartl-services/medusa-base",
812
+ options: {
813
+ email: {
814
+ jwtSecret: process.env.JWT_SECRET!,
815
+ shop: {
816
+ displayName: "Mein Shop",
817
+ affiliatePortalUrl: "https://shop.example.com/de/partner",
818
+ inviteUrl: ({ token }) =>
819
+ `https://shop.example.com/app/invite?token=${token}`,
820
+ orderNotificationEmail: "orders@shop.example.com",
821
+ passwordResetUrl: ({ locale }) =>
822
+ `https://shop.example.com/${locale ?? "de"}/passwort-zuruecksetzen`,
823
+ }, // a ShopEmailConfiguration, see `ShopEmailConfiguration` in `@hartl-services/medusa-base/types`
824
+ },
825
+ },
826
+ },
827
+ ],
828
+ });
829
+ ```
830
+
831
+ Options of the `email` namespace:
832
+
833
+ - `jwtSecret` — used for Email's own tokens; typically the same secret as
834
+ `projectConfig.http.jwtSecret`.
835
+ - `shop` — a `ShopEmailConfiguration` object: `displayName`,
836
+ `affiliatePortalUrl`, the `passwordResetUrl`/`inviteUrl` builders and the
837
+ `orderNotificationEmail` mailbox. `layout` and `renderers` are optional
838
+ overrides, see "Shop configuration and default templates".
839
+
840
+ Without `email.shop` the module still boots, but it cannot dispatch: no mail
841
+ is sent (the dispatch configuration is requested lazily and fails with
842
+ `UNEXPECTED_STATE`). Shipment and cancellation e-mails wait for a Documents
843
+ fact only while the Documents `invoices` feature is on when the message is
844
+ created (with `invoices` off, or the Documents plugin switched off, Documents
845
+ publishes no fact, so the mail is sent without a document). Refund e-mails
846
+ come only from the Documents refund fact, so there are none while `invoices`
847
+ is off. The former `documentsEnabled` option no longer exists: Documents is
848
+ always part of the base.
849
+
850
+ The event contracts the module consumes for shipment, cancellation, refund and
851
+ affiliate-reminder e-mails come from
852
+ `@hartl-services/medusa-food-supplements-contracts/events/documents` and
853
+ `/events/affiliate`, so installing the base does not pull in Affiliate.
854
+
855
+ Environment variables (all seven required outside development/test; at least
856
+ one of `SMTP_SECURE`/`SMTP_REQUIRE_TLS` must be `true` outside
857
+ development/test):
858
+
859
+ ```text
860
+ SMTP_HOST=
861
+ SMTP_PORT=
862
+ SMTP_SECURE=
863
+ SMTP_REQUIRE_TLS=
864
+ SMTP_USER=
865
+ SMTP_PASSWORD=
866
+ SMTP_FROM=
867
+ ```
868
+
869
+ Set `REDIS_URL` to use `@medusajs/event-bus-redis` instead of Medusa's local
870
+ Event Bus; production requires an authenticated `rediss://` URL. See "Event
871
+ Bus" below for retry/backoff behavior.
872
+
873
+ ### Feature toggles
874
+
875
+ The E-Mail module declares its toggles to `@hartl-services/medusa-base` under the
876
+ plugin key `email`. An Admin switches them under Settings → "Hartl Services
877
+ Plugins"; Storefronts read the effective values from
878
+ `GET /store/plugin-features/email`. A feature is only effective while the
879
+ plugin switch (default on) is on. A gated subscriber that finds its feature
880
+ off returns without creating a message; the event is not replayed when the
881
+ feature is switched on again.
882
+
883
+ | Key | Label | Default | Gates | Keeps running when off |
884
+ | --------------------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
885
+ | (plugin switch) | E-Mail | on | every route under `/admin/email/*` (mail log, resends, terms versions; 404); jobs `email-reconcile-attempts`, `email-reconcile-pending-messages`, `email-retry-messages`; every feature below | checkout and orders; already queued messages stay stored |
886
+ | `order_mails` | Bestell-Mails | on | subscribers `order-placed`, `email-shipment-created`, `email-order-canceled`, `return-requested`, `return-received`, `documents-shipment-invoice-assignment-decided`, `documents-order-cancellation-ready`, `documents-refund-ready` | jobs, Admin routes, already created messages |
887
+ | `account_mails` | Konto-Mails | on | subscribers `password-reset`, `user-invite` | jobs, Admin routes, already created messages |
888
+ | `affiliate_reminders` | Affiliate-Erinnerungen | on | subscriber `affiliate-consignment-reminder-due` | jobs, Admin routes, already created messages |
889
+
890
+ With the plugin switched off the three jobs return immediately, so nothing
891
+ is sent, retried or reconciled until it is switched on again; then the jobs
892
+ pick up the stored messages (a message whose payload expired in the
893
+ meantime, such as a password-reset link, is not sent). A toggle read
894
+ failure fails the subscriber with its fixed sentinel (the Event Bus retries
895
+ it) or fails the job run.
896
+
897
+ The plugin declares one setting, edited in the Einstellungen section of its
898
+ Admin page: `logo` (type `image`, optional), the PNG/JPG shown on top of every
899
+ mail. It is not a Shop-Stammdaten field; Documents has its own `logo`.
900
+
901
+ Admin surfaces of a disabled feature are hidden. The E-Mail settings page
902
+ keeps `PluginFeatureSections` visible (so the plugin can be switched back on)
903
+ and gates the Terms PDF versions and mail log sections below it on the
904
+ plugin switch, showing `PluginFeatureDisabledNotice` in their place while it
905
+ is off.
906
+
907
+ ### Ownership
908
+
909
+ - Documents owns document issuance and publishes immutable document facts.
910
+ - Affiliate owns reminder eligibility and publishes reminder facts.
911
+ - Email resolves recipients, renders content, attaches files, and owns delivery
912
+ status plus the append-only global Terms PDF history.
913
+ - Public HTTP DTOs, schemas, and paths remain in
914
+ `@hartl-services/medusa-food-supplements-contracts`.
915
+
916
+ The base's `/types` and `/config` exports are dedicated server integration
917
+ surfaces for the backend. The Documents and Affiliate event contracts live in
918
+ the Contracts package (`events/documents`, `events/affiliate`). The base exports no DML models, services,
919
+ workflows, migrations, or generated output as a consumer contract.
920
+
921
+ ### Event Bus
922
+
923
+ The backend owns Event Bus selection. Development without `REDIS_URL` leaves
924
+ Medusa's local Event Bus active. Configuring `REDIS_URL` registers
925
+ `@medusajs/event-bus-redis` 2.19.0 directly; production requires an
926
+ authenticated `rediss://` URL unless the operator explicitly accepts private
927
+ network transport risk with `REDIS_ALLOW_INSECURE_PRIVATE_NETWORK=true`.
928
+
929
+ Redis Event Bus jobs have at most three total attempts with exponential backoff
930
+ starting at one second. Completed jobs are removed, while failed jobs are kept
931
+ for at most one hour and 1,000 entries. There is no custom Event Bus wrapper,
932
+ startup capability probe, or purge job. Events are at-least-once facts and can
933
+ be replayed, delayed, or delivered out of order.
934
+
935
+ ### Event Contracts
936
+
937
+ All payload objects are strict. Unless noted otherwise, every string shown
938
+ below is trimmed and non-empty. Native event names and shapes are fixed to the
939
+ installed Medusa 2.19 exports:
940
+
941
+ | Event | Payload |
942
+ | ------------------------ | ---------------------------------------------------------------------------------------------- |
943
+ | `order.placed` | `{ id: string }` |
944
+ | `shipment.created` | `{ id: string; no_notification?: boolean }` |
945
+ | `order.canceled` | `{ id: string }` |
946
+ | `order.return_requested` | `{ order_id: string; return_id: string }` |
947
+ | `order.return_received` | `{ order_id: string; return_id: string }` |
948
+ | `auth.password_reset` | `{ entity_id: string; actor_type: string; token: string; metadata?: Record<string, unknown> }` |
949
+
950
+ The shared immutable Documents artifact is:
951
+
952
+ ```ts
953
+ type DocumentsEmailArtifact = {
954
+ artifact_id: string;
955
+ document_id: string;
956
+ document_number: string;
957
+ file_id: string;
958
+ filename: string;
959
+ content_type: "application/pdf";
960
+ };
961
+ ```
962
+
963
+ The versioned project events are exact integration contracts:
964
+
965
+ `documents.shipment_invoice_assignment.decided.v1`
966
+
967
+ ```ts
968
+ type Payload =
969
+ | {
970
+ schema_version: 1;
971
+ order_id: string;
972
+ fulfillment_id: string;
973
+ decision: "non_carrier";
974
+ }
975
+ | {
976
+ schema_version: 1;
977
+ order_id: string;
978
+ fulfillment_id: string;
979
+ decision: "carrier";
980
+ invoice_fulfillment_id: string;
981
+ invoice_artifact: DocumentsEmailArtifact;
982
+ };
983
+ ```
984
+
985
+ For `carrier`, `invoice_fulfillment_id` must equal `fulfillment_id`.
986
+
987
+ `documents.order_cancellation_documents.ready.v1`
988
+
989
+ ```ts
990
+ type Payload = {
991
+ schema_version: 1;
992
+ order_id: string;
993
+ expected_refund_ids: string[];
994
+ attachments: Array<{
995
+ payment_id: string;
996
+ refund_id: string;
997
+ artifact: DocumentsEmailArtifact;
998
+ }>;
999
+ };
1000
+ ```
1001
+
1002
+ `expected_refund_ids` must be sorted and unique. Attachments must be sorted by
1003
+ unique `refund_id`, and every attachment refund must be expected.
1004
+
1005
+ `documents.refund_document.ready.v1`
1006
+
1007
+ ```ts
1008
+ type Payload = {
1009
+ schema_version: 1;
1010
+ order_id: string;
1011
+ payment_id: string;
1012
+ refund_id: string;
1013
+ artifact: DocumentsEmailArtifact | null;
1014
+ };
1015
+ ```
1016
+
1017
+ `affiliate.consignment_report_reminder_due.v1`
1018
+
1019
+ ```ts
1020
+ type Payload = {
1021
+ schema_version: 1;
1022
+ reminder_key: string;
1023
+ affiliate_id: string;
1024
+ customer_id: string | null;
1025
+ affiliate_display_name: string;
1026
+ period_start: string;
1027
+ due_date: string;
1028
+ locale: string | null;
1029
+ trigger: "scheduled" | "manual";
1030
+ };
1031
+ ```
1032
+
1033
+ `period_start` is a valid `YYYY-MM-01`, `due_date` is a valid `YYYY-MM-DD`, and
1034
+ `reminder_key` is exactly
1035
+ `affiliate-consignment:<affiliate_id>:<period_start>`. `locale`, when present,
1036
+ has at most 35 characters. Email runtime-validates every contract before use.
1037
+
1038
+ ### Invariants
1039
+
1040
+ - The accepted kinds are exactly `order.received`, `order.received.admin`, `order.shipped`,
1041
+ `order.canceled`, `order.return_requested`, `order.return_received`,
1042
+ `order.refunded`, `auth.password_reset`, and
1043
+ `affiliate.consignment_report_due`.
1044
+ - Every automatic message has a deterministic business key.
1045
+ - Every placed Order creates independent customer and Shop intents. The customer
1046
+ intent honors `no_notification`; the Shop intent always targets the static
1047
+ role mailbox selected by the Shop configuration.
1048
+ - A new customer `order.received` intent snapshots the newest global Terms PDF
1049
+ whose `effective_at` is not later than the authoritative `order.created_at`.
1050
+ If no version is effective, the customer message is intentionally created
1051
+ without Terms. The Shop `order.received.admin` intent never receives it.
1052
+ - Terms versions and their private files are immutable. Event replays, transport
1053
+ retries, and explicit Admin resends reuse the original message snapshot, so a
1054
+ later or backdated upload never changes an existing message.
1055
+ - Event payloads are runtime-validated before use and may be replayed, delayed,
1056
+ or delivered out of order.
1057
+ - With the Documents `invoices` feature on, shipment and
1058
+ cancellation messages remain pending until both their native Medusa fact
1059
+ and immutable Documents fact arrive. The required facts are fixed when the
1060
+ message is created; later facts reuse them.
1061
+ - Shipment `no_notification` suppresses the complete correlated message,
1062
+ including any invoice attachment; refund email has no Documents-disabled
1063
+ native fallback.
1064
+ - Recipients, rendered content, attachment bytes, SMTP responses, credentials,
1065
+ reset tokens, and reset URLs are never logged.
1066
+ - Reset tokens and reset URLs are never persisted by Email or Notification.
1067
+ - Password-reset messages cannot be retried by the generic retry job or resent
1068
+ by an Admin.
1069
+ - Unknown transport outcomes are never automatically resent.
1070
+ - Shop branding is the configured `displayName` plus the `logo` setting of
1071
+ the E-Mail plugin and the company details of the Shop-Stammdaten; a shop overrides the default layout or
1072
+ single renderers in its own configuration, not in the plugin.
1073
+
1074
+ Read this section and the central design before changing Email domain behavior.
1075
+
1076
+ ### Shop configuration and default templates
1077
+
1078
+ The E-Mail module ships a neutral German layout in grey tones (table-based,
1079
+ inline styles) and one renderer per accepted kind. The layout shows, from top
1080
+ to bottom: the `logo` centered (an `image` setting of the E-Mail plugin,
1081
+ edited on its Admin page; without one, the configured `displayName`), a white card with the subject as heading and the
1082
+ renderer's body, a contact line ("Fragen? Dann schreib uns an … oder ruf uns
1083
+ an unter …", only with `email`/`phone`), and a footer with the company details
1084
+ (`legal_name` or `displayName`, address, UID, Firmenbuchnummer and
1085
+ Firmenbuchgericht, website) plus "Diese E-Mail wurde automatisch versendet.".
1086
+ Missing master data fields are left out. Email reads the logo and the
1087
+ Shop-Stammdaten when it sends a mail, so a change applies to the next mail; if
1088
+ a read fails the mail is sent without that part (logo or company details)
1089
+ instead of failing. Besides
1090
+ the layout, `displayName` appears in the password-reset and invite subjects.
1091
+ A custom `layout` receives the same data as `shop` (`EmailShopDetails`, every
1092
+ field optional) next to `bodyHtml`, `preheader` and `subject`. A shop
1093
+ therefore needs only `displayName`, `affiliatePortalUrl`, `inviteUrl`,
1094
+ `passwordResetUrl` and `orderNotificationEmail`.
1095
+
1096
+ The order confirmation (`order.received`) receives an `OrderSummary` as
1097
+ `summary`: the display ID, the items with quantity and gross amount, the
1098
+ shipping method names, subtotal, shipping, discount, included tax and total,
1099
+ plus the billing and shipping address. Email freezes it when the order is
1100
+ placed, so a resend shows the order as it was placed. `summary` is absent for
1101
+ messages stored before the summary existed; a custom renderer must handle
1102
+ that case.
1103
+
1104
+ The shop's order notification (`order.received.admin`) receives the same
1105
+ `summary` (with the order's `email` and `placed_at`). Its subject names the
1106
+ order number and the customer ("Neue Bestellung #1042 von Ada Beispiel"),
1107
+ an info box lists number, customer, e-mail and order time, followed by an
1108
+ "Im Admin öffnen" button and the items, totals and addresses. Two optional
1109
+ `ShopEmailConfiguration` fields drive it: `adminOrderUrl({ orderId })` (the
1110
+ button is left out without it) and `timeZone` (IANA; the order time is left
1111
+ out without it). `buildFoodShopConfig` sets both (`<MEDUSA_BACKEND_URL>/app/
1112
+ orders/<id>` and its `timeZone` option). The layout receives `audience`
1113
+ (`"shop"` for this kind and `user.invite`, `"customer"` for all others) and
1114
+ shows the contact line only to customers.
1115
+
1116
+ The shipping confirmation (`order.shipped`, one per fulfillment) receives a
1117
+ `ShipmentSummary` as `shipment`, frozen when the shipment fact arrives: the
1118
+ display ID, the shipping option name, the tracking numbers and URLs entered
1119
+ at shipping, the parcel's items (title and variant of the order line,
1120
+ shipped quantity), the delivery address and `more_to_follow` (some ordered
1121
+ quantity is not shipped yet). `invoice_attached` is `true` when Documents chose
1122
+ this shipment for the invoice PDF. Its subject is "Deine Bestellung bei
1123
+ <displayName> ist unterwegs"; a single http(s) tracking URL becomes a
1124
+ "Sendung verfolgen" button, several become one link each. A fulfillment that
1125
+ cannot be read is confirmed without `shipment`, like a message stored before
1126
+ the snapshot existed.
1127
+
1128
+ The cancellation confirmation (`order.canceled`) receives the same
1129
+ `OrderSummary` as `summary` plus `canceled_at`, frozen when the cancellation
1130
+ fact arrives, and `cancellation_attached` (`true` when Documents attached at
1131
+ least one cancellation document). Its subject is "Deine Bestellung #1042 bei
1132
+ <displayName> wurde storniert"; an info box lists the order number and the
1133
+ cancellation date (left out without `timeZone`), followed by a fixed refund
1134
+ note and the canceled items and totals without addresses. An order that
1135
+ cannot be summarized is confirmed without `summary` and without its internal
1136
+ ID.
1137
+
1138
+ The return confirmation (`order.return_requested`) receives a `ReturnSummary`
1139
+ as `summary`, frozen when the return is requested: return and order numbers,
1140
+ the return shipping method, the items to send back (title and variant of the
1141
+ order line, quantity, return reason label) and the return address. It is
1142
+ the stock location's address, addressed to the location's company or else
1143
+ the master data's `legal_name`; without a location address it falls back to
1144
+ the master data's company address. Its subject is "Deine Rücksendung zur
1145
+ Bestellung #1042 bei <displayName>"; the mail asks the customer to enclose
1146
+ the return number (`#R5`). A return that cannot be read is confirmed without
1147
+ `summary` and without internal IDs.
1148
+
1149
+ The return receipt (`order.return_received`, sent once the return is
1150
+ completely received) receives a `ReturnReceivedSummary` as `summary`: return
1151
+ and order numbers, `received_at` and the received items (title and variant
1152
+ of the order line, received quantity and the damaged part of it). Its subject
1153
+ is "Deine Rücksendung zur Bestellung #1042 bei <displayName> ist
1154
+ eingetroffen"; the arrival date needs `timeZone`, damaged units are noted per
1155
+ item, and a fixed sentence says that an eventual refund follows on the
1156
+ original payment method, without promising a separate mail (`order.refunded`
1157
+ only exists with Documents).
1158
+
1159
+ The refund confirmation (`order.refunded`, one per non-cancellation refund,
1160
+ Documents only) receives a `RefundSummary` as `summary`, frozen when the
1161
+ Documents refund fact arrives and read through the refund's payment: order
1162
+ number, refunded gross amount and currency, `refunded_at` and the Admin's
1163
+ refund reason label. `cancellation_attached` is `true` when Documents attached
1164
+ a cancellation document. Its subject is "Deine Erstattung zur Bestellung #1042
1165
+ bei <displayName>"; the info box shows number, amount, date (needs
1166
+ `timeZone`) and reason, followed by a fixed note on bank processing times.
1167
+ Without `summary` the mail keeps the intro and the attachment note.
1168
+
1169
+ The password reset (`auth.password_reset`) has the subject "Neues Passwort
1170
+ für dein Kundenkonto bei <displayName>", a "Neues Passwort festlegen" button,
1171
+ the link as copyable text below it, the link's lifetime ("Der Link ist 15
1172
+ Minuten gültig.", computed from the token's `exp` − `iat` and left out
1173
+ without `iat`) and a note that the mail can be ignored. The lifetime is
1174
+ passed as the ephemeral `valid_minutes` next to `reset_url` and is never
1175
+ persisted.
1176
+
1177
+ The affiliate reminder (`affiliate.consignment_report_due`) greets the
1178
+ affiliate by `affiliate_display_name`, names the report month ("September
1179
+ 2026", Austrian month names) and the due date ("10.10.2026") as calendar
1180
+ values formatted in UTC so no time zone shifts them, explains that the counted
1181
+ stock determines the billed sales, links `affiliatePortalUrl` as a "Zum
1182
+ Partnerbereich" button and says the mail can be ignored once the report is
1183
+ submitted. The button leads to a 404 while the affiliate plugin's
1184
+ `partner_portal` toggle is off.
1185
+
1186
+ The Admin invite (`user.invite`) has the subject "Einladung ins Admin-Team von
1187
+ <displayName>", an "Einladung annehmen" button with the link as copyable text,
1188
+ the invite's current expiry ("Die Einladung ist gültig bis 02.10.2026, 01:40
1189
+ Uhr.", needs `timeZone`; read from the invite at send time, so a resend shows
1190
+ its new expiry) and a note that the mail can be ignored. The expiry is passed
1191
+ as the ephemeral `expires_at` next to `invite_url`. The invite goes to the
1192
+ shop's team, so its layout audience is `"shop"` (no contact line).
1193
+
1194
+ - `layout` (optional) replaces the default layout completely.
1195
+ - `renderers` (optional, `Partial<EmailRendererMap>`) replaces only the listed
1196
+ kinds; every other kind keeps its default renderer.
1197
+
1198
+ ```ts
1199
+ import { defineShopEmailConfiguration } from "@hartl-services/medusa-base/config";
1200
+
1201
+ const shop = defineShopEmailConfiguration({
1202
+ displayName: "Mein Shop",
1203
+ // ...URLs and mailbox as above
1204
+ renderers: {
1205
+ "order.canceled": ({ order_id }) => ({
1206
+ bodyHtml: `<p>Bestellung <strong>${order_id}</strong> storniert.</p>`,
1207
+ subject: "Storniert",
1208
+ text: `Bestellung ${order_id} storniert.`,
1209
+ }),
1210
+ },
1211
+ });
1212
+ ```
1213
+
1214
+ The defaults are merged once when the Email module boots, so an invalid
1215
+ configuration (blank `displayName`, a non-function `layout` or renderer, an
1216
+ unknown renderer kind) fails at startup and not at the first send. Custom
1217
+ renderers must escape dynamic values in `bodyHtml` themselves.
1218
+
1219
+ ### SMTP environment helpers
1220
+
1221
+ `@hartl-services/medusa-base/config` exports helpers for the SMTP
1222
+ deployment variables:
1223
+
1224
+ - `SMTP_ENVIRONMENT_VARIABLES` — the seven variable names.
1225
+ - `SMTP_BUILD_PLACEHOLDERS` — placeholder values for all seven variables so
1226
+ `medusa build` can load `medusa-config.ts` without deployment secrets. Use
1227
+ them only while building.
1228
+ - `resolveSmtp(values, allowInsecure)` — validates the values and returns the
1229
+ `ResolvedSmtpConfiguration` for `createSmtpNotificationModuleConfig`. It
1230
+ rejects a port outside 1–65535, a non-`"true"`/`"false"` boolean and an
1231
+ invalid `SMTP_FROM`. `allowInsecure` permits plain SMTP (development, test,
1232
+ build); otherwise `SMTP_SECURE` or `SMTP_REQUIRE_TLS` must be `true`.
1233
+
1234
+ Runtime requires `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_REQUIRE_TLS`,
1235
+ `SMTP_USER`, `SMTP_PASSWORD`, and `SMTP_FROM`. `SMTP_SECURE=true` selects
1236
+ implicit TLS; `SMTP_REQUIRE_TLS=true` requires STARTTLS. Outside development
1237
+ and test, at least one must be true. Certificate verification is never
1238
+ disabled. Development and test may set both to `false` for an isolated local
1239
+ SMTP server. SMTP endpoints and credentials never belong in the shop
1240
+ configuration.
1241
+
1242
+ ### Delivery Semantics
1243
+
1244
+ For ordinary durable messages, only characterized connection failures,
1245
+ temporary SMTP rejections, and temporary attachment access failures are
1246
+ automatically retried. There are at most three total SMTP attempts, scheduled
1247
+ after one and five minutes. Permanent rejection and invalid content become
1248
+ terminal `failed`.
1249
+
1250
+ `unknown` means SMTP may have accepted a message but Email cannot prove the
1251
+ outcome. A persisted successful Medusa Notification is accepted as proof of
1252
+ delivery; otherwise an expired attempt lease or uncharacterized transport
1253
+ result becomes terminal `unknown` and is never retried automatically. An Admin
1254
+ may consciously resend it as a new logical message.
1255
+
1256
+ Business keys deduplicate logical automatic messages, attempt claims serialize
1257
+ workers, and each attempt has its own Notification idempotency key. These
1258
+ controls do not promise exact-once SMTP: a server can accept mail before the
1259
+ process persists success, and a conscious resend can produce a duplicate.
1260
+
1261
+ Email transport retries and Event Bus retries are separate. Once an ordinary
1262
+ message outcome is persisted, its subscriber completes and the Email retry job
1263
+ owns any `retry_pending` work. Password-reset token and URL data remain
1264
+ ephemeral; password reset is excluded from the generic retry job and Admin
1265
+ resend, and retryable handling stays bounded by the original Event Bus event.
1266
+
1267
+ ### Admin API
1268
+
1269
+ Email exposes authenticated Admin operations at:
1270
+
1271
+ - `GET /admin/email/messages` lists safe message summaries. Pagination is
1272
+ bounded and deterministic; `kind` and `status` accept only the published
1273
+ allowlists.
1274
+ - `GET /admin/email/messages/:id` returns the safe message detail and delivery
1275
+ attempts.
1276
+ - `POST /admin/email/messages/:id/resends` makes one explicit resend decision
1277
+ using a request UUID.
1278
+ - `GET /admin/email/terms-versions` lists safe Terms version metadata in newest
1279
+ effective order.
1280
+ - `POST /admin/email/terms-versions` idempotently stores one private PDF of at
1281
+ most 5 MiB with its `effective_at` timestamp.
1282
+ - `GET /admin/email/terms-versions/:id/download` streams one authenticated,
1283
+ non-cacheable PDF download.
1284
+
1285
+ List and detail responses expose only safe identifiers and linkage metadata,
1286
+ kind/status and safe status codes, resource references, trigger, attempt
1287
+ counts, and timestamps. They never expose recipients, rendered content,
1288
+ template data, payloads, reset tokens, transport responses, or credentials.
1289
+ Terms responses expose filename, effective timestamp, byte size, SHA-256, and
1290
+ creation time, but never request IDs, file IDs, storage keys, URLs, or content.
1291
+
1292
+ An Admin resend is accepted only for a `sent`, `unknown`, or terminal `failed`
1293
+ source. Password-reset messages can never use the generic resend operation.
1294
+ The authenticated Admin actor is derived by the server and is not accepted
1295
+ from the request body. Replaying the same request UUID returns the same resend;
1296
+ a different UUID represents a separate, conscious Admin decision. The Admin UI
1297
+ therefore retains a UUID while retrying an ambiguous failed request and creates
1298
+ a new UUID only after a new confirmation.
1299
+
1300
+ ### Admin UI
1301
+
1302
+ The E-Mail module has one Admin page, `/settings/hartl-services/email`
1303
+ (**Settings → Hartl Services Plugins → E-Mail → Öffnen**; the descriptor's
1304
+ `admin_page` points the hub there) instead of a top-level sidebar item. It
1305
+ composes the base's `PluginPageShell` and `PluginFeatureSections` from
1306
+ `@hartl-services/medusa-base/admin-components` (the toggles above) and adds
1307
+ two operations sections below them, in this order: **AGB/PDF-Versionen** and,
1308
+ as the last section, the **E-Mail-Protokoll**. They show Terms PDF and mail
1309
+ log state to act on, not configuration the plugin reads at run time. The mail
1310
+ log provides bounded server pagination, allowlisted status/kind filters, safe
1311
+ message and attempt status, and an explicitly confirmed resend action.
1312
+ Recipient, template, and payload data are intentionally absent from the page.
1313
+
1314
+ The `AGB/PDF-Versionen` section manages the global Terms PDFs. Admins can
1315
+ upload a PDF of at most 5 MiB with its effective date, review versions newest
1316
+ first, and download a selected version. Uploads receive a fresh request UUID;
1317
+ downloads use the configured authenticated Admin SDK and never expose storage
1318
+ file IDs or URLs in the UI.
1319
+
1320
+ Admin consumers import wire DTOs and paths only from the Contracts package and
1321
+ reuse their configured Medusa SDK:
1322
+
1323
+ ```ts
1324
+ import type { ListAdminEmailMessagesResponse } from "@hartl-services/medusa-food-supplements-contracts/types/email-admin";
1325
+ import { emailAdminPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/email";
1326
+
1327
+ const response = await sdk.client.fetch<ListAdminEmailMessagesResponse>(
1328
+ emailAdminPaths.get.list,
1329
+ );
1330
+ ```
1331
+
1332
+ Do not import E-Mail module implementations, generated `.medusa` files, or
1333
+ duplicate runtime schemas in an Admin consumer. The base's own exports remain
1334
+ internal server and generated Admin integration surfaces; Email HTTP contracts
1335
+ are published only by `@hartl-services/medusa-food-supplements-contracts`.
1336
+
1337
+ ### Development and Rollout
1338
+
1339
+ For local development, provide the seven SMTP variables and omit `REDIS_URL` to
1340
+ use Medusa's local Event Bus. Set `REDIS_URL` only when intentionally exercising
1341
+ the direct Redis provider. The backend HTTP runner ignores ambient Redis and
1342
+ SMTP settings and injects an isolated Redis container, unique queue, and
1343
+ token-protected SMTP fixture for every child.
1344
+
1345
+ This architecture replaces the old Documents and Affiliate delivery baselines
1346
+ without a backfill or compatibility layer. Existing local databases from those
1347
+ baselines must be wiped once with:
1348
+
1349
+ ```bash
1350
+ pnpm --dir apps/backend db:recreate
1351
+ ```
1352
+
1353
+ The first production rollout must start from an empty database and run
1354
+ `pnpm --dir apps/backend db:migrate` before accepting traffic. This assumes no
1355
+ production business data exists; a populated database requires a separately
1356
+ reviewed migration. No old delivery rows or queued jobs are migrated or purged
1357
+ by Email.
1358
+
1359
+ ## Dokumente
1360
+
1361
+ The base issues order documents: one total invoice per order after the final
1362
+ shipment and one negative cancellation document for each eligible payment
1363
+ refund. An Admin user can regenerate issued documents in place for exceptional
1364
+ corrections. The base ships default German document markup, so a new shop needs
1365
+ no template code; a project can optionally replace the HTML/CSS factory. PDF
1366
+ generation, private file storage, customer authorization and durable
1367
+ document-readiness facts stay inside the `documents` module. There is no
1368
+ separate documents package since 1.0.0.
1369
+
1370
+ ### What it does
1371
+
1372
+ - Listens to Medusa's `shipment.created` event.
1373
+ - Creates an invoice only if the order is fully shipped and not cancelled.
1374
+ Partial shipments do not create a document.
1375
+ - Saves the rendered PDF and the exact source data as a document snapshot.
1376
+ - Reads eligible B2B evidence frozen under the order ID from Tax Compliance and
1377
+ persists the customer company, customer UID and exemption wording in that
1378
+ same snapshot. B2C and non-eligible evidence add no tax-proof statement.
1379
+ - Freezes a tax breakdown (`totals.tax_breakdown`) grouped by the order's
1380
+ item and shipping tax-line name and rate, as configured on the Medusa tax
1381
+ region. Amounts are rounded to the currency and add up exactly to the
1382
+ tax total; cancellations scale it proportionally. Snapshots issued before
1383
+ this field existed read as an empty breakdown.
1384
+ - Persists one immutable decision per shipped Fulfillment. A leased, Order-
1385
+ unique coordinator chooses exactly one invoice carrier without assuming
1386
+ event delivery order; all other shipments become `non_carrier`.
1387
+ - Uses a durable per-order issuance key and token-fenced finalization, so
1388
+ duplicate, concurrent or delayed events cannot create a second initial
1389
+ invoice or committed carrier.
1390
+ - Allocates financial document numbers from Documents-owned, transactional
1391
+ annual number ranges. Invoice, cancellation and future credit-note ranges are
1392
+ independent of Medusa Order display IDs and safe across backend workers.
1393
+ - Lets an Admin regenerate the invoice in place with a fresh order and active
1394
+ batch-allocation snapshot.
1395
+ - Listens to Medusa's successful `payment.refunded` event and issues one
1396
+ cancellation document per persisted refund, including partial refunds.
1397
+ - Uses the immutable Refund ID as both cancellation issuance key and resolution
1398
+ identity, so replayed payment events cannot create duplicate cancellation
1399
+ documents or readiness facts.
1400
+ - Reconciles the complete Refund set after Order cancellation, including Medusa
1401
+ bulk refunds that do not emit a singular refund event. A canceled Order with
1402
+ zero Refunds still receives a final empty cancellation resolution.
1403
+ - Publishes strict versioned shipment, Refund and Order-cancellation facts only
1404
+ after their database state and referenced immutable artifacts are committed.
1405
+ Publication markers and periodic reconciliation close the database-to-event
1406
+ enqueue gap.
1407
+ - Exposes only an authenticated customer Store API. A customer can list,
1408
+ inspect and download only documents belonging to their own customer ID.
1409
+ Guest documents deliberately have no Store lookup path.
1410
+ - Adds an Admin order-detail widget. Authenticated Admin users can view the
1411
+ issued documents belonging to the current order as a private inline PDF.
1412
+ - Supports `invoice`, `cancellation` and `credit_note` document kinds.
1413
+ Cancellation issuance is wired to payment refunds; `credit_note` remains a
1414
+ reserved type for a future, distinct business process.
1415
+ - Creates one internal packing list for every physical fulfillment, including
1416
+ optional fulfillment-scoped batch data. Partial fulfillments therefore
1417
+ produce independent packing lists.
1418
+ - Keeps packing lists in a separate Admin-only aggregate. They have no customer
1419
+ owner, Store route, communication artifact or customer-facing Documents event.
1420
+ - Reconciles non-canceled physical fulfillments periodically so a lost local
1421
+ event can be repaired idempotently, including after shipment.
1422
+
1423
+ ### Configuration
1424
+
1425
+ `@hartl-services/medusa-tax-compliance` is a required *registered plugin*,
1426
+ intentionally not a package dependency of the base: install it and list it in
1427
+ `medusa-config.ts` yourself. The invoice workflow resolves the
1428
+ `tax_compliance` module with no fallback; a shop that does not register Tax
1429
+ Compliance fails at invoice issuance time (the first `shipment.created`
1430
+ event) with an `UNEXPECTED_STATE` error naming the missing plugin, not at
1431
+ startup.
1432
+
1433
+ ```ts
1434
+ export default defineConfig({
1435
+ modules: [
1436
+ {
1437
+ resolve: "@medusajs/medusa/tax",
1438
+ options: {
1439
+ providers: [
1440
+ {
1441
+ resolve:
1442
+ "@hartl-services/medusa-tax-compliance/providers/tax-compliance",
1443
+ },
1444
+ ],
1445
+ },
1446
+ },
1447
+ ],
1448
+ plugins: [
1449
+ {
1450
+ resolve: "@hartl-services/medusa-base",
1451
+ options: {
1452
+ documents: {
1453
+ numbering: {
1454
+ timeZone: "Europe/Vienna", // default when omitted
1455
+ },
1456
+ pdf: {
1457
+ executablePath: process.env.DOCUMENT_PDF_EXECUTABLE, // default: "google-chrome"
1458
+ },
1459
+ },
1460
+ },
1461
+ },
1462
+ {
1463
+ resolve: "@hartl-services/medusa-tax-compliance",
1464
+ options: {
1465
+ merchant: {
1466
+ countryCode: process.env.MERCHANT_COUNTRY_CODE!,
1467
+ vatNumber: process.env.MERCHANT_VAT_NUMBER!,
1468
+ },
1469
+ },
1470
+ },
1471
+ ],
1472
+ });
1473
+ ```
1474
+
1475
+ Options of the `documents` namespace (all optional; `{}` is valid):
1476
+
1477
+ - `htmlFactory` — optional. A `(input: DocumentRenderInput) => string |
1478
+ Promise<string>` function that replaces the built-in German markup; the
1479
+ module owns PDF rendering and storage either way. When omitted, the module
1480
+ uses `createDefaultDocumentHtmlFactory({ timeZone })` with the resolved
1481
+ `numbering.timeZone`, so printed dates are shown in the same time zone that
1482
+ picks the numbering period. A value that is not a function is rejected at
1483
+ startup.
1484
+ - `numbering.timeZone` — an IANA time zone used to resolve the annual
1485
+ numbering period and the dates printed by the default markup. Defaults to
1486
+ `"Europe/Vienna"`; a given value (also a blank one) is validated and
1487
+ rejected at startup when invalid.
1488
+ - `pdf.executablePath` — Chromium/Chrome executable path or command name.
1489
+ Defaults to `"google-chrome"`.
1490
+ - `merchant` — optional fallback for the shop master data (see "Merchant
1491
+ data" below), shape `DocumentMerchantOption`:
1492
+ `{ countryCode, legalName, registeredAddress: { address1, address2?, city,
1493
+ countryCode, postalCode }, vatNumber? }`.
1494
+
1495
+ The `documents` module is always registered with the base (no `documentsEnabled`
1496
+ switch); the Admin switches it off at run time through the feature toggles
1497
+ below. `medusa db:migrate` creates its tables.
1498
+
1499
+ ### The `/config` subpath
1500
+
1501
+ `@hartl-services/medusa-base/config` exports, next to the environment helpers,
1502
+ build-time values for `medusa-config.ts` and tests:
1503
+
1504
+ - `createDefaultDocumentHtmlFactory({ timeZone, theme? })` — the built-in
1505
+ factory, to use as it is, with a shop theme (see "Layout, logo, payment
1506
+ terms and shop theme" below) or to wrap.
1507
+ - `DOCUMENTS_BUILD_PLACEHOLDERS` — `{ DOCUMENT_PDF_EXECUTABLE: "google-chrome" }`.
1508
+ `medusa build` evaluates `medusa-config.ts` without the runtime-only
1509
+ environment, so spread it under the real environment while building.
1510
+
1511
+ The document types (`DocumentMerchantOption`, `DocumentHtmlFactory`,
1512
+ `DocumentRenderInput`, ...) come from `@hartl-services/medusa-base/types`.
1513
+
1514
+ ### Layout, logo, payment terms and shop theme
1515
+
1516
+ The default markup prints an A4 letterhead: logo top left, document title and
1517
+ key data (number, order, date, customer UID for exempt B2B supplies) on the
1518
+ right, the billing address on the left, the item table with batch and
1519
+ best-before date per line, the totals below the price columns and a
1520
+ three-column footer (sender with UID and company register, contact, bank) on
1521
+ every page. Packing lists show only the sender in the footer. Every page
1522
+ carries "Seite x von y" bottom right in an 8 mm bottom page margin
1523
+ (`@page { @bottom-right }`; the page count exists only in page margin boxes).
1524
+
1525
+ Settings under Settings → "Hartl Services Plugins" → **Dokumente**, read on
1526
+ every render and passed as `input.settings` (`DocumentRenderSettings`). Like
1527
+ the merchant data they are not part of the snapshot: a regenerated document
1528
+ shows the current values.
1529
+
1530
+ - **Logo** (`logo`, PNG or JPG): each render downloads it (10 s timeout, at
1531
+ most 2 MiB) and passes it inline as `settings.logo_src` (`data:` URL), so
1532
+ Chromium never waits on the network. Without a logo, or when the download
1533
+ fails (logged as a warning), the document shows the merchant's legal name;
1534
+ a logo never blocks issuance.
1535
+ - **Zahlungskonditionen** (`payment_terms`, text): printed below the totals
1536
+ of invoices only (not on cancellations or credit notes); blank means none.
1537
+ Line breaks in the text are kept.
1538
+
1539
+ **Theme:** `createDefaultDocumentHtmlFactory({ timeZone, theme })` keeps the
1540
+ content and markup and changes only the look. `theme.stylesheet` is CSS
1541
+ appended after the default stylesheet (later rules win; the defaults use the
1542
+ custom properties `--accent`, `--text`, `--muted`, `--line`, `--table-head`,
1543
+ `--page-x`, `--page-top`, `--footer-height`). Put a watermark or other
1544
+ full-page image into `@page { background: ... / 210mm 297mm }`: it covers the
1545
+ whole sheet on every page, whereas Chromium clips fixed elements at the page
1546
+ margin and sizes an inline `<svg>` in them wrongly from the second page on.
1547
+ Restyle the page number with `@page { @bottom-right { ... } }`. Embed web
1548
+ fonts as `data:` URLs; the container's Chromium has only its system fonts. A
1549
+ shop passes its factory through the preset option `documentHtmlFactory` (see
1550
+ "Shop-Config-Preset").
1551
+
1552
+ ### Merchant data
1553
+
1554
+ The legal sender printed on every document comes from the base plugin's shop
1555
+ master data (Admin → Settings → Hartl Services Plugins → **Shop-Stammdaten**).
1556
+ Each render reads it and passes it to the `htmlFactory` as `input.merchant`
1557
+ (`DocumentMerchant`: `legal_name`, `address_line1`, `address_line2`,
1558
+ `postal_code`, `city`, `country_code`, `vat_number`, `email`, `phone`,
1559
+ `website`, `iban`, `bic`, `company_register_number`,
1560
+ `company_register_court`; unset optional fields are `null`). Per field, a
1561
+ value stored in the Admin wins over the `merchant` option.
1562
+
1563
+ - `merchant` is not part of the stored snapshot: a regenerated document
1564
+ prints the master data current at regeneration time.
1565
+ - The development UID `ATU00000000` arrives as `vat_number: null` and is
1566
+ never printed.
1567
+ - Without `legal_name`, `address_line1`, `postal_code`, `city` and
1568
+ `country_code` a document cannot be issued: issuance fails with
1569
+ `UNEXPECTED_STATE` ("Shop-Stammdaten unvollständig: …" naming the missing
1570
+ fields). Invoices and cancellations check this before a document number
1571
+ and issue date are reserved, so no number is used up: the invoice workflow
1572
+ fails (the subscriber logs it, the shipment assignment stays pending), a
1573
+ refund resolution is marked failed. A packing list revision is marked
1574
+ failed with the message, which the Admin order widget shows. Once the data
1575
+ is complete, the existing paths issue it again (event redelivery,
1576
+ reconciliation jobs, the Admin issue and retry actions). A missing UID
1577
+ does not block issuance.
1578
+
1579
+ Requirements/couplings:
1580
+
1581
+ - A registered Tax Compliance module (`tax_compliance`) as shown above.
1582
+ - A Chromium/Chrome executable and a Medusa File provider that supports
1583
+ private files (see "Development" below for the sandbox/non-root note).
1584
+ Documents stores PDFs through `Modules.FILE` and streams them only through
1585
+ its own authenticated routes, so it works with any File provider and does
1586
+ not need the S3 file storage or its `/files` routes.
1587
+ **With Medusa's local File provider, set `private_upload_dir` to a
1588
+ directory outside `static/`:** its default stores private files in
1589
+ `static/`, which Medusa serves publicly under `/static`, making every
1590
+ invoice downloadable by its guessable file name. The base plugin still
1591
+ starts in that case (it ships the Documents module to every shop), but logs
1592
+ a warning at startup and refuses to write any document: issuing an invoice
1593
+ or packing list fails until the File Module stores private files outside
1594
+ `static/`.
1595
+ - Optionally, `@hartl-services/medusa-batch-management` 0.0.3+ for
1596
+ batch/lot data on invoices and packing lists (see "Optional Batch
1597
+ Management integration" below).
1598
+
1599
+
1600
+ ### Feature toggles
1601
+
1602
+ The Documents module declares its toggles to the base under the plugin key
1603
+ `documents`. An Admin switches them under Settings → "Hartl
1604
+ Services Plugins"; Storefronts read the effective values from
1605
+ `GET /store/plugin-features/documents`. A disabled plugin or feature answers
1606
+ its routes with HTTP 404, and its subscribers and jobs return without doing
1607
+ anything. Store routes answer 404 even without a customer login (the gate runs
1608
+ before the route's authentication); Admin routes still require an Admin login
1609
+ first, because Medusa authenticates every `/admin` request before any plugin
1610
+ middleware (401 without login, 404 afterwards). A feature is only effective while the plugin
1611
+ switch (default on) is on.
1612
+
1613
+ | Key | Label | Default | Gates | Keeps running when off |
1614
+ | --------------- | -------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
1615
+ | (plugin switch) | Dokumente | on | every Documents route (`/store/documents/*`, `/admin/orders/:id/documents/*`, `/admin/orders/:id/packing-lists/*`) and, through the two features, every subscriber and job | checkout, fulfillment and shipping; already issued documents and PDFs stay stored |
1616
+ | `invoices` | Rechnungen & Stornos | on | `/store/documents/*`, `/admin/orders/:id/documents/*`; subscribers `documents-shipment-created`, `documents-order-canceled`, `payment-refunded`; jobs `documents-fact-publication-reconciliation`, `documents-domain-resolution-reconciliation` | packing lists; stored invoices stay stored but are not reachable through the API |
1617
+ | `packing_lists` | Packlisten | on | `/admin/orders/:id/packing-lists/*`; subscribers `packing-list-fulfillment-created`, `packing-list-fulfillment-canceled`, `packing-list-allocation-revised`; job `documents-packing-list-reconciliation` | invoices, cancellations and refund documents |
1618
+
1619
+ **Events are not replayed.** While `invoices` is off, no invoice,
1620
+ cancellation or refund document is issued for new shipments, cancellations
1621
+ or refunds, and nothing is issued later for them either once the feature is
1622
+ switched back on; issue a missing invoice by hand with
1623
+ `POST /admin/orders/:id/documents`. While `packing_lists` is off, no packing
1624
+ list is created, revised or canceled; after switching it back on, the
1625
+ periodic `documents-packing-list-reconciliation` job creates the missing
1626
+ packing lists of non-canceled physical fulfillments (cancellations that
1627
+ happened meanwhile are not replayed).
1628
+
1629
+ Admin surfaces of a disabled feature are hidden. The Admin widgets on the
1630
+ order detail page (`order-documents`, `order-packing-lists`) stay registered
1631
+ but render nothing while their feature (`invoices`, `packing_lists`) is off,
1632
+ using `PluginFeatureGate` from
1633
+ `@hartl-services/medusa-base/admin-components`.
1634
+
1635
+ No workflow hook and nothing on the cart, checkout or fulfillment path reads
1636
+ a toggle.
1637
+
1638
+ ### Shop integration
1639
+
1640
+ The demo shop (`apps/backend`) gets everything from the preset (see
1641
+ "Shop-Config-Preset"): `documents.merchant` is its `merchant` option,
1642
+ `documents.numbering.timeZone` its `timeZone` option and
1643
+ `documents.pdf.executablePath` the `DOCUMENT_PDF_EXECUTABLE` variable. The
1644
+ development UID `ATU00000000` is a placeholder: Documents never prints it,
1645
+ and Tax Compliance does not transmit it to FinanzOnline. Enter the real
1646
+ merchant UID in the Shop-Stammdaten before issuing production invoices.
1647
+
1648
+ `htmlFactory` is intentionally the only presentation extension point. It gets
1649
+ a fully typed value snapshot (`DocumentTemplateInput` or
1650
+ `PackingListTemplateInput`) plus `merchant` (see "Merchant data"), never a
1651
+ live Medusa model. Escape every dynamic value in the factory; the HTML is
1652
+ rendered by a headless Chromium process before it is stored as a private PDF.
1653
+
1654
+ The consuming host needs a Chromium/Chrome executable and a Medusa File
1655
+ provider that supports private files. Run the Medusa process as a dedicated
1656
+ non-root user: the PDF renderer keeps Chromium's sandbox enabled and therefore
1657
+ refuses to start as root. Installation and Dokploy runtime checks are
1658
+ documented under `Chromium für Dokument-PDFs` in `apps/backend/README.md`.
1659
+ Each render uses an ephemeral Chrome profile below its private temporary
1660
+ directory and removes it afterward. The Chromium child is also disconnected
1661
+ from the user's session bus and legacy keyring socket, so it cannot access the
1662
+ operator's normal browser profile or desktop keyring, including under WSLg.
1663
+
1664
+ ### Restore check for private files
1665
+
1666
+ After restoring the object store (or the local file directory), check that
1667
+ every issued document's PDF can be read through the active Medusa File
1668
+ provider. The read-only script lives in the base but is deliberately not a
1669
+ migration script, so `db:migrate` never runs it:
1670
+
1671
+ ```bash
1672
+ pnpm exec medusa exec ./node_modules/@hartl-services/medusa-base/.medusa/server/src/documents/scripts/verify-documents-file-storage.js
1673
+ ```
1674
+
1675
+ It fails on the first issued document without a stored file and otherwise
1676
+ logs the number of verified files and bytes.
1677
+
1678
+ ### Clean baseline and first deployment
1679
+
1680
+ The module intentionally contains one generated eight-table baseline,
1681
+ `Migration20260827061749`. It removes the former `documents_delivery` table
1682
+ entirely and adds the Artifact, Shipment-assignment/coordinator and Refund-/
1683
+ Order-cancellation resolution tables. Old delivery rows are not migrated:
1684
+ Email now owns logical messages and attempts. The generated snapshot is not
1685
+ edited by hand, while migration-only constraints and indexes remain in the
1686
+ baseline.
1687
+
1688
+ `Migration20260830111207` is the forward migration that adds the
1689
+ `documents_document_number_sequence` table, makes every financial document's
1690
+ number and issue date mandatory, and protects the complete document number with
1691
+ an unconditional unique index. It deliberately does not renumber legacy rows.
1692
+ A disposable local database containing pre-migration Documents rows with null
1693
+ or duplicate numbers must be recreated rather than silently backfilled.
1694
+
1695
+ A disposable local database that has already run an older Documents baseline
1696
+ must be recreated once after stopping the backend and verifying the exact local
1697
+ `DATABASE_URL` target:
1698
+
1699
+ ```bash
1700
+ pnpm --dir apps/backend db:recreate
1701
+ ```
1702
+
1703
+ This command deletes all data and is restricted to local development. Never use
1704
+ it for staging, shared or production databases; those require an
1705
+ environment-owner-approved reset. A first production installation is supported
1706
+ only on a deliberately empty database. Run:
1707
+
1708
+ ```bash
1709
+ pnpm --dir apps/backend db:migrate
1710
+ pnpm --dir apps/backend exec medusa db:sync-links
1711
+ ```
1712
+
1713
+ `db:migrate` applies all module baselines and Medusa's pending initial seed once;
1714
+ do not invoke that seed separately. If any production database has already run
1715
+ an older Documents baseline or contains data that must survive, stop: this clean
1716
+ baseline is not a forward migration or backfill and a new forward migration is
1717
+ required instead.
1718
+
1719
+ Apply normal pending module migrations after later changes or upgrades with
1720
+ `pnpm --dir apps/backend db:migrate`. This plugin defines no module links, so it
1721
+ does not require link synchronization by itself; `db:sync-links` above is the
1722
+ safe shop-wide first-deployment step for links owned elsewhere.
1723
+
1724
+ ### Storefront contract
1725
+
1726
+ All three endpoints require a customer session or bearer token. There is no
1727
+ guest lookup endpoint and no direct File-provider URL.
1728
+
1729
+ - `GET /store/documents` – document summaries for the authenticated customer.
1730
+ - `GET /store/documents/:id` – one owned document summary.
1731
+ - `GET /store/documents/:id/download` – streams the owned private PDF.
1732
+
1733
+ The public DTO deliberately includes only the document number, kind, issue
1734
+ date, order display ID and total. It excludes the immutable snapshot, recipient
1735
+ address/email, file and artifact IDs, leases and resolution state.
1736
+
1737
+ For a typed Storefront integration:
1738
+
1739
+ ```ts
1740
+ import type { ListStoreDocumentsResponse } from "@hartl-services/medusa-food-supplements-contracts/types/documents-store";
1741
+ import { documentsPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/documents";
1742
+
1743
+ const { documents: issuedDocuments } =
1744
+ await sdk.client.fetch<ListStoreDocumentsResponse>(
1745
+ documentsPaths.store.get.list,
1746
+ { method: "GET" },
1747
+ );
1748
+ const downloadPath = documentsPaths.store.get.download(issuedDocuments[0].id);
1749
+ ```
1750
+
1751
+ The Storefront should request `downloadPath` with its authenticated SDK;
1752
+ it is deliberately a protected application route rather than an exposed object
1753
+ storage URL.
1754
+
1755
+ The same Contracts boundary applies to all Documents and Tax Compliance public
1756
+ routes: import transport DTOs only from
1757
+ `@hartl-services/medusa-food-supplements-contracts/types/<feature>`, paths only from
1758
+ `@hartl-services/medusa-food-supplements-contracts/paths/<feature>`, and call the
1759
+ Storefront's configured Medusa SDK. A consumer that needs a runtime guard may
1760
+ opt in to `@hartl-services/medusa-food-supplements-contracts/schemas/<feature>`; it must
1761
+ not import either the plugin implementation or generated `.medusa` output.
1762
+
1763
+ ### Admin order detail
1764
+
1765
+ When Documents is enabled, the Medusa Admin order detail shows a
1766
+ **Dokumente** widget. It lists the issued documents for that order and opens a
1767
+ private PDF preview. The widget obtains the PDF through the Medusa JS SDK, so
1768
+ both session and bearer-token Admin authentication work without exposing a
1769
+ File-provider URL.
1770
+
1771
+ The Admin API is deliberately scoped by both order and document ID:
1772
+
1773
+ - `GET /admin/orders/:id/documents`
1774
+ - `POST /admin/orders/:id/documents/regenerate`
1775
+ - `POST /admin/orders/:id/documents/:document_id/regenerate`
1776
+ - `GET /admin/orders/:id/documents/:document_id/download`
1777
+
1778
+ All authenticated Admin users can inspect order documents; a document from a
1779
+ different order returns `404` from the download endpoint.
1780
+
1781
+ #### Internal packing lists
1782
+
1783
+ The separate **Packlisten** order widget lists every physical fulfillment in
1784
+ creation order. A ready entry can be previewed and downloaded as a private PDF;
1785
+ missing or failed entries can be generated again through the same idempotent
1786
+ workflow used by the automatic subscriber.
1787
+
1788
+ - `GET /admin/orders/:id/packing-lists`
1789
+ - `POST /admin/orders/:id/packing-lists/:fulfillment_id/retry`
1790
+ - `GET /admin/orders/:id/packing-lists/:fulfillment_id/download`
1791
+
1792
+ The PDF freezes the fulfillment-item title, SKU, quantity and Medusa Stock
1793
+ Location name. Managed variants with multiple inventory items are listed as
1794
+ their concrete warehouse components. Their quantity is the concrete inventory
1795
+ quantity after Medusa's `required_quantity` multiplier, not necessarily the
1796
+ commercial order-line quantity.
1797
+
1798
+ Packing lists never pass through the Documents customer-fact pipeline. They are
1799
+ also stored outside `documents_document`, so the authenticated Store document
1800
+ queries cannot expose them accidentally.
1801
+
1802
+ #### Regenerating an invoice
1803
+
1804
+ Once an invoice exists, the widget changes **Rechnung ausstellen** to
1805
+ **Rechnung regenerieren**. This action retains the document ID, document number
1806
+ and original issue date, but replaces its persisted PDF and snapshot with a
1807
+ fresh render. A prior artifact that is locked by a committed communication fact
1808
+ and its private File are retained permanently. Only an unlocked superseded
1809
+ artifact File may be deleted on a best-effort basis after the replacement has
1810
+ been stored, so Storefront and Admin continue to show exactly one current PDF.
1811
+
1812
+ Regeneration re-applies the normal completed-shipment and cancellation checks,
1813
+ then reads the current order values and active batch withdrawals. It is a
1814
+ deliberate exception workflow and does not emit a customer communication event.
1815
+
1816
+ The document-specific regeneration route is used by the widget for every
1817
+ issued document. For invoices it has the same live-data behavior described
1818
+ above. A cancellation or future credit note is instead rendered from its
1819
+ persisted snapshot, retaining its original amount, source invoice reference,
1820
+ number and issue date. It also emits no customer communication event.
1821
+
1822
+ #### Financial document numbers
1823
+
1824
+ Documents assigns numbers in these annual ranges:
1825
+
1826
+ ```text
1827
+ RE-2026-1
1828
+ ST-2026-1
1829
+ GS-2026-1
1830
+ ```
1831
+
1832
+ The prefix identifies the document kind, the year is resolved in the selected
1833
+ shop's configured numbering time zone, and the final integer is allocated by a
1834
+ transactional PostgreSQL counter. A new range starts at `1` for each document
1835
+ kind and year. `order_display_id` remains a separate order reference in the
1836
+ snapshot and PDF; it never determines a financial document number.
1837
+
1838
+ Number and issue date are reserved in the same short transaction that creates
1839
+ the processing document. PDF rendering and File I/O remain outside database
1840
+ transactions. A retry reuses the reserved identity and regeneration never
1841
+ increments a range. A permanently failed document retains its number and
1842
+ failure reason as the explanation for that range's gap; reserved numbers are
1843
+ never recycled or reassigned.
1844
+
1845
+ #### Cancellation documents for payment refunds
1846
+
1847
+ Medusa's refund event identifies the payment, so the plugin enumerates its
1848
+ persisted refund records and claims one cancellation document using each
1849
+ immutable refund ID. This makes delivery at-least-once safe: repeated events
1850
+ or a retry leave an already issued cancellation unchanged.
1851
+
1852
+ When an invoice exists, the cancellation issue date is reserved when Documents
1853
+ starts issuing the cancellation and its amount is negative. Since a payment
1854
+ refund has no line-item allocation, the plugin proportionally derives the
1855
+ original invoice's subtotal and tax from the
1856
+ immutable invoice snapshot; the document has one explicit `Rückerstattung zu
1857
+ Rechnung …` line. The document, immutable artifact and final Refund resolution
1858
+ are committed in one short token-fenced transaction.
1859
+
1860
+ For a non-canceled Refund without an existing invoice, Documents finalizes a
1861
+ `not_applicable` resolution and publishes the Refund fact without an artifact.
1862
+ It does not wait forever for a future invoice, and later invoice issuance does
1863
+ not create a second customer fact. For a canceled Order, individual Refund
1864
+ facts are suppressed in favor of the authoritative Order-cancellation
1865
+ resolution and its sorted artifact list.
1866
+
1867
+ ### Optional Batch Management integration
1868
+
1869
+ If `@hartl-services/medusa-batch-management` version `0.0.3` or later is registered, Documents detects
1870
+ its `getOrderDocumentBatchSnapshot(orderId)` capability structurally. For every
1871
+ active withdrawal it prints the persisted **charge/lot**, **MHD** and quantity
1872
+ under the matching invoice line.
1873
+
1874
+ These values are captured at FEFO withdrawal time. Regenerating an invoice
1875
+ reads the active withdrawals again: reverse/correct the order's allocation
1876
+ first, then regenerate to put that corrected allocation on the document.
1877
+ Renaming a Batch master record alone deliberately does not change an already
1878
+ persisted withdrawal snapshot. A shipment received before the matching durable
1879
+ batch-withdrawal run is available fails safely and can be retried. An installed
1880
+ incompatible Batch version fails explicitly before a PDF is issued; upgrade
1881
+ Batch Management and apply its migration first.
1882
+
1883
+ Packing lists use the separate structural capability
1884
+ `getFulfillmentPackingListBatchSnapshot(fulfillmentId)`. It returns only active
1885
+ or already returned immutable LOT snapshots belonging to that exact
1886
+ fulfillment. Return receipts reconstruct the original picked quantity before a
1887
+ delayed first render. Documents validates that every managed fulfillment item's
1888
+ concrete quantity equals the sum of its LOT quantities before rendering. If
1889
+ Batch Management is not registered, the same packing list is issued without LOT
1890
+ rows. An installed but incompatible Batch version fails explicitly.
1891
+
1892
+ A manual allocation reassignment increments the fulfillment run's allocation
1893
+ revision and emits an internal event. Documents stores a new immutable packing
1894
+ list revision and serves the newest ready PDF. Returns do not rewrite the
1895
+ historical pick instruction. Fulfillment cancellation marks the packing list as
1896
+ canceled and prevents a concurrent render from being published as active.
1897
+
1898
+ ### Document facts and artifacts
1899
+
1900
+ Documents owns no Notification or delivery state. Its integration boundary is
1901
+ the strict versioned event contract exported from
1902
+ `@hartl-services/medusa-food-supplements-contracts/events/documents`. Shipment assignments, standalone Refund resolutions and Order-
1903
+ cancellation resolutions each carry an `event_emitted_at` marker that is set
1904
+ only after successful enqueue.
1905
+
1906
+ Every customer-facing attachment references an immutable `application/pdf`
1907
+ artifact. The artifact becomes permanently `communication_locked` in the same
1908
+ transaction that finalizes the fact. The publication reconciler first verifies
1909
+ the private File remains readable, and then safely republishes any final fact
1910
+ whose marker is still null. Refunds without an invoice and zero-Refund
1911
+ cancellations are the deliberate attachment-free cases.
1912
+
1913
+ Document issuance and resolution work uses ten-minute token leases. PDF and
1914
+ File I/O runs outside database transactions. If File creation succeeds after a
1915
+ worker has lost its lease, the unreferenced private File is deleted best-effort;
1916
+ a hard process exit in that external File/database gap can leave a storage
1917
+ orphan but cannot commit another Artifact, carrier or resolution.
1918
+
1919
+ #### Exact outgoing event contracts
1920
+
1921
+ The server-only Contracts subpath
1922
+ `@hartl-services/medusa-food-supplements-contracts/events/documents` exports the
1923
+ event-name constants,
1924
+ strict Zod schemas and inferred payload types for exactly these three events:
1925
+
1926
+ ```ts
1927
+ type DocumentsEmailArtifact = {
1928
+ artifact_id: string;
1929
+ document_id: string;
1930
+ document_number: string;
1931
+ file_id: string;
1932
+ filename: string;
1933
+ content_type: "application/pdf";
1934
+ };
1935
+
1936
+ type DocumentsOutgoingEmailEvent =
1937
+ | {
1938
+ name: "documents.shipment_invoice_assignment.decided.v1";
1939
+ data:
1940
+ | {
1941
+ schema_version: 1;
1942
+ order_id: string;
1943
+ fulfillment_id: string;
1944
+ decision: "non_carrier";
1945
+ }
1946
+ | {
1947
+ schema_version: 1;
1948
+ order_id: string;
1949
+ fulfillment_id: string;
1950
+ decision: "carrier";
1951
+ invoice_fulfillment_id: string;
1952
+ invoice_artifact: DocumentsEmailArtifact;
1953
+ };
1954
+ }
1955
+ | {
1956
+ name: "documents.order_cancellation_documents.ready.v1";
1957
+ data: {
1958
+ schema_version: 1;
1959
+ order_id: string;
1960
+ expected_refund_ids: string[];
1961
+ attachments: Array<{
1962
+ payment_id: string;
1963
+ refund_id: string;
1964
+ artifact: DocumentsEmailArtifact;
1965
+ }>;
1966
+ };
1967
+ }
1968
+ | {
1969
+ name: "documents.refund_document.ready.v1";
1970
+ data: {
1971
+ schema_version: 1;
1972
+ order_id: string;
1973
+ payment_id: string;
1974
+ refund_id: string;
1975
+ artifact: DocumentsEmailArtifact | null;
1976
+ };
1977
+ };
1978
+ ```
1979
+
1980
+ Every `string` above is trimmed and non-empty. For a `carrier`,
1981
+ `invoice_fulfillment_id` must equal `fulfillment_id`. Cancellation
1982
+ `expected_refund_ids` are lexicographically sorted and unique; `attachments`
1983
+ are sorted by unique `refund_id`, and every attached Refund belongs to the
1984
+ expected set. `artifact: null` is the final no-Invoice Refund result. The events
1985
+ contain no recipient, template, Notification ID, SMTP setting or delivery
1986
+ status.
1987
+
1988
+ Documents owns committed facts and their at-least-once publication. The E-Mail
1989
+ module owns recipient resolution, logical message deduplication, templates,
1990
+ attachments at dispatch, Medusa Notification records, SMTP attempts, status and
1991
+ explicit Admin resends. See the central
1992
+ [Email design](../../docs/superpowers/specs/2026-08-26-central-email-plugin-design.md)
1993
+ and
1994
+ [implementation plan](../../docs/superpowers/plans/2026-08-26-central-email-plugin.md).
1995
+
1996
+ Neither event publication nor SMTP promises exactly-once delivery. Publication
1997
+ reconciliation and the Redis Event Bus may deliver a fact more than once; Email
1998
+ deduplicates the logical message by its business key. SMTP may accept a message
1999
+ before the process can persist success, so an ambiguous attempt becomes
2000
+ `unknown` and is not automatically sent again. Any later resend is an explicit
2001
+ Email Admin action and a new logical message.
2002
+
2003
+ ### Development
2004
+
2005
+ #### Public Storefront and Admin contracts
2006
+
2007
+ The central contracts package exports the Documents Store and Admin DTOs, Zod
2008
+ schemas and route paths, but no second Storefront client. Consumers use their
2009
+ own Medusa SDK instance:
2010
+
2011
+ ```ts
2012
+ import type { GetStoreDocumentResponse } from "@hartl-services/medusa-food-supplements-contracts/types/documents-store";
2013
+ import { documentsPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/documents";
2014
+
2015
+ const response = await sdk.client.fetch<GetStoreDocumentResponse>(
2016
+ documentsPaths.store.get.retrieve(documentId),
2017
+ { method: "GET" },
2018
+ );
2019
+ ```
2020
+
2021
+ `@hartl-services/medusa-food-supplements-contracts/types/documents-store` and
2022
+ `@hartl-services/medusa-food-supplements-contracts/types/documents-admin` contain the
2023
+ respective type-only DTOs. Import
2024
+ `@hartl-services/medusa-food-supplements-contracts/schemas/documents` only for explicit
2025
+ runtime validation. The common Storefront case imports only types, which are
2026
+ erased by TypeScript, and the path module; it never imports the plugin's Medusa
2027
+ server code. Store and Admin responses use the same safe `DocumentSummary`
2028
+ transport DTO, while protected PDF download paths remain binary endpoints.
2029
+
2030
+ Add a DTO schema, central contract type and path entry, HTTP integration test,
2031
+ and README note together when introducing another Documents route.
2032
+
2033
+ Packing-list consumers stay Admin-only:
2034
+
2035
+ ```ts
2036
+ import type { ListAdminOrderPackingListsResponse } from "@hartl-services/medusa-food-supplements-contracts/types/documents-admin";
2037
+ import { packingListsPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/documents";
2038
+
2039
+ const response = await sdk.client.fetch<ListAdminOrderPackingListsResponse>(
2040
+ packingListsPaths.admin.get.listForOrder(orderId),
2041
+ { method: "GET" },
2042
+ );
2043
+ ```
2044
+
2045
+ ## Dateispeicher (S3)
2046
+
2047
+ The base provides a Medusa File Module provider for durable S3-compatible
2048
+ asset storage with a local, TTL-based read cache (`providers/s3-cache`), and
2049
+ the `/files` gateway routes every provider URL points at. It supports AWS S3,
2050
+ MinIO and compatible endpoints through AWS SDK v3. There is no separate S3
2051
+ package since 1.0.0; the provider identifier `s3-cache` and the resolve path
2052
+ `@hartl-services/medusa-base/providers/s3-cache` replace the old one.
2053
+
2054
+ S3 is always the source of truth. The local cache is a per-replica performance
2055
+ optimization and may be discarded at any time without data loss.
2056
+
2057
+ ### Coverage
2058
+
2059
+ The provider covers every asset that uses Medusa's native `Modules.FILE`
2060
+ service:
2061
+
2062
+ - public product images and other Medusa uploads;
2063
+ - private PDF documents of the Documents module, including its
2064
+ authorized download routes and email attachments;
2065
+ - future plugins that use the same File Module.
2066
+
2067
+ It cannot transparently back up a plugin that writes directly to its own local
2068
+ directory, own S3 client, or a third-party URL. Such integrations must be
2069
+ changed to use `Modules.FILE` or migrated explicitly.
2070
+
2071
+ ### Configuration
2072
+
2073
+ ```ts
2074
+ // medusa-config.ts
2075
+ import { createS3CacheStorageConfig } from "@hartl-services/medusa-base/config";
2076
+
2077
+ const s3CacheStorage = createS3CacheStorageConfig({
2078
+ s3: {
2079
+ bucket: process.env.S3_CACHE_STORAGE_BUCKET,
2080
+ region: process.env.S3_CACHE_STORAGE_REGION,
2081
+ // optional: MinIO or another S3-compatible endpoint
2082
+ endpoint: process.env.S3_CACHE_STORAGE_ENDPOINT,
2083
+ forcePathStyle: process.env.S3_CACHE_STORAGE_ENDPOINT ? true : undefined,
2084
+ // omit entirely to use the AWS SDK credential chain (e.g. an IAM role)
2085
+ credentials: process.env.S3_CACHE_STORAGE_ACCESS_KEY_ID
2086
+ ? {
2087
+ accessKeyId: process.env.S3_CACHE_STORAGE_ACCESS_KEY_ID,
2088
+ secretAccessKey: process.env.S3_CACHE_STORAGE_SECRET_ACCESS_KEY,
2089
+ }
2090
+ : undefined,
2091
+ },
2092
+ gateway: {
2093
+ baseUrl: `${process.env.MEDUSA_BACKEND_URL}/files`,
2094
+ signingSecret: process.env.FILE_GATEWAY_SIGNING_SECRET,
2095
+ },
2096
+ });
2097
+
2098
+ export default defineConfig({
2099
+ modules: [s3CacheStorage.fileModule /* , ...other modules */],
2100
+ plugins: [
2101
+ {
2102
+ resolve: "@hartl-services/medusa-base",
2103
+ options: { files: { gateway: s3CacheStorage.gateway } },
2104
+ },
2105
+ /* , ...other plugins */
2106
+ ],
2107
+ });
2108
+ ```
2109
+
2110
+ S3 Cache Storage needs **both** parts, which `createS3CacheStorageConfig`
2111
+ returns from one validated configuration:
2112
+
2113
+ - `fileModule` goes under `modules`. It registers the S3 provider as Medusa's
2114
+ File Module (`@medusajs/medusa/file`); Medusa accepts File providers only
2115
+ there, not from a plugin.
2116
+ - `gateway` goes into the base options as `files.gateway`. It carries the
2117
+ signing secret of the gateway routes every provider URL points at:
2118
+ `GET /files/:asset`, `GET /files/private/:token` and
2119
+ `PUT /files/upload/:token`. Without it, no public file, signed private
2120
+ download or presigned upload works (the routes answer that signing is not
2121
+ configured).
2122
+
2123
+ Both carry the same signing secret, so the routes accept every URL the
2124
+ provider signs. Upload CORS follows Medusa's `projectConfig.http.adminCors`.
2125
+
2126
+ `createS3CacheFileModuleConfig` remains for Medusa's stock local File Module
2127
+ (`enabled` omitted or `false`); with `enabled: true` it registers only the
2128
+ provider, without the gateway part, so use `createS3CacheStorageConfig`
2129
+ instead. The signing helpers of the routes (`createPrivateFileGatewayUrl`, ...)
2130
+ are exported from `@hartl-services/medusa-base/gateway`.
2131
+
2132
+ Required options and environment variables:
2133
+
2134
+ - `s3.bucket` (`S3_CACHE_STORAGE_BUCKET`) and `s3.region`
2135
+ (`S3_CACHE_STORAGE_REGION`).
2136
+ - `gateway.baseUrl` — the public Medusa origin plus `/files`, derived from
2137
+ `MEDUSA_BACKEND_URL`. It must never point at S3 or MinIO.
2138
+ - `gateway.signingSecret` — a strong, server-only secret used to sign private
2139
+ file and upload URLs. Changing it invalidates every outstanding signed URL.
2140
+
2141
+ Everything else (`s3.endpoint`, `s3.forcePathStyle`, `s3.storagePrefix`,
2142
+ `s3.credentials`, `gateway.privateDownloadDurationSeconds`, and the `cache.*`
2143
+ options) is optional; see
2144
+ [Active S3 options and defaults](#active-s3-options-and-defaults) below. The
2145
+ provider has no database migrations to run.
2146
+
2147
+ ### Environment helpers
2148
+
2149
+ `@hartl-services/medusa-base/config` also exports small helpers for reading
2150
+ the deployment variables in `medusa-config.ts`:
2151
+
2152
+ - `S3_BUILD_PLACEHOLDERS` — `{ S3_CACHE_STORAGE_BUCKET, S3_CACHE_STORAGE_REGION }`
2153
+ placeholder values so `medusa build` can load the configuration without
2154
+ deployment secrets. Use them only while building.
2155
+ - `resolveS3Credentials(process.env, phase)` — returns the static key pair
2156
+ from `S3_CACHE_STORAGE_ACCESS_KEY_ID`, `S3_CACHE_STORAGE_SECRET_ACCESS_KEY`
2157
+ and optional `S3_CACHE_STORAGE_SESSION_TOKEN`, or `undefined` for the AWS
2158
+ credential chain (and always during `"build"`). A declared but incomplete
2159
+ pair throws, naming the missing variables.
2160
+ - `resolveExpirationStrategy(process.env, defaultValue)` — reads
2161
+ `S3_CACHE_STORAGE_EXPIRATION_STRATEGY` (`"absolute"` or `"sliding"`).
2162
+
2163
+ ### Runtime configuration
2164
+
2165
+ The demo shop (`apps/backend`) enables S3 Cache Storage as the sole Medusa
2166
+ File Module provider through the preset; there is no per-deployment feature
2167
+ flag or local-provider fallback. Dokploy must provide
2168
+ `S3_CACHE_STORAGE_BUCKET`, `S3_CACHE_STORAGE_REGION`, and
2169
+ `MEDUSA_BACKEND_URL`. The backend URL is the public Medusa origin, for
2170
+ example `https://api.example.com`; the preset derives the `/files` gateway
2171
+ path from it. It must never point to S3 or MinIO.
2172
+
2173
+ Set `S3_CACHE_STORAGE_ACCESS_KEY_ID` and
2174
+ `S3_CACHE_STORAGE_SECRET_ACCESS_KEY` together, or omit both to use the AWS
2175
+ credential chain. `S3_CACHE_STORAGE_SESSION_TOKEN` is valid only with that
2176
+ complete static key pair.
2177
+
2178
+ Do not change a production shop from S3 to local storage after it has written
2179
+ S3 keys unless the matching database and storage rollback is being performed.
2180
+ The local provider cannot read S3-backed IDs. Treat a storage-provider change
2181
+ as a controlled deployment cutover, not as a runtime storage router.
2182
+
2183
+ #### Active S3 options and defaults
2184
+
2185
+ In this demo host, `bucket`, `region`, and the File Gateway base URL are
2186
+ required.
2187
+ Credentials are either a complete access-key pair or omitted, in which case the
2188
+ AWS SDK credential chain (for example an IAM role) is used.
2189
+
2190
+ | Option | Default | Meaning |
2191
+ | ---------------------------------------- | -------------------- | ---------------------------------------------------------------------------------- |
2192
+ | `s3.endpoint` | none | Internal HTTP(S) endpoint; use it for MinIO or another S3-compatible service. |
2193
+ | `s3.forcePathStyle` | `false` | Set to `true` for normal MinIO deployments. |
2194
+ | `s3.storagePrefix` | `""` | Environment namespace inside the bucket, for example `production/medusa`. |
2195
+ | `gateway.privateDownloadDurationSeconds` | `3600` | Lifetime of signed Medusa download URLs; `1`–`604800`. |
2196
+ | `cache.dir` | `./.medusa-s3-cache` | Writable local directory belonging to one replica. |
2197
+ | `cache.ttlMs` | `3600000` | Cache freshness window; `0`–`604800000`. |
2198
+ | `cache.expirationStrategy` | `absolute` | `absolute` keeps the original write timestamp; `sliding` refreshes mtime on a hit. |
2199
+ | `cache.cleanupIntervalMs` | `900000` | Startup cleanup plus this unreferenced timer; `1`–`86400000`. |
2200
+
2201
+ URLs may use only HTTP(S), with no credentials, query string, or fragment.
2202
+ Prefixes and provider keys reject absolute paths, traversal, control
2203
+ characters, and backslashes.
2204
+
2205
+ ### Object and access model
2206
+
2207
+ New uploads have opaque keys. The original filename is stored only as encoded
2208
+ object metadata:
2209
+
2210
+ ```text
2211
+ logical key: public/<ULID>.<ext> or private/<ULID>.<ext>
2212
+ S3 key: <storagePrefix>/<logical key>
2213
+ public URL: <MEDUSA_BACKEND_URL>/files/<ULID>.<ext>
2214
+ private URL: <MEDUSA_BACKEND_URL>/files/private/<signed-token>
2215
+ ```
2216
+
2217
+ No provider method returns an S3 URL. Public files receive stable Medusa gateway
2218
+ URLs and `Cache-Control: public, max-age=31536000, immutable`; their keys never
2219
+ change, so browser or an optional CDN in front of Medusa may cache them safely.
2220
+ Generic private files receive short-lived HMAC-signed Medusa URLs with
2221
+ `private, no-store`. Domain files such as invoices continue to use their
2222
+ authenticated Documents routes. Gateway responses use a restrictive sandbox
2223
+ Content Security Policy so active uploaded formats cannot execute under the
2224
+ Medusa API origin.
2225
+
2226
+ The provider sends no S3 ACL parameter. Keep the complete bucket and every
2227
+ prefix private. Only the Medusa runtime receives S3 credentials. Public and
2228
+ private presigned-upload requests receive a short-lived Medusa PUT capability
2229
+ under `/files/upload`; upload and download signatures are not interchangeable.
2230
+ The route accepts at most 100 MiB and allows configured `ADMIN_CORS` origins
2231
+ without exposing S3.
2232
+
2233
+ Backend reads (`getDownloadStream` and `getAsBuffer`) share an atomic cache:
2234
+ cache filenames are SHA-256 hashes, concurrent misses for one object download
2235
+ once per replica, and a failed refresh never serves an expired object. A
2236
+ successful remote write/delete evicts the affected entry only after S3 has
2237
+ accepted it. Do not share the cache directory between replicas; use a bounded
2238
+ `emptyDir` or per-pod volume and monitor free disk space.
2239
+
2240
+ ### Backup and restore
2241
+
2242
+ A complete restore requires both a PostgreSQL backup and the versioned object
2243
+ store. The cache is not a backup and is intentionally excluded.
2244
+
2245
+ Before production use, provision a dedicated bucket and prefix per
2246
+ environment, Block Public Access, SSE-S3 or KMS encryption, least-privilege
2247
+ application IAM, versioning, and lifecycle cleanup for old versions/abandoned
2248
+ multipart uploads. For stronger recovery objectives, add cross-account or
2249
+ cross-region replication and rehearse restores.
2250
+
2251
+ To restore on another server, restore the database and bucket/prefix, keep the
2252
+ same File Gateway base URL and signing secret, configure the provider, and let
2253
+ the replica caches start empty.
2254
+
2255
+ The Documents module ships a read-only restore drill. It streams
2256
+ every issued PDF through the active File provider and reports the verified file
2257
+ count and total bytes without modifying data:
2258
+
2259
+ ```sh
2260
+ medusa exec ./node_modules/@hartl-services/medusa-base/.medusa/server/src/documents/scripts/verify-documents-file-storage.js
2261
+ ```
2262
+
2263
+ ### HTTP contract
2264
+
2265
+ The gateway routes expose `GET /files/:asset` for public immutable files and
2266
+ `GET /files/private/:token` for signed private capability URLs. Both call
2267
+ `Modules.FILE.getDownloadStream`, so a fresh local cache entry is always used
2268
+ before S3. The private route validates its query through the Contracts schema.
2269
+ Paths, query types, and the runtime query schema are exported from the separate
2270
+ `/paths/files`, `/types/files`, and `/schemas/files` Contracts subpaths. Clients
2271
+ normally consume the complete URL returned by Medusa and never construct a
2272
+ private URL themselves.
2273
+
2274
+ ## Shop-Config-Preset
2275
+
2276
+ `@hartl-services/medusa-base/shop-config` turns a food shop's configuration
2277
+ from about 290 lines into about 30: the plugin list and order, the Redis event
2278
+ bus, SMTP notifications, S3 file storage, the tax and Stripe payment providers,
2279
+ the Admin path, the Sentry logger bridge and the environment validation are
2280
+ assembled there; the shop supplies only its own facts. It reuses the `/config`
2281
+ helpers above for environment parsing. It is a separate subpath because it
2282
+ loads the Sentry logger wrapper and with it `@sentry/node`; `/config` stays
2283
+ free of it.
2284
+
2285
+ The preset registers the base (with its E-Mail, Documents and `/files` options)
2286
+ plus five feature plugins, in this order: `medusa-base`,
2287
+ `medusa-tax-compliance`, `medusa-free-shipping`, `medusa-batch-management`,
2288
+ `medusa-affiliate`, `medusa-payload-sync`.
2289
+
2290
+ ### Installation in a shop
2291
+
2292
+ ```bash
2293
+ pnpm add @hartl-services/medusa-base \
2294
+ @hartl-services/medusa-tax-compliance \
2295
+ @hartl-services/medusa-free-shipping \
2296
+ @hartl-services/medusa-batch-management \
2297
+ @hartl-services/medusa-affiliate \
2298
+ @hartl-services/medusa-payload-sync \
2299
+ @medusajs/event-bus-redis @medusajs/payment-stripe \
2300
+ @sentry/node
2301
+ ```
2302
+
2303
+ ### Usage
2304
+
2305
+ ```ts
2306
+ // medusa-config.ts
2307
+ import { defineFoodShopConfig } from "@hartl-services/medusa-base/shop-config"
2308
+
2309
+ module.exports = defineFoodShopConfig({
2310
+ merchant: {
2311
+ countryCode: "at",
2312
+ legalName: "Example Shop GmbH",
2313
+ registeredAddress: {
2314
+ address1: "Musterstrasse 1",
2315
+ city: "Wien",
2316
+ countryCode: "at",
2317
+ postalCode: "1010",
2318
+ },
2319
+ vatNumber: "ATU12345678",
2320
+ },
2321
+ displayName: "EXAMPLE SHOP",
2322
+ orderNotificationEmail: "orders@example.com",
2323
+ timeZone: "Europe/Vienna",
2324
+ storefrontCountryCode: "de",
2325
+ })
2326
+ ```
2327
+
2328
+ A shop that has to adjust the result (a test harness, for example) builds the
2329
+ plain input, changes it and calls `defineConfig` itself:
2330
+
2331
+ ```ts
2332
+ import { defineConfig } from "@medusajs/framework/utils"
2333
+ import { buildFoodShopConfig } from "@hartl-services/medusa-base/shop-config"
2334
+
2335
+ const config = buildFoodShopConfig(options)
2336
+ config.plugins = config.plugins.filter(
2337
+ (plugin) => plugin.resolve !== "@hartl-services/medusa-batch-management",
2338
+ )
2339
+ module.exports = defineConfig(config)
2340
+ ```
2341
+
2342
+ ### API
2343
+
2344
+ - `defineFoodShopConfig(options, environment = process.env)` returns
2345
+ `defineConfig(buildFoodShopConfig(options, environment))`.
2346
+ - `buildFoodShopConfig(options, environment = process.env)` returns the plain
2347
+ config input (`admin`, `logger`, `projectConfig`, `modules`, `plugins`)
2348
+ before `defineConfig`. `modules` and `plugins` are ordinary arrays.
2349
+ - Types: `FoodShopConfigOptions`, `FoodShopConfigInput`, `FoodShopPlugin`,
2350
+ `FoodShopModule`.
2351
+
2352
+ Both functions validate the options first and throw a `TypeError` naming the
2353
+ option (`Food shop option "timeZone" must be ...`), so a misconfigured shop
2354
+ fails at boot. They then load the `.env` file (unless disabled) and read and
2355
+ validate the environment; a missing variable fails with the variable's name.
2356
+
2357
+ ### Options
2358
+
2359
+ | Option | Meaning |
2360
+ | --- | --- |
2361
+ | `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. |
2362
+ | `displayName` | Shop name in the default e-mail templates. |
2363
+ | `orderNotificationEmail` | Receives order notification e-mails; must look like an e-mail address. |
2364
+ | `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. |
2365
+ | `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=`. |
2366
+ | `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. |
2367
+ | `loadEnvFile` | Default `true`: calls `loadEnv(NODE_ENV \|\| "development", process.cwd())`. Pass `false` when the environment is already complete (tests, isolated child processes). |
2368
+
2369
+ ### Environment variables
2370
+
2371
+ Required at runtime (during `medusa build`, with `MEDUSA_BUILD_PHASE=true`, the
2372
+ plugins' build placeholders stand in for them):
2373
+
2374
+ - Medusa core: `DATABASE_URL`, `STORE_CORS`, `ADMIN_CORS`, `AUTH_CORS`,
2375
+ `JWT_SECRET`, `COOKIE_SECRET`, `MEDUSA_BACKEND_URL`, `STOREFRONT_URL` (plain
2376
+ HTTP origins are accepted only in development and test).
2377
+ - Redis: `REDIS_URL` (optional in development, where Medusa's local event bus
2378
+ is used; required elsewhere, `rediss://` with authentication in production
2379
+ unless `REDIS_ALLOW_INSECURE_PRIVATE_NETWORK=true`). Never used during a
2380
+ build.
2381
+ - SMTP: `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`,
2382
+ `SMTP_FROM`, `SMTP_REQUIRE_TLS` (outside development and test either
2383
+ `SMTP_SECURE` or `SMTP_REQUIRE_TLS` must be `true`).
2384
+ - Stripe: `STRIPE_API_KEY` (`sk_...`), `STRIPE_WEBHOOK_SECRET` (`whsec_...`).
2385
+ - S3: `S3_CACHE_STORAGE_BUCKET`, `S3_CACHE_STORAGE_REGION`.
2386
+ - Documents: `DOCUMENT_PDF_EXECUTABLE`.
2387
+
2388
+ Optional: `S3_CACHE_STORAGE_ENDPOINT`, `S3_CACHE_STORAGE_FORCE_PATH_STYLE`,
2389
+ `S3_CACHE_STORAGE_PREFIX`, `S3_CACHE_STORAGE_ACCESS_KEY_ID`,
2390
+ `S3_CACHE_STORAGE_SECRET_ACCESS_KEY`, `S3_CACHE_STORAGE_SESSION_TOKEN` (omit
2391
+ the keys to use the AWS credential chain), `S3_CACHE_STORAGE_CACHE_DIR`
2392
+ (default `./.medusa-s3-cache`), `S3_CACHE_STORAGE_TTL_MS`,
2393
+ `S3_CACHE_STORAGE_CLEANUP_INTERVAL_MS`,
2394
+ `S3_CACHE_STORAGE_EXPIRATION_STRATEGY`,
2395
+ `FILE_GATEWAY_PRIVATE_DOWNLOAD_DURATION_SECONDS`, and `SENTRY_DSN` /
2396
+ `SENTRY_ENVIRONMENT` for error tracking (see the base's
2397
+ `/instrumentation`).
2398
+
2399
+ ### Fixed configuration
2400
+
2401
+ Not configurable on purpose: the plugin order (base first), the Redis event
2402
+ bus job policy (3 attempts, exponential backoff from 1 s), S3 cache defaults
2403
+ (15 min cleanup, 1 h TTL, absolute expiration), Stripe immediate capture with
2404
+ automatic payment methods, the 24 h session lifetime, Admin path `/app`,
2405
+ consignment reminders due on day 10, and the Documents module always being part of the base.