@hartl-services/medusa-base 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/.medusa/server/src/admin/index.js +2164 -29
  2. package/.medusa/server/src/admin/index.mjs +2166 -31
  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/admin/plugin-features/[plugin_key]/actions/[action_key]/route.js +31 -0
  16. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/actions/[action_key]/runs/route.js +16 -0
  17. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/route.js +14 -0
  18. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/settings/route.js +22 -0
  19. package/.medusa/server/src/api/admin/plugin-features/route.js +6 -4
  20. package/.medusa/server/src/api/document-download-response.js +16 -0
  21. package/.medusa/server/src/api/document-response.js +23 -0
  22. package/.medusa/server/src/api/files/[asset]/route.js +24 -0
  23. package/.medusa/server/src/api/files/file-response.js +43 -0
  24. package/.medusa/server/src/api/files/gateway-options.js +31 -0
  25. package/.medusa/server/src/api/files/private/[token]/route.js +29 -0
  26. package/.medusa/server/src/api/files/upload/[token]/route.js +66 -0
  27. package/.medusa/server/src/api/middlewares.js +186 -5
  28. package/.medusa/server/src/api/packing-list-response.js +22 -0
  29. package/.medusa/server/src/api/plugin-actions.js +221 -0
  30. package/.medusa/server/src/api/plugin-features-http.js +67 -8
  31. package/.medusa/server/src/api/route.js +27 -0
  32. package/.medusa/server/src/api/store/documents/[id]/download/route.js +21 -0
  33. package/.medusa/server/src/api/store/documents/[id]/route.js +15 -0
  34. package/.medusa/server/src/api/store/documents/route.js +11 -0
  35. package/.medusa/server/src/api/terms-download-response.js +12 -0
  36. package/.medusa/server/src/documents/config/index.js +18 -0
  37. package/.medusa/server/src/documents/paths/documents.js +6 -0
  38. package/.medusa/server/src/documents/paths/index.js +18 -0
  39. package/.medusa/server/src/documents/plugin-features.js +24 -0
  40. package/.medusa/server/src/documents/schemas/documents.js +18 -0
  41. package/.medusa/server/src/documents/schemas/index.js +18 -0
  42. package/.medusa/server/src/documents/scripts/verify-documents-file-storage.js +55 -0
  43. package/.medusa/server/src/documents/templates/financial-document.js +142 -0
  44. package/.medusa/server/src/documents/templates/format.js +31 -0
  45. package/.medusa/server/src/documents/templates/html-factory.js +40 -0
  46. package/.medusa/server/src/documents/templates/html-utils.js +13 -0
  47. package/.medusa/server/src/documents/templates/layout.js +97 -0
  48. package/.medusa/server/src/documents/templates/packing-list.js +50 -0
  49. package/.medusa/server/src/documents/types/admin-api.js +3 -0
  50. package/.medusa/server/src/documents/types/document-summary.js +3 -0
  51. package/.medusa/server/src/documents/types/documents.js +3 -0
  52. package/.medusa/server/src/documents/types/html-factory.js +6 -0
  53. package/.medusa/server/src/documents/types/index.js +22 -0
  54. package/.medusa/server/src/documents/types/store-api.js +3 -0
  55. package/.medusa/server/src/documents/utils/document-merchant.js +22 -0
  56. package/.medusa/server/src/documents/utils/html-to-pdf.js +61 -0
  57. package/.medusa/server/src/documents/utils/private-file-storage.js +64 -0
  58. package/.medusa/server/src/documents/utils/resolve-services.js +7 -0
  59. package/.medusa/server/src/email/application/handle-invite-event.js +312 -0
  60. package/.medusa/server/src/email/application/handle-password-reset-event.js +354 -0
  61. package/.medusa/server/src/email/config/index.js +84 -0
  62. package/.medusa/server/src/email/config/notification-module.js +20 -0
  63. package/.medusa/server/src/email/domain/business-keys.js +50 -0
  64. package/.medusa/server/src/email/domain/documents-fact-fingerprint.js +7 -0
  65. package/.medusa/server/src/email/domain/event-schemas.js +36 -0
  66. package/.medusa/server/src/email/domain/failure-classification.js +53 -0
  67. package/.medusa/server/src/email/plugin-features.js +31 -0
  68. package/.medusa/server/src/email/rendering/default-templates.js +91 -0
  69. package/.medusa/server/src/email/rendering/render-email.js +37 -0
  70. package/.medusa/server/src/email/rendering/resolve-shop-configuration.js +48 -0
  71. package/.medusa/server/src/email/types/email-kinds.js +18 -0
  72. package/.medusa/server/src/email/types/index.js +21 -0
  73. package/.medusa/server/src/email/types/shop-configuration.js +3 -0
  74. package/.medusa/server/src/email/types/smtp-configuration.js +3 -0
  75. package/.medusa/server/src/email/types/template-data.js +79 -0
  76. package/.medusa/server/src/files/config/index.js +256 -0
  77. package/.medusa/server/src/files/gateway/index.js +115 -0
  78. package/.medusa/server/src/files/types/index.js +6 -0
  79. package/.medusa/server/src/files/types/public.js +6 -0
  80. package/.medusa/server/src/jobs/document-fact-publication-reconciliation.js +18 -0
  81. package/.medusa/server/src/jobs/document-resolution-reconciliation.js +20 -0
  82. package/.medusa/server/src/jobs/packing-list-reconciliation.js +49 -0
  83. package/.medusa/server/src/jobs/reconcile-email-attempts.js +63 -0
  84. package/.medusa/server/src/jobs/reconcile-pending-email-messages.js +41 -0
  85. package/.medusa/server/src/jobs/retry-email-messages.js +27 -0
  86. package/.medusa/server/src/migration-scripts/dismiss-orphaned-cart-promotion-links.js +26 -0
  87. package/.medusa/server/src/modules/documents/domain/cancellation-snapshot.js +97 -0
  88. package/.medusa/server/src/modules/documents/domain/document-number.js +36 -0
  89. package/.medusa/server/src/modules/documents/domain/document-resolution.js +24 -0
  90. package/.medusa/server/src/modules/documents/domain/issuance-rules.js +37 -0
  91. package/.medusa/server/src/modules/documents/domain/merchant.js +86 -0
  92. package/.medusa/server/src/modules/documents/domain/options.js +40 -0
  93. package/.medusa/server/src/modules/documents/domain/packing-list.js +90 -0
  94. package/.medusa/server/src/modules/documents/domain/snapshot-normalization.js +17 -0
  95. package/.medusa/server/src/modules/documents/domain/tax-breakdown.js +66 -0
  96. package/.medusa/server/src/modules/documents/index.js +15 -0
  97. package/.medusa/server/src/modules/documents/loaders/assert-private-file-storage.js +20 -0
  98. package/.medusa/server/src/modules/documents/migrations/Migration20260827061749.js +75 -0
  99. package/.medusa/server/src/modules/documents/migrations/Migration20260830111207.js +28 -0
  100. package/.medusa/server/src/modules/documents/models/document-artifact.js +25 -0
  101. package/.medusa/server/src/modules/documents/models/document-number-sequence.js +29 -0
  102. package/.medusa/server/src/modules/documents/models/document.js +82 -0
  103. package/.medusa/server/src/modules/documents/models/order-cancellation-resolution.js +51 -0
  104. package/.medusa/server/src/modules/documents/models/packing-list-revision.js +36 -0
  105. package/.medusa/server/src/modules/documents/models/packing-list.js +27 -0
  106. package/.medusa/server/src/modules/documents/models/refund-document-resolution.js +66 -0
  107. package/.medusa/server/src/modules/documents/models/shipment-invoice-assignment.js +51 -0
  108. package/.medusa/server/src/modules/documents/models/shipment-invoice-coordinator.js +44 -0
  109. package/.medusa/server/src/modules/documents/service.js +1701 -0
  110. package/.medusa/server/src/modules/email/domain/attachment.js +25 -0
  111. package/.medusa/server/src/modules/email/domain/message-state.js +69 -0
  112. package/.medusa/server/src/modules/email/index.js +13 -0
  113. package/.medusa/server/src/modules/email/migrations/Migration20260826173422.js +32 -0
  114. package/.medusa/server/src/modules/email/migrations/Migration20260827172945.js +16 -0
  115. package/.medusa/server/src/modules/email/migrations/Migration20260827194804.js +22 -0
  116. package/.medusa/server/src/modules/email/migrations/Migration20260906195205.js +16 -0
  117. package/.medusa/server/src/modules/email/models/email-attempt.js +56 -0
  118. package/.medusa/server/src/modules/email/models/email-message.js +71 -0
  119. package/.medusa/server/src/modules/email/models/email-terms-version.js +37 -0
  120. package/.medusa/server/src/modules/email/service.js +1285 -0
  121. package/.medusa/server/src/modules/plugin-features/domain/plugin-action-runs.js +92 -0
  122. package/.medusa/server/src/modules/plugin-features/domain/plugin-settings.js +213 -0
  123. package/.medusa/server/src/modules/plugin-features/domain/secret-box.js +56 -0
  124. package/.medusa/server/src/modules/plugin-features/migrations/Migration20260928205031.js +16 -0
  125. package/.medusa/server/src/modules/plugin-features/migrations/Migration20260928215428.js +17 -0
  126. package/.medusa/server/src/modules/plugin-features/models/plugin-action-run.js +43 -0
  127. package/.medusa/server/src/modules/plugin-features/models/plugin-setting-state.js +32 -0
  128. package/.medusa/server/src/modules/plugin-features/service.js +419 -17
  129. package/.medusa/server/src/providers/s3-cache/cache/atomic-write.js +25 -0
  130. package/.medusa/server/src/providers/s3-cache/cache/cache-manager.js +115 -0
  131. package/.medusa/server/src/providers/s3-cache/cache/keyed-lock.js +28 -0
  132. package/.medusa/server/src/providers/s3-cache/index.js +8 -0
  133. package/.medusa/server/src/providers/s3-cache/object-key.js +53 -0
  134. package/.medusa/server/src/providers/s3-cache/s3-cache-file.js +229 -0
  135. package/.medusa/server/src/providers/s3-cache/s3-object-store.js +86 -0
  136. package/.medusa/server/src/providers/smtp/index.js +8 -0
  137. package/.medusa/server/src/providers/smtp/service.js +236 -0
  138. package/.medusa/server/src/providers/smtp/types.js +11 -0
  139. package/.medusa/server/src/public/action-progress.js +67 -0
  140. package/.medusa/server/src/public/config.js +186 -0
  141. package/.medusa/server/src/public/descriptor.js +199 -2
  142. package/.medusa/server/src/public/effective-features.js +9 -5
  143. package/.medusa/server/src/public/enforcement.js +18 -8
  144. package/.medusa/server/src/public/gateway.js +19 -0
  145. package/.medusa/server/src/public/index.js +10 -2
  146. package/.medusa/server/src/public/instrumentation.js +56 -0
  147. package/.medusa/server/src/public/module-access.js +14 -0
  148. package/.medusa/server/src/public/settings.js +17 -0
  149. package/.medusa/server/src/public/shop-config.js +12 -0
  150. package/.medusa/server/src/public/shop-master-data.js +95 -0
  151. package/.medusa/server/src/public/shop-types.js +24 -0
  152. package/.medusa/server/src/shop-config/build-food-shop-config.js +248 -0
  153. package/.medusa/server/src/shop-config/options.js +71 -0
  154. package/.medusa/server/src/subscribers/affiliate-consignment-reminder-due.js +26 -0
  155. package/.medusa/server/src/subscribers/documents-order-canceled.js +24 -0
  156. package/.medusa/server/src/subscribers/documents-order-cancellation-ready.js +26 -0
  157. package/.medusa/server/src/subscribers/documents-refund-ready.js +24 -0
  158. package/.medusa/server/src/subscribers/documents-shipment-created.js +36 -0
  159. package/.medusa/server/src/subscribers/documents-shipment-invoice-assignment-decided.js +26 -0
  160. package/.medusa/server/src/subscribers/email-order-canceled.js +28 -0
  161. package/.medusa/server/src/subscribers/email-shipment-created.js +32 -0
  162. package/.medusa/server/src/subscribers/order-placed.js +27 -0
  163. package/.medusa/server/src/subscribers/packing-list-allocation-revised.js +32 -0
  164. package/.medusa/server/src/subscribers/packing-list-fulfillment-canceled.js +54 -0
  165. package/.medusa/server/src/subscribers/packing-list-fulfillment-created.js +31 -0
  166. package/.medusa/server/src/subscribers/password-reset.js +23 -0
  167. package/.medusa/server/src/subscribers/payment-refunded.js +38 -0
  168. package/.medusa/server/src/subscribers/return-received.js +27 -0
  169. package/.medusa/server/src/subscribers/return-requested.js +27 -0
  170. package/.medusa/server/src/subscribers/user-invite.js +23 -0
  171. package/.medusa/server/src/utils/cart-promotion-links.js +101 -0
  172. package/.medusa/server/src/utils/discover-complete-cart-participants.js +9 -2
  173. package/.medusa/server/src/utils/discover-plugin-features.js +47 -9
  174. package/.medusa/server/src/workflows/documents/issue-cancellation-after-refund.js +246 -0
  175. package/.medusa/server/src/workflows/documents/issue-document.js +163 -0
  176. package/.medusa/server/src/workflows/documents/issue-invoice-after-shipment.js +521 -0
  177. package/.medusa/server/src/workflows/documents/issue-packing-list.js +384 -0
  178. package/.medusa/server/src/workflows/documents/publish-document-facts.js +53 -0
  179. package/.medusa/server/src/workflows/documents/reconcile-document-resolutions.js +48 -0
  180. package/.medusa/server/src/workflows/documents/reconcile-order-cancellation.js +92 -0
  181. package/.medusa/server/src/workflows/documents/regenerate-issued-document.js +61 -0
  182. package/.medusa/server/src/workflows/email/dispatch-email-message.js +299 -0
  183. package/.medusa/server/src/workflows/email/documents-required-facts.js +30 -0
  184. package/.medusa/server/src/workflows/email/prepare-affiliate-reminder-email.js +58 -0
  185. package/.medusa/server/src/workflows/email/prepare-order-canceled-email.js +64 -0
  186. package/.medusa/server/src/workflows/email/prepare-order-received-email.js +56 -0
  187. package/.medusa/server/src/workflows/email/prepare-refund-email.js +47 -0
  188. package/.medusa/server/src/workflows/email/prepare-return-email.js +75 -0
  189. package/.medusa/server/src/workflows/email/prepare-shipment-email.js +82 -0
  190. package/.medusa/server/src/workflows/email/retry-email-messages.js +31 -0
  191. package/.medusa/server/src/workflows/hooks/complete-cart-participants.js +28 -16
  192. package/.medusa/server/src/workflows/hooks/dismiss-deleted-promotion-cart-links.js +23 -0
  193. package/README.md +2004 -11
  194. package/dist/admin-components/features/plugin-features/components/PluginActionsSection.d.ts +6 -0
  195. package/dist/admin-components/features/plugin-features/components/PluginActionsSection.js +66 -0
  196. package/dist/admin-components/features/plugin-features/components/PluginDetailPage.d.ts +2 -0
  197. package/dist/admin-components/features/plugin-features/components/PluginDetailPage.js +9 -0
  198. package/dist/admin-components/features/plugin-features/components/PluginFeatureGate.d.ts +22 -0
  199. package/dist/admin-components/features/plugin-features/components/PluginFeatureGate.js +18 -0
  200. package/dist/admin-components/features/plugin-features/components/PluginFeatureSections.d.ts +11 -0
  201. package/dist/admin-components/features/plugin-features/components/PluginFeatureSections.js +19 -0
  202. package/dist/admin-components/features/plugin-features/components/PluginFeaturesCard.d.ts +12 -0
  203. package/dist/admin-components/features/plugin-features/components/PluginFeaturesCard.js +34 -0
  204. package/dist/admin-components/features/plugin-features/components/PluginFeaturesSettingsPage.d.ts +5 -0
  205. package/dist/admin-components/features/plugin-features/components/PluginFeaturesSettingsPage.js +16 -0
  206. package/dist/admin-components/features/plugin-features/components/PluginHubCard.d.ts +7 -0
  207. package/dist/admin-components/features/plugin-features/components/PluginHubCard.js +21 -0
  208. package/dist/admin-components/features/plugin-features/components/PluginPageShell.d.ts +13 -0
  209. package/dist/admin-components/features/plugin-features/components/PluginPageShell.js +20 -0
  210. package/dist/admin-components/features/plugin-features/components/PluginSettingsForm.d.ts +7 -0
  211. package/dist/admin-components/features/plugin-features/components/PluginSettingsForm.js +39 -0
  212. package/dist/admin-components/features/plugin-features/components/PluginStatusChips.d.ts +5 -0
  213. package/dist/admin-components/features/plugin-features/components/PluginStatusChips.js +8 -0
  214. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatureEnabled.d.ts +114 -0
  215. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatureEnabled.js +40 -0
  216. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatures.d.ts +392 -0
  217. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatures.js +128 -0
  218. package/dist/admin-components/features/plugin-features/hooks/usePluginSettingsForm.d.ts +93 -0
  219. package/dist/admin-components/features/plugin-features/hooks/usePluginSettingsForm.js +44 -0
  220. package/dist/admin-components/features/plugin-features/lib/action-runs.d.ts +19 -0
  221. package/dist/admin-components/features/plugin-features/lib/action-runs.js +74 -0
  222. package/dist/admin-components/features/plugin-features/lib/plugin-features-admin-client.d.ts +363 -0
  223. package/dist/admin-components/features/plugin-features/lib/plugin-features-admin-client.js +9 -0
  224. package/dist/admin-components/features/plugin-features/lib/plugin-paths.d.ts +4 -0
  225. package/dist/admin-components/features/plugin-features/lib/plugin-paths.js +4 -0
  226. package/dist/admin-components/features/plugin-features/lib/settings-draft.d.ts +28 -0
  227. package/dist/admin-components/features/plugin-features/lib/settings-draft.js +86 -0
  228. package/dist/admin-components/features/plugin-features/queries/pluginFeatureKeys.d.ts +7 -0
  229. package/dist/admin-components/features/plugin-features/queries/pluginFeatureKeys.js +7 -0
  230. package/dist/admin-components/i18n/texts.d.ts +90 -0
  231. package/dist/admin-components/i18n/texts.js +90 -0
  232. package/dist/admin-components/index.d.ts +14 -0
  233. package/dist/admin-components/index.js +14 -0
  234. package/dist/admin-components/lib/errors.d.ts +6 -0
  235. package/dist/admin-components/lib/errors.js +27 -0
  236. package/dist/admin-components/lib/sdk.d.ts +2 -0
  237. package/dist/admin-components/lib/sdk.js +13 -0
  238. package/dist/documents/config/index.d.ts +11 -0
  239. package/dist/documents/config/index.js +17 -0
  240. package/dist/documents/templates/financial-document.d.ts +3 -0
  241. package/dist/documents/templates/financial-document.js +141 -0
  242. package/dist/documents/templates/format.d.ts +16 -0
  243. package/dist/documents/templates/format.js +30 -0
  244. package/dist/documents/templates/html-factory.d.ts +11 -0
  245. package/dist/documents/templates/html-factory.js +39 -0
  246. package/dist/documents/templates/html-utils.d.ts +2 -0
  247. package/dist/documents/templates/html-utils.js +12 -0
  248. package/dist/documents/templates/layout.d.ts +17 -0
  249. package/dist/documents/templates/layout.js +96 -0
  250. package/dist/documents/templates/packing-list.d.ts +3 -0
  251. package/dist/documents/templates/packing-list.js +49 -0
  252. package/dist/documents/types/admin-api.d.ts +1 -0
  253. package/dist/documents/types/admin-api.js +2 -0
  254. package/dist/documents/types/document-summary.d.ts +1 -0
  255. package/dist/documents/types/document-summary.js +2 -0
  256. package/dist/documents/types/documents.d.ts +174 -0
  257. package/dist/documents/types/documents.js +2 -0
  258. package/dist/documents/types/html-factory.d.ts +7 -0
  259. package/dist/documents/types/html-factory.js +5 -0
  260. package/dist/documents/types/index.d.ts +5 -0
  261. package/dist/documents/types/index.js +21 -0
  262. package/dist/documents/types/store-api.d.ts +1 -0
  263. package/dist/documents/types/store-api.js +2 -0
  264. package/dist/email/config/index.d.ts +17 -0
  265. package/dist/email/config/index.js +83 -0
  266. package/dist/email/config/notification-module.d.ts +22 -0
  267. package/dist/email/config/notification-module.js +19 -0
  268. package/dist/email/types/email-kinds.d.ts +15 -0
  269. package/dist/email/types/email-kinds.js +17 -0
  270. package/dist/email/types/index.d.ts +4 -0
  271. package/dist/email/types/index.js +20 -0
  272. package/dist/email/types/shop-configuration.d.ts +43 -0
  273. package/dist/email/types/shop-configuration.js +2 -0
  274. package/dist/email/types/smtp-configuration.d.ts +9 -0
  275. package/dist/email/types/smtp-configuration.js +2 -0
  276. package/dist/email/types/template-data.d.ts +100 -0
  277. package/dist/email/types/template-data.js +78 -0
  278. package/dist/files/config/index.d.ts +32 -0
  279. package/dist/files/config/index.js +255 -0
  280. package/dist/files/gateway/index.d.ts +30 -0
  281. package/dist/files/gateway/index.js +114 -0
  282. package/dist/files/types/index.d.ts +2 -0
  283. package/dist/files/types/index.js +5 -0
  284. package/dist/files/types/public.d.ts +90 -0
  285. package/dist/files/types/public.js +5 -0
  286. package/dist/modules/documents/domain/merchant.d.ts +22 -0
  287. package/dist/modules/documents/domain/merchant.js +85 -0
  288. package/dist/modules/documents/domain/options.d.ts +34 -0
  289. package/dist/modules/documents/domain/options.js +39 -0
  290. package/dist/public/action-progress.d.ts +25 -0
  291. package/dist/public/action-progress.js +66 -0
  292. package/dist/public/config.d.ts +59 -0
  293. package/dist/public/config.js +185 -0
  294. package/dist/public/descriptor.d.ts +3 -2
  295. package/dist/public/descriptor.js +198 -1
  296. package/dist/public/effective-features.d.ts +2 -1
  297. package/dist/public/effective-features.js +8 -4
  298. package/dist/public/enforcement.d.ts +7 -0
  299. package/dist/public/enforcement.js +17 -7
  300. package/dist/public/gateway.d.ts +2 -0
  301. package/dist/public/gateway.js +18 -0
  302. package/dist/public/index.d.ts +5 -2
  303. package/dist/public/index.js +9 -1
  304. package/dist/public/instrumentation.d.ts +12 -0
  305. package/dist/public/instrumentation.js +55 -0
  306. package/dist/public/module-access.d.ts +3 -0
  307. package/dist/public/module-access.js +13 -0
  308. package/dist/public/settings.d.ts +11 -0
  309. package/dist/public/settings.js +16 -0
  310. package/dist/public/shop-config.d.ts +8 -0
  311. package/dist/public/shop-config.js +11 -0
  312. package/dist/public/shop-master-data.d.ts +25 -0
  313. package/dist/public/shop-master-data.js +94 -0
  314. package/dist/public/shop-types.d.ts +7 -0
  315. package/dist/public/shop-types.js +23 -0
  316. package/dist/public/types.d.ts +113 -1
  317. package/dist/shop-config/build-food-shop-config.d.ts +44 -0
  318. package/dist/shop-config/build-food-shop-config.js +247 -0
  319. package/dist/shop-config/options.d.ts +41 -0
  320. package/dist/shop-config/options.js +70 -0
  321. package/dist/utils/discover-plugin-features.d.ts +28 -0
  322. package/dist/utils/discover-plugin-features.js +96 -0
  323. package/package.json +53 -6
package/README.md CHANGED
@@ -1,9 +1,13 @@
1
1
  # Medusa Base
2
2
 
3
3
  Medusa v2.19 base plugin for Hartl Services shops. Every shop installs it. It
4
- provides per-plugin feature toggles: a generic Admin page under
5
- **Settings → Hartl Services Plugins**, a Store endpoint for storefronts and
6
- helpers that plugins use to enforce their toggles.
4
+ provides per-plugin feature toggles, runtime settings and actions: a generic
5
+ Admin hub under **Settings → Hartl Services Plugins** with one page per
6
+ plugin, a Store endpoint for storefronts and helpers that plugins use to
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").
7
11
 
8
12
  ## What it does
9
13
 
@@ -16,9 +20,35 @@ helpers that plugins use to enforce their toggles.
16
20
  values to the Admin.
17
21
  - Exports `definePluginFeatures`, `requirePluginFeature` and
18
22
  `isPluginFeatureEnabled` from `@hartl-services/medusa-base` for plugins.
23
+ - Emits `plugin-features.updated.v1` after every saved Admin toggle update
24
+ (see "Toggle-change event").
25
+ - Stores runtime settings that plugins declare (secrets encrypted), resolves
26
+ them with the medusa-config fallback and exports `getPluginSettings` (see
27
+ "Runtime settings").
28
+ - Runs actions that plugins declare as workflow ids, records every run with
29
+ its progress and outcome and rejects a second start while one runs (see
30
+ "Actions").
31
+ - Holds the shop's master data (Firmenangaben) as its own settings and
32
+ exports `getShopMasterData` (see "Shop-Stammdaten").
33
+ - Exports Admin components from `@hartl-services/medusa-base/admin-components`
34
+ so a plugin composes the base's sections into its own page and hides
35
+ widgets of disabled features (see "Admin components for plugin pages").
19
36
  - Owns the `completeCartWorkflow` hooks `validate` and `orderCreated` and runs
20
37
  the workflows that plugins declare for them (see "Complete-cart hook
21
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").
22
52
 
23
53
  ## Installation in a shop
24
54
 
@@ -32,13 +62,49 @@ plugins: [
32
62
  // First in the list: other plugins read their toggles through it.
33
63
  {
34
64
  resolve: "@hartl-services/medusa-base",
35
- options: {},
65
+ options: {
66
+ // Optional: key material for secret settings (see "Secrets").
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 },
73
+ },
36
74
  },
37
75
  // … all other plugins
38
76
  ]
39
77
  ```
40
78
 
41
- The plugin has no options. Run `medusa db:migrate` after installing it.
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.
42
108
 
43
109
  Peer dependencies are the host's Medusa 2.19 packages (`@medusajs/framework`,
44
110
  `@medusajs/medusa`, `@medusajs/admin-sdk`, `@medusajs/js-sdk`, `@medusajs/ui`)
@@ -49,12 +115,22 @@ dashboard uses.
49
115
 
50
116
  | Method | Path | Auth | Response |
51
117
  |---|---|---|---|
118
+ | `GET` | `/` | none | 302 redirect to the configured `admin.path` (default `/app`) |
52
119
  | `GET` | `/store/plugin-features` | publishable key | `StorePluginFeaturesListResponse` |
53
120
  | `GET` | `/store/plugin-features/:plugin_key` | publishable key | `StorePluginFeaturesResponse`, 404 for an unknown plugin |
54
121
  | `GET` | `/admin/plugin-features` | Admin user | `AdminPluginFeaturesListResponse` |
55
122
  | `POST` | `/admin/plugin-features` | Admin user | `AdminPluginFeaturesResponse` |
123
+ | `GET` | `/admin/plugin-features/:plugin_key` | Admin user | `AdminPluginFeaturesResponse`, 404 for an unknown plugin |
124
+ | `GET` | `/admin/plugin-features/:plugin_key/settings` | Admin user | `AdminPluginSettingsResponse` |
125
+ | `POST` | `/admin/plugin-features/:plugin_key/settings` | Admin user | `AdminPluginFeaturesResponse` |
126
+ | `POST` | `/admin/plugin-features/:plugin_key/actions/:action_key` | Admin user | `AdminPluginActionRunResponse` (200 sync, 202 background), 409 `AdminPluginActionConflictResponse` while it runs |
127
+ | `GET` | `/admin/plugin-features/:plugin_key/actions/:action_key/runs` | Admin user | `AdminPluginActionRunsResponse` (`?limit=`, default 1, max 20) |
56
128
 
57
- Every authenticated Admin user may change the toggles (no RBAC by design).
129
+ Every authenticated Admin user may change toggles and settings and run
130
+ actions (no RBAC by design). Admin plugin DTOs carry the toggles plus
131
+ `settings` (with `source`, never a secret's value), `configured`, `missing`,
132
+ `settings_version`, `actions`, `admin_page` (the plugin's own page or
133
+ `null`) and `switchable`, so the list is one round trip for the hub.
58
134
  `POST` takes `{ plugin_key, expected_version, enabled, features }`; `features`
59
135
  holds configured values for declared keys, missing keys keep their default and
60
136
  an unknown key is 400. An unknown `plugin_key` is 404, a stale
@@ -108,6 +184,11 @@ Keys match `^[a-z][a-z0-9_]{0,62}$`, feature keys are unique, a `parent_key`
108
184
  must exist and only one nesting level is allowed. `definePluginFeatures`
109
185
  throws at import time otherwise.
110
186
 
187
+ `switchable: false` declares a plugin that cannot be switched off (the
188
+ base's own shop master data): the Admin shows no plugin switch, `enabled` is
189
+ always `true` whatever is stored, and `POST /admin/plugin-features` with
190
+ `enabled: false` is 400. It cannot be combined with `default_enabled: false`.
191
+
111
192
  ### Gating routes
112
193
 
113
194
  Put gate entries at the start of the plugin's `routes` array. An entry without
@@ -148,6 +229,43 @@ A read failure throws, so the subscriber or job is retried. A missing base
148
229
  plugin fails with `UNEXPECTED_STATE` ("@hartl-services/medusa-base ist nicht in
149
230
  medusa-config registriert.").
150
231
 
232
+ To check another plugin without importing it, use its plugin key:
233
+ `isPluginFeatureEnabledByKey(container, "documents", "invoices")`. It finds
234
+ the descriptor among the registered modules; an unknown or uninstalled plugin
235
+ key (and an undeclared feature key) is `false`.
236
+
237
+ ### Toggle-change event
238
+
239
+ After a successful `POST /admin/plugin-features` the base emits
240
+ `PLUGIN_FEATURES_UPDATED_EVENT` (`plugin-features.updated.v1`) through the
241
+ event bus. The payload (`PluginFeaturesUpdatedEventSchema`) carries the
242
+ plugin's effective values after the update:
243
+ `{ plugin_key, enabled, features: { [key]: effective }, version }`. The event
244
+ is emitted after the write committed; a failed emit is only logged and does
245
+ not undo the write. Direct service calls (`updatePluginFeatures`) emit
246
+ nothing.
247
+
248
+ A plugin that must react right away (instead of on its next job run)
249
+ subscribes to it and filters on its own `plugin_key`:
250
+
251
+ ```ts
252
+ import {
253
+ PLUGIN_FEATURES_UPDATED_EVENT,
254
+ PluginFeaturesUpdatedEventSchema,
255
+ } from "@hartl-services/medusa-food-supplements-contracts/events/plugin-features"
256
+
257
+ export default async function onToggleChange({ event, container }: SubscriberArgs<unknown>) {
258
+ const payload = PluginFeaturesUpdatedEventSchema.parse(event.data)
259
+ if (payload.plugin_key !== demoPluginFeatures.plugin_key) return
260
+ // re-read the toggles and reconcile
261
+ }
262
+
263
+ export const config: SubscriberConfig = { event: PLUGIN_FEATURES_UPDATED_EVENT }
264
+ ```
265
+
266
+ Events can arrive late or twice; re-read the current toggles instead of
267
+ trusting the payload for anything that writes.
268
+
151
269
  ### Never on the cart path
152
270
 
153
271
  Code on the cart and checkout hot path (cart middlewares, cart workflow hooks,
@@ -156,6 +274,310 @@ fail-safe and independent of the database state of this plugin. Toggles
156
274
  control visibility and reachability of features, not the integrity of
157
275
  existing data.
158
276
 
277
+ ## Runtime settings
278
+
279
+ Configuration a plugin reads at RUN time (for example an external system's
280
+ URL and API key) is declared in the descriptor and edited on the plugin's
281
+ Admin page. Boot-time provider configuration (SMTP, S3, Sentry, Redis) and
282
+ function-valued options stay in `medusa-config.ts`.
283
+
284
+ ```ts
285
+ export const demoPluginFeatures = definePluginFeatures({
286
+ plugin_key: "demo",
287
+ label: "Demo-Plugin",
288
+ features: [],
289
+ settings: [
290
+ { key: "api_url", label: "API-URL", type: "url", required: true },
291
+ { key: "api_key", label: "API-Key", type: "secret", required: true },
292
+ { key: "collection", label: "Collection", type: "string", default: "users" },
293
+ { key: "batch_size", label: "Batchgröße", type: "number", min: 1, max: 500, default: 100 },
294
+ {
295
+ key: "mode",
296
+ label: "Modus",
297
+ type: "select",
298
+ options: [
299
+ { value: "live", label: "Live" },
300
+ { value: "test", label: "Test" },
301
+ ],
302
+ default: "test",
303
+ },
304
+ ],
305
+ })
306
+
307
+ class DemoModuleService extends MedusaService({ /* models */ }) {
308
+ readonly pluginFeatures = demoPluginFeatures
309
+ // Values the plugin received from medusa-config; shown as "aus Konfiguration".
310
+ readonly pluginSettingsFallback: PluginSettingsFallback<"api_url" | "api_key">
311
+
312
+ constructor(cradle: unknown, options?: DemoOptions) {
313
+ super(...arguments)
314
+ this.pluginSettingsFallback = { api_url: options?.apiUrl, api_key: options?.apiKey }
315
+ }
316
+ }
317
+ ```
318
+
319
+ - Types: `string`, `url` (http/https), `number` (finite, optional inclusive
320
+ `min`/`max`), `boolean`, `secret`, `select` (`options: [{ value, label }]`,
321
+ non-empty, unique values; the stored value must be one of them). Keys
322
+ follow the feature key format and are unique. A `secret` has no default;
323
+ any other default must match its type, options and bounds. `options` exist
324
+ only on `select`, `min`/`max` only on `number`. `required` defaults to
325
+ `false`. `invalidates` (optional) lists secret settings of the same plugin
326
+ that a change of this setting deletes, e.g. `invalidates: ["api_key"]` on
327
+ `api_url` so a stored key is never sent to a new host. `definePluginFeatures`
328
+ throws at import time otherwise (also for an `invalidates` entry that is not
329
+ a secret of the plugin). The Admin renders a `select` as a dropdown, gives
330
+ number inputs their bounds and shows "Beim Ändern muss … neu eingegeben
331
+ werden" under a setting with `invalidates`.
332
+ - Precedence per key: stored in the Admin > `pluginSettingsFallback`
333
+ (medusa-config) > declared default > not set. A value that does not fit the
334
+ declared type is skipped. A plugin is `configured` when every required
335
+ setting has a value; `missing` lists the others. A plugin must handle "nicht
336
+ konfiguriert" at run time (log and skip) instead of failing at boot.
337
+ - Server-side code reads the resolved values with secrets DECRYPTED:
338
+
339
+ ```ts
340
+ import { getPluginSettings } from "@hartl-services/medusa-base"
341
+
342
+ const { values, configured, missing } = await getPluginSettings(
343
+ container, demoPluginFeatures, fallback
344
+ )
345
+ ```
346
+
347
+ A module service has no access to other modules through its own container;
348
+ it passes the global `container` from `@medusajs/framework`.
349
+ - Updates (`POST …/settings` with `{ expected_version, values }`) merge
350
+ `values` into the stored row in one transaction: `null` clears a stored
351
+ value (the setting falls back to medusa-config or its default), a secret
352
+ that is not sent keeps its stored value, keys the plugin no longer declares
353
+ are dropped. When a setting with `invalidates` gets a different stored
354
+ value (also when it is cleared), the listed secrets are deleted in the same
355
+ transaction unless the update sets them anew; they appear in
356
+ `changed_keys`. Unknown keys and values of the wrong type, outside `min`/`max`
357
+ or not among a select's options answer 400 naming the key, a stale `expected_version` 409. The response is the full
358
+ `AdminPluginFeaturesResponse`.
359
+ - After a saved update the base emits `PLUGIN_SETTINGS_UPDATED_EVENT`
360
+ (`plugin-settings.updated.v1`) with `{ plugin_key, version, changed_keys }`
361
+ (no values). Direct service calls emit nothing.
362
+
363
+ ### Secrets
364
+
365
+ Secrets are stored AES-256-GCM encrypted (`{ enc: "<base64 iv|tag|ciphertext>" }`
366
+ in `plugin_setting_state.values`) with a key derived by HKDF-SHA256 from the
367
+ plugin option `settingsSecret`, else `projectConfig.http.cookieSecret`. The
368
+ ciphertext is bound to `<plugin_key>:<setting key>` (AES-GCM additional
369
+ authenticated data), so a stored value cannot be copied to another setting or
370
+ plugin. The Admin never receives a secret's value, only whether it is set.
371
+ **Changing the key material (`settingsSecret`, or `COOKIE_SECRET` without it)
372
+ invalidates stored secrets:** they then count as "nicht gesetzt" (a warning is
373
+ logged once per secret) and must be entered again; a medusa-config fallback
374
+ applies in the meantime. Secrets saved by an unpublished development build
375
+ before the key binding existed count as "nicht gesetzt" the same way and must
376
+ be entered again.
377
+
378
+ ## Actions
379
+
380
+ A plugin declares actions as registered workflow ids; the base runs them from
381
+ the plugin's Admin page:
382
+
383
+ ```ts
384
+ actions: [
385
+ {
386
+ key: "sync_now",
387
+ label: "Jetzt synchronisieren",
388
+ workflow_id: "demo-sync-catalog",
389
+ mode: "background", // or "sync"
390
+ confirm: "Den ganzen Katalog jetzt abgleichen?", // optional prompt
391
+ },
392
+ ],
393
+ ```
394
+
395
+ The workflow receives `PluginActionWorkflowInput`
396
+ (`{ plugin_key, action_key, triggered_by, run_id }`; `triggered_by` is the
397
+ Admin user id, `run_id` the recorded run).
398
+
399
+ - Every start is recorded in `plugin_action_run` (`running` → `completed` |
400
+ `failed`, `started_at`, `finished_at`, `triggered_by`, `message` of a
401
+ failure, `progress`, `result` when it is JSON and at most 4 KB).
402
+ - While a run of the same action is `running`, a start answers 409
403
+ (`{ type: "conflict", message: "Diese Aktion läuft bereits.", run }`) and
404
+ starts nothing. A partial unique index enforces this also for concurrent
405
+ requests.
406
+ - `sync`: the request waits for the workflow and answers
407
+ `{ action: { key, status: "completed", result?, run } }`. A failure goes
408
+ through Medusa's error pipeline (a `MedusaError` keeps its status); the run
409
+ is recorded as failed.
410
+ - `background`: the run is started without waiting and the request answers
411
+ 202 `{ action: { key, status: "started", run } }`. The outcome is recorded
412
+ on the run when the workflow settles; a failure is also logged.
413
+ - A workflow that is still running when the engine returns (async steps,
414
+ e.g. a step retry scheduled with `retryInterval`) keeps its run `running`
415
+ (a second start stays 409) until the engine reports its finish; the run is
416
+ then recorded like a background run. A `sync` action answers 202 `started`
417
+ in that case.
418
+ - Both emit `PLUGIN_ACTION_FINISHED_EVENT` (`plugin-action.finished.v1`,
419
+ `{ plugin_key, action_key, run_id, status: "completed" | "failed", message? }`)
420
+ when the run ends.
421
+ - `GET …/actions/:action_key/runs?limit=1` returns the latest runs, newest
422
+ first. The Admin polls it every 2 s while the latest run is `running`.
423
+ - Lost runs: a `running` run without start or progress for one hour (the
424
+ process restarted, the workflow was lost) is listed as `failed` with
425
+ "Abgebrochen (Neustart)" (listing never writes) and persisted as such the
426
+ next time the action is started. A run that reports progress regularly is
427
+ never affected.
428
+ - An unknown action is 404 ("Aktion unbekannt"); a declared action whose
429
+ workflow is not registered is 500 (`UNEXPECTED_STATE` naming the workflow,
430
+ no run is recorded).
431
+
432
+ ### Reporting progress
433
+
434
+ A long-running action reports progress from its workflow; the Admin shows it
435
+ as a bar ("current / total") with the label:
436
+
437
+ ```ts
438
+ import {
439
+ reportPluginActionProgress,
440
+ type PluginActionWorkflowInput,
441
+ } from "@hartl-services/medusa-base"
442
+
443
+ const syncPageStep = createStep(
444
+ "demo-sync-page",
445
+ async (input: PluginActionWorkflowInput & { page: number }, { container }) => {
446
+ // … one page of work
447
+ await reportPluginActionProgress(container, input.run_id, {
448
+ current: input.page,
449
+ total: 12, // or null when unknown
450
+ label: `Produkte: Seite ${input.page}`,
451
+ })
452
+ }
453
+ )
454
+ ```
455
+
456
+ `current` and `total` must be non-negative integers, the label is cut to 200
457
+ characters. Without a `run_id` (the same workflow run from a script) the
458
+ helper does nothing; invalid progress, an unknown or finished run and a
459
+ failed write are logged as a warning and dropped, and never fail the action.
460
+
461
+ ## Shop-Stammdaten
462
+
463
+ The base declares its own descriptor on its module service
464
+ (`plugin_key: "shop"`, label "Shop-Stammdaten", `switchable: false`, no
465
+ features). The hub shows it as the first card; its generic page edits the
466
+ settings like any plugin's:
467
+
468
+ | Key | Type | Required |
469
+ |---|---|---|
470
+ | `legal_name` (Firmenname) | string | yes |
471
+ | `address_line1`, `address_line2` | string | line 1 |
472
+ | `postal_code`, `city` | string | yes |
473
+ | `country_code` | select: EU-27 plus `ch`, `gb`, `no`, `is`, `li` (lowercase ISO-2, German labels) | yes |
474
+ | `vat_number` (UID-Nummer) | string | yes |
475
+ | `email`, `phone` | string | no |
476
+ | `website` | url | no |
477
+ | `iban`, `bic` | string | no |
478
+ | `company_register_number` (Firmenbuchnummer), `company_register_court` (Firmenbuchgericht) | string | no |
479
+
480
+ Server-side code reads them with a plugin's own medusa-config values as
481
+ fallback (stored in the Admin wins per key):
482
+
483
+ ```ts
484
+ import { getShopMasterData } from "@hartl-services/medusa-base"
485
+
486
+ const { values, configured, missing } = await getShopMasterData(container, {
487
+ legal_name: options.merchant?.legalName,
488
+ })
489
+ if (!configured) {
490
+ throw new MedusaError(
491
+ MedusaError.Types.UNEXPECTED_STATE,
492
+ `Shop-Stammdaten unvollständig: ${missing.join(", ")}`
493
+ )
494
+ }
495
+ ```
496
+
497
+ The base only checks presence and type; what counts as a usable value
498
+ (for example a placeholder UID) is the consuming plugin's rule.
499
+
500
+ ## Admin pages
501
+
502
+ - `/settings/hartl-services` (sidebar entry "Hartl Services Plugins") is the
503
+ hub: one card per plugin with status chips, the plugin switch (not for
504
+ `switchable: false`) and "Öffnen"; non-switchable plugins (Shop-Stammdaten)
505
+ come first.
506
+ - The Aktionen section shows per action the progress of a running run
507
+ ("läuft seit …", bar, label; the button is disabled) and the outcome of the
508
+ last finished run; a finished run it watched and a second start ("läuft
509
+ bereits") are announced as toasts.
510
+ - "Öffnen" leads to the plugin's ONE page: its `admin_page` when declared,
511
+ otherwise the base's generic page
512
+ `/settings/hartl-services/plugin/<plugin_key>` (Funktionen, Einstellungen,
513
+ Aktionen). The static `plugin` segment keeps it from colliding with
514
+ plugin-owned routes.
515
+ - A plugin that needs more than the generic sections (its own forms, lists)
516
+ declares `admin_page: "/settings/hartl-services/<plugin-slug>"` and composes
517
+ that page from the base's components (below). Nested settings routes get no
518
+ sidebar entry, so the hub is the only way in. `definePluginFeatures`
519
+ rejects a path outside `/settings/hartl-services/` or under
520
+ `/settings/hartl-services/plugin/`.
521
+
522
+ ### Admin components for plugin pages
523
+
524
+ `@hartl-services/medusa-base/admin-components` (ESM for the Admin's vite
525
+ build, with types) exports:
526
+
527
+ | Export | Purpose |
528
+ | --- | --- |
529
+ | `PluginPageShell({ pluginKey, children })` | Back link to the hub, the plugin's label, description and status chips, then `children`; loading, "nicht installiert" and error states instead of `children` |
530
+ | `PluginFeatureSections({ pluginKey })` | Funktionen, Einstellungen, Aktionen exactly as on the generic page |
531
+ | `usePluginFeature(pluginKey)` | Query of `GET /admin/plugin-features/:plugin_key` |
532
+ | `pluginFeatureKeys` | Query key factory (`all`, `list()`, `detail(key)`) for invalidation after an own mutation |
533
+ | `usePluginFeatureEnabled(pluginKey, ...featureKeys)` | `{ enabled, isPending, isError }` from the shared list query (30 s stale time); plugin switch and every key must be effective; `enabled` is `false` while loading and on error |
534
+ | `PluginFeatureGate({ pluginKey, featureKeys?, fallback? })` | Renders `children` only while enabled, otherwise `fallback` (default nothing) |
535
+ | `PluginFeatureDisabledNotice({ pluginKey, featureLabel? })` | "Diese Funktion ist unter Einstellungen → Hartl Services Plugins ausgeschaltet." with a link to the hub |
536
+
537
+ ```tsx
538
+ // src/admin/routes/settings/hartl-services/demo/page.tsx
539
+ import {
540
+ PluginFeatureSections,
541
+ PluginPageShell,
542
+ } from "@hartl-services/medusa-base/admin-components"
543
+
544
+ import { DemoMappingSection } from "../../../../features/demo/DemoMappingSection"
545
+
546
+ const DemoPage = () => (
547
+ <PluginPageShell pluginKey="demo">
548
+ <PluginFeatureSections pluginKey="demo" />
549
+ <DemoMappingSection />
550
+ </PluginPageShell>
551
+ )
552
+
553
+ export default DemoPage
554
+ ```
555
+
556
+ Widgets and pages of a disabled feature disappear instead of showing a 404:
557
+
558
+ ```tsx
559
+ // Widget: renders nothing while the feature is off (or still loading).
560
+ const DemoOrderWidget = () => (
561
+ <PluginFeatureGate pluginKey="demo" featureKeys={["order_sync"]}>
562
+ <DemoOrderPanel />
563
+ </PluginFeatureGate>
564
+ )
565
+
566
+ // Full page with a sidebar entry that cannot be hidden: explain instead.
567
+ const DemoReportsPage = () => {
568
+ const { enabled, isPending } = usePluginFeatureEnabled("demo", "reports")
569
+ if (isPending) return null
570
+ return enabled ? <DemoReports /> : (
571
+ <PluginFeatureDisabledNotice pluginKey="demo" featureLabel="Berichte" />
572
+ )
573
+ }
574
+ ```
575
+
576
+ The consuming plugin lists `@hartl-services/medusa-base` in its
577
+ `dependencies` or `peerDependencies`, so its admin build keeps the import
578
+ external and the shop's Admin resolves one shared copy. The components use
579
+ the host Admin's React, React Query, React Router and `@medusajs/ui`.
580
+
159
581
  ## Semantics
160
582
 
161
583
  - No stored row: every value is its declared default, `version` is 0.
@@ -204,9 +626,10 @@ class MyModuleService extends MedusaService({ /* … */ }) {
204
626
  finished transaction.
205
627
  - The base discovers participants like feature descriptors (every module in
206
628
  `medusa-config` that is registered in the container). Fail closed: an
207
- invalid declaration (non-string or blank `workflow_id`, non-finite `order`,
208
- unknown hook name, the same workflow twice for one hook) is
209
- `UNEXPECTED_STATE` and blocks checkout, naming the module key.
629
+ invalid declaration (non-string or blank `workflow_id`, a `workflow_id` that
630
+ is not a registered workflow, non-finite `order`, unknown hook name, the
631
+ same workflow twice for one hook) is `UNEXPECTED_STATE` and blocks
632
+ checkout, naming the module key (and the workflow id).
210
633
 
211
634
  **Ordering.** Ascending `order`, then module key, then workflow id. In use:
212
635
  Affiliate 100 (`affiliate-validate-complete-cart`,
@@ -218,9 +641,12 @@ Affiliate 100 (`affiliate-validate-complete-cart`,
218
641
  - `validate` participants run one after another without their own transaction
219
642
  id (they write nothing). The first error stops the chain and is rethrown
220
643
  unchanged.
644
+ - Participants run and are cancelled with an explicitly built context, like
645
+ Medusa's `runAsStep`: the parent's shared transaction context plus its
646
+ event group (`preventReleaseEvents`), never the step's parent linkage
647
+ (`parentStepIdempotencyKey`), so complete-cart may itself run nested.
221
648
  - `orderCreated` participants run one after another through the workflow
222
- engine under the complete-cart transaction id, with the parent's event group
223
- (`preventReleaseEvents`). If participant *k* fails, the base cancels
649
+ engine under the complete-cart transaction id. If participant *k* fails, the base cancels
224
650
  participants *k-1 … 0* in reverse order, logs every failed cancellation
225
651
  (module key, workflow id, cart, order, transaction), and rethrows the
226
652
  original error.
@@ -253,3 +679,1570 @@ module.exports = defineConfig({
253
679
 
254
680
  Initialize Sentry in `instrumentation.ts` as usual; without a `SENTRY_DSN` the
255
681
  wrapper is inert.
682
+
683
+ ## Root redirect
684
+
685
+ Medusa serves nothing at `/`, so a deployed domain answers it with a 404. The
686
+ base registers `GET /` and answers with a `302` to the shop's `admin.path`
687
+ (read from the config module, fallback `/app`), so opening the bare domain
688
+ lands on the admin dashboard in every shop and in local development without a
689
+ reverse-proxy rule. The redirect is temporary on purpose: browsers cache a
690
+ `301` on the bare domain essentially forever, which would be painful to undo
691
+ should `/` ever get real content. A shop that needs its own `/` defines
692
+ `src/api/route.ts` itself.
693
+
694
+ ## Cart-promotion link repair
695
+
696
+ Medusa's `deletePromotionsWorkflow` only soft-deletes a promotion and leaves
697
+ its `cart_promotion` link rows behind, so every cart that had it applied
698
+ reads back a `null` entry inside `cart.promotions`. The base removes those
699
+ links itself:
700
+
701
+ - The `deletePromotionsWorkflow.hooks.promotionsDeleted` hook
702
+ (`workflows/hooks/dismiss-deleted-promotion-cart-links`) dismisses the
703
+ links of the deleted promotions and restores them if a later step fails.
704
+ - The migration script `dismiss-orphaned-cart-promotion-links` runs once in
705
+ `medusa db:migrate` and removes the links that already dangle from earlier
706
+ deletions. Medusa tracks migration scripts by file name in
707
+ `script_migrations`, so the name must not change.
708
+
709
+ Nothing to configure; `medusa db:migrate` after installing or upgrading to
710
+ 0.4.1 is enough.
711
+
712
+ ## Config helpers
713
+
714
+ `@hartl-services/medusa-base/config` holds the generic environment helpers a
715
+ shop's `medusa-config.ts` needs, so every shop fails at startup with the name
716
+ of the missing variable instead of a runtime crash. The environment helpers
717
+ have no dependency on Medusa; the subpath does not load `@sentry/node`.
718
+
719
+ | Export | Purpose |
720
+ |---|---|
721
+ | `ConfigurationPhase` | `"build" \| "runtime"` |
722
+ | `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` |
723
+ | `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 |
724
+ | `optionalEnvironment`, `integerEnvironment`, `booleanEnvironment` | Trimmed optional string, range-checked integer, strict `"true"`/`"false"` boolean |
725
+ | `allowsInsecureLocalServices(environment, phase)` | `true` for build, development and test |
726
+ | `resolvePublicOrigin(value, name, allowHttp)`, `shopUrl(origin, path)` | Validate a bare HTTP(S) origin; build an absolute URL that cannot leave the origin |
727
+ | `resolveStripe(values)` | Checks the `sk_` key and the `whsec_` webhook secret |
728
+ | `resolveRedisEventBus(environment, phase)` | `{ url } \| undefined`: optional in development, required at runtime elsewhere, never used in a build, authenticated `rediss://` in production |
729
+ | `createSmtpNotificationModuleConfig`, `resolveSmtp`, `SMTP_ENVIRONMENT_VARIABLES`, `SMTP_BUILD_PLACEHOLDERS`, `defineShopEmailConfiguration` | SMTP notification provider and E-Mail configuration (see "E-Mail") |
730
+ | `createS3CacheStorageConfig`, `createS3CacheFileModuleConfig`, `resolveS3Credentials`, `resolveExpirationStrategy`, `S3_BUILD_PLACEHOLDERS` | S3 file storage (see "Dateispeicher (S3)") |
731
+ | `createDefaultDocumentHtmlFactory`, `DOCUMENTS_BUILD_PLACEHOLDERS` | Documents markup and build placeholder (see "Dokumente") |
732
+
733
+ `medusa build` loads the configuration without deployment secrets. Each
734
+ plugin's own placeholders (SMTP, S3, PDF executable, ...) live with that
735
+ plugin; the shop merges them:
736
+
737
+ ```ts
738
+ // medusa-config.ts
739
+ import {
740
+ MEDUSA_BUILD_PLACEHOLDERS,
741
+ requireEnvironment,
742
+ resolveRedisEventBus,
743
+ type ConfigurationPhase,
744
+ } from "@hartl-services/medusa-base/config"
745
+
746
+ const phase: ConfigurationPhase =
747
+ process.env.MEDUSA_BUILD_PHASE === "true" ? "build" : "runtime"
748
+ const env = requireEnvironment(
749
+ process.env,
750
+ ["DATABASE_URL", "JWT_SECRET", "COOKIE_SECRET"],
751
+ phase,
752
+ { ...MEDUSA_BUILD_PLACEHOLDERS /* , ...other plugins' placeholders */ },
753
+ )
754
+ const redis = resolveRedisEventBus(process.env, phase)
755
+ ```
756
+
757
+ ## Sentry instrumentation
758
+
759
+ `@hartl-services/medusa-base/instrumentation` exports `register()`, the hook
760
+ Medusa's start command calls from the shop's `instrumentation.ts`. It runs
761
+ `Sentry.init` with `SENTRY_DSN`, `SENTRY_ENVIRONMENT` (fallback `NODE_ENV`)
762
+ and `tracesSampleRate: 0`; without a `SENTRY_DSN` Sentry stays inert. The
763
+ shop's `instrumentation.ts` is one line:
764
+
765
+ ```ts
766
+ export { register } from "@hartl-services/medusa-base/instrumentation"
767
+ ```
768
+
769
+ `@sentry/node` stays an optional peer dependency of the base: a shop that uses
770
+ this export (or `sentry-logger`) lists `@sentry/node` in its own
771
+ `dependencies`.
772
+
773
+ ## E-Mail
774
+
775
+ The base owns transactional e-mail for the shop: logical email messages,
776
+ attempts, rendering, the Medusa Notification integration, SMTP transport,
777
+ retries and e-mail-specific Admin operations. It is the `email` module, its
778
+ SMTP notification provider (`providers/smtp`), its subscribers, jobs and
779
+ workflows, and the Admin page `/settings/hartl-services/email`. There is no
780
+ separate e-mail package since 1.0.0.
781
+
782
+ ### Configuration
783
+
784
+ The SMTP notification provider is a `modules` entry built by
785
+ `createSmtpNotificationModuleConfig` (from
786
+ `@hartl-services/medusa-base/config`); the branding and rendering
787
+ configuration goes into the `email` namespace of the base options:
788
+
789
+ ```ts
790
+ import {
791
+ createSmtpNotificationModuleConfig,
792
+ resolveSmtp,
793
+ SMTP_ENVIRONMENT_VARIABLES,
794
+ } from "@hartl-services/medusa-base/config";
795
+
796
+ // Validates the seven SMTP_* variables (see "SMTP environment helpers").
797
+ const smtp = resolveSmtp(
798
+ Object.fromEntries(
799
+ SMTP_ENVIRONMENT_VARIABLES.map((name) => [name, process.env[name] ?? ""]),
800
+ ) as Record<(typeof SMTP_ENVIRONMENT_VARIABLES)[number], string>,
801
+ process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test",
802
+ );
803
+
804
+ export default defineConfig({
805
+ modules: [createSmtpNotificationModuleConfig(smtp)],
806
+ plugins: [
807
+ {
808
+ resolve: "@hartl-services/medusa-base",
809
+ options: {
810
+ email: {
811
+ jwtSecret: process.env.JWT_SECRET!,
812
+ shop: {
813
+ displayName: "Mein Shop",
814
+ affiliatePortalUrl: "https://shop.example.com/de/partner",
815
+ inviteUrl: ({ token }) =>
816
+ `https://shop.example.com/app/invite?token=${token}`,
817
+ orderNotificationEmail: "orders@shop.example.com",
818
+ passwordResetUrl: ({ locale }) =>
819
+ `https://shop.example.com/${locale ?? "de"}/passwort-zuruecksetzen`,
820
+ }, // a ShopEmailConfiguration, see `ShopEmailConfiguration` in `@hartl-services/medusa-base/types`
821
+ },
822
+ },
823
+ },
824
+ ],
825
+ });
826
+ ```
827
+
828
+ Options of the `email` namespace:
829
+
830
+ - `jwtSecret` — used for Email's own tokens; typically the same secret as
831
+ `projectConfig.http.jwtSecret`.
832
+ - `shop` — a `ShopEmailConfiguration` object: `displayName`,
833
+ `affiliatePortalUrl`, the `passwordResetUrl`/`inviteUrl` builders and the
834
+ `orderNotificationEmail` mailbox. `layout` and `renderers` are optional
835
+ overrides, see "Shop configuration and default templates".
836
+
837
+ Without `email.shop` the module still boots, but it cannot dispatch: no mail
838
+ is sent (the dispatch configuration is requested lazily and fails with
839
+ `UNEXPECTED_STATE`). Shipment and cancellation e-mails wait for a Documents
840
+ fact only while the Documents `invoices` feature is on when the message is
841
+ created (with `invoices` off, or the Documents plugin switched off, Documents
842
+ publishes no fact, so the mail is sent without a document). Refund e-mails
843
+ come only from the Documents refund fact, so there are none while `invoices`
844
+ is off. The former `documentsEnabled` option no longer exists: Documents is
845
+ always part of the base.
846
+
847
+ The event contracts the module consumes for shipment, cancellation, refund and
848
+ affiliate-reminder e-mails come from
849
+ `@hartl-services/medusa-food-supplements-contracts/events/documents` and
850
+ `/events/affiliate`, so installing the base does not pull in Affiliate.
851
+
852
+ Environment variables (all seven required outside development/test; at least
853
+ one of `SMTP_SECURE`/`SMTP_REQUIRE_TLS` must be `true` outside
854
+ development/test):
855
+
856
+ ```text
857
+ SMTP_HOST=
858
+ SMTP_PORT=
859
+ SMTP_SECURE=
860
+ SMTP_REQUIRE_TLS=
861
+ SMTP_USER=
862
+ SMTP_PASSWORD=
863
+ SMTP_FROM=
864
+ ```
865
+
866
+ Set `REDIS_URL` to use `@medusajs/event-bus-redis` instead of Medusa's local
867
+ Event Bus; production requires an authenticated `rediss://` URL. See "Event
868
+ Bus" below for retry/backoff behavior.
869
+
870
+ ### Feature toggles
871
+
872
+ The E-Mail module declares its toggles to `@hartl-services/medusa-base` under the
873
+ plugin key `email`. An Admin switches them under Settings → "Hartl Services
874
+ Plugins"; Storefronts read the effective values from
875
+ `GET /store/plugin-features/email`. A feature is only effective while the
876
+ plugin switch (default on) is on. A gated subscriber that finds its feature
877
+ off returns without creating a message; the event is not replayed when the
878
+ feature is switched on again.
879
+
880
+ | Key | Label | Default | Gates | Keeps running when off |
881
+ | --------------------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
882
+ | (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 |
883
+ | `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 |
884
+ | `account_mails` | Konto-Mails | on | subscribers `password-reset`, `user-invite` | jobs, Admin routes, already created messages |
885
+ | `affiliate_reminders` | Affiliate-Erinnerungen | on | subscriber `affiliate-consignment-reminder-due` | jobs, Admin routes, already created messages |
886
+
887
+ With the plugin switched off the three jobs return immediately, so nothing
888
+ is sent, retried or reconciled until it is switched on again; then the jobs
889
+ pick up the stored messages (a message whose payload expired in the
890
+ meantime, such as a password-reset link, is not sent). A toggle read
891
+ failure fails the subscriber with its fixed sentinel (the Event Bus retries
892
+ it) or fails the job run.
893
+
894
+ Admin surfaces of a disabled feature are hidden. The E-Mail settings page
895
+ keeps `PluginFeatureSections` visible (so the plugin can be switched back on)
896
+ and gates the Terms PDF versions and mail log sections below it on the
897
+ plugin switch, showing `PluginFeatureDisabledNotice` in their place while it
898
+ is off.
899
+
900
+ ### Ownership
901
+
902
+ - Documents owns document issuance and publishes immutable document facts.
903
+ - Affiliate owns reminder eligibility and publishes reminder facts.
904
+ - Email resolves recipients, renders content, attaches files, and owns delivery
905
+ status plus the append-only global Terms PDF history.
906
+ - Public HTTP DTOs, schemas, and paths remain in
907
+ `@hartl-services/medusa-food-supplements-contracts`.
908
+
909
+ The base's `/types` and `/config` exports are dedicated server integration
910
+ surfaces for the backend. The Documents and Affiliate event contracts live in
911
+ the Contracts package (`events/documents`, `events/affiliate`). The base exports no DML models, services,
912
+ workflows, migrations, or generated output as a consumer contract.
913
+
914
+ ### Event Bus
915
+
916
+ The backend owns Event Bus selection. Development without `REDIS_URL` leaves
917
+ Medusa's local Event Bus active. Configuring `REDIS_URL` registers
918
+ `@medusajs/event-bus-redis` 2.19.0 directly; production requires an
919
+ authenticated `rediss://` URL unless the operator explicitly accepts private
920
+ network transport risk with `REDIS_ALLOW_INSECURE_PRIVATE_NETWORK=true`.
921
+
922
+ Redis Event Bus jobs have at most three total attempts with exponential backoff
923
+ starting at one second. Completed jobs are removed, while failed jobs are kept
924
+ for at most one hour and 1,000 entries. There is no custom Event Bus wrapper,
925
+ startup capability probe, or purge job. Events are at-least-once facts and can
926
+ be replayed, delayed, or delivered out of order.
927
+
928
+ ### Event Contracts
929
+
930
+ All payload objects are strict. Unless noted otherwise, every string shown
931
+ below is trimmed and non-empty. Native event names and shapes are fixed to the
932
+ installed Medusa 2.19 exports:
933
+
934
+ | Event | Payload |
935
+ | ------------------------ | ---------------------------------------------------------------------------------------------- |
936
+ | `order.placed` | `{ id: string }` |
937
+ | `shipment.created` | `{ id: string; no_notification?: boolean }` |
938
+ | `order.canceled` | `{ id: string }` |
939
+ | `order.return_requested` | `{ order_id: string; return_id: string }` |
940
+ | `order.return_received` | `{ order_id: string; return_id: string }` |
941
+ | `auth.password_reset` | `{ entity_id: string; actor_type: string; token: string; metadata?: Record<string, unknown> }` |
942
+
943
+ The shared immutable Documents artifact is:
944
+
945
+ ```ts
946
+ type DocumentsEmailArtifact = {
947
+ artifact_id: string;
948
+ document_id: string;
949
+ document_number: string;
950
+ file_id: string;
951
+ filename: string;
952
+ content_type: "application/pdf";
953
+ };
954
+ ```
955
+
956
+ The versioned project events are exact integration contracts:
957
+
958
+ `documents.shipment_invoice_assignment.decided.v1`
959
+
960
+ ```ts
961
+ type Payload =
962
+ | {
963
+ schema_version: 1;
964
+ order_id: string;
965
+ fulfillment_id: string;
966
+ decision: "non_carrier";
967
+ }
968
+ | {
969
+ schema_version: 1;
970
+ order_id: string;
971
+ fulfillment_id: string;
972
+ decision: "carrier";
973
+ invoice_fulfillment_id: string;
974
+ invoice_artifact: DocumentsEmailArtifact;
975
+ };
976
+ ```
977
+
978
+ For `carrier`, `invoice_fulfillment_id` must equal `fulfillment_id`.
979
+
980
+ `documents.order_cancellation_documents.ready.v1`
981
+
982
+ ```ts
983
+ type Payload = {
984
+ schema_version: 1;
985
+ order_id: string;
986
+ expected_refund_ids: string[];
987
+ attachments: Array<{
988
+ payment_id: string;
989
+ refund_id: string;
990
+ artifact: DocumentsEmailArtifact;
991
+ }>;
992
+ };
993
+ ```
994
+
995
+ `expected_refund_ids` must be sorted and unique. Attachments must be sorted by
996
+ unique `refund_id`, and every attachment refund must be expected.
997
+
998
+ `documents.refund_document.ready.v1`
999
+
1000
+ ```ts
1001
+ type Payload = {
1002
+ schema_version: 1;
1003
+ order_id: string;
1004
+ payment_id: string;
1005
+ refund_id: string;
1006
+ artifact: DocumentsEmailArtifact | null;
1007
+ };
1008
+ ```
1009
+
1010
+ `affiliate.consignment_report_reminder_due.v1`
1011
+
1012
+ ```ts
1013
+ type Payload = {
1014
+ schema_version: 1;
1015
+ reminder_key: string;
1016
+ affiliate_id: string;
1017
+ customer_id: string | null;
1018
+ affiliate_display_name: string;
1019
+ period_start: string;
1020
+ due_date: string;
1021
+ locale: string | null;
1022
+ trigger: "scheduled" | "manual";
1023
+ };
1024
+ ```
1025
+
1026
+ `period_start` is a valid `YYYY-MM-01`, `due_date` is a valid `YYYY-MM-DD`, and
1027
+ `reminder_key` is exactly
1028
+ `affiliate-consignment:<affiliate_id>:<period_start>`. `locale`, when present,
1029
+ has at most 35 characters. Email runtime-validates every contract before use.
1030
+
1031
+ ### Invariants
1032
+
1033
+ - The accepted kinds are exactly `order.received`, `order.received.admin`, `order.shipped`,
1034
+ `order.canceled`, `order.return_requested`, `order.return_received`,
1035
+ `order.refunded`, `auth.password_reset`, and
1036
+ `affiliate.consignment_report_due`.
1037
+ - Every automatic message has a deterministic business key.
1038
+ - Every placed Order creates independent customer and Shop intents. The customer
1039
+ intent honors `no_notification`; the Shop intent always targets the static
1040
+ role mailbox selected by the Shop configuration.
1041
+ - A new customer `order.received` intent snapshots the newest global Terms PDF
1042
+ whose `effective_at` is not later than the authoritative `order.created_at`.
1043
+ If no version is effective, the customer message is intentionally created
1044
+ without Terms. The Shop `order.received.admin` intent never receives it.
1045
+ - Terms versions and their private files are immutable. Event replays, transport
1046
+ retries, and explicit Admin resends reuse the original message snapshot, so a
1047
+ later or backdated upload never changes an existing message.
1048
+ - Event payloads are runtime-validated before use and may be replayed, delayed,
1049
+ or delivered out of order.
1050
+ - With the Documents `invoices` feature on, shipment and
1051
+ cancellation messages remain pending until both their native Medusa fact
1052
+ and immutable Documents fact arrive. The required facts are fixed when the
1053
+ message is created; later facts reuse them.
1054
+ - Shipment `no_notification` suppresses the complete correlated message,
1055
+ including any invoice attachment; refund email has no Documents-disabled
1056
+ native fallback.
1057
+ - Recipients, rendered content, attachment bytes, SMTP responses, credentials,
1058
+ reset tokens, and reset URLs are never logged.
1059
+ - Reset tokens and reset URLs are never persisted by Email or Notification.
1060
+ - Password-reset messages cannot be retried by the generic retry job or resent
1061
+ by an Admin.
1062
+ - Unknown transport outcomes are never automatically resent.
1063
+ - Shop branding is only the configured `displayName`; a shop overrides the
1064
+ default layout or single renderers in its own configuration, not in the
1065
+ plugin.
1066
+
1067
+ Read this section and the central design before changing Email domain behavior.
1068
+
1069
+ ### Shop configuration and default templates
1070
+
1071
+ The E-Mail module ships a neutral German layout and one renderer per accepted kind.
1072
+ Both show the shop only as the configured `displayName` (escaped in HTML; the
1073
+ layout header and footer, the password-reset and invite subjects). A shop
1074
+ therefore needs only `displayName`, `affiliatePortalUrl`, `inviteUrl`,
1075
+ `passwordResetUrl` and `orderNotificationEmail`.
1076
+
1077
+ - `layout` (optional) replaces the default layout completely.
1078
+ - `renderers` (optional, `Partial<EmailRendererMap>`) replaces only the listed
1079
+ kinds; every other kind keeps its default renderer.
1080
+
1081
+ ```ts
1082
+ import { defineShopEmailConfiguration } from "@hartl-services/medusa-base/config";
1083
+
1084
+ const shop = defineShopEmailConfiguration({
1085
+ displayName: "Mein Shop",
1086
+ // ...URLs and mailbox as above
1087
+ renderers: {
1088
+ "order.canceled": ({ order_id }) => ({
1089
+ bodyHtml: `<p>Bestellung <strong>${order_id}</strong> storniert.</p>`,
1090
+ subject: "Storniert",
1091
+ text: `Bestellung ${order_id} storniert.`,
1092
+ }),
1093
+ },
1094
+ });
1095
+ ```
1096
+
1097
+ The defaults are merged once when the Email module boots, so an invalid
1098
+ configuration (blank `displayName`, a non-function `layout` or renderer, an
1099
+ unknown renderer kind) fails at startup and not at the first send. Custom
1100
+ renderers must escape dynamic values in `bodyHtml` themselves.
1101
+
1102
+ ### SMTP environment helpers
1103
+
1104
+ `@hartl-services/medusa-base/config` exports helpers for the SMTP
1105
+ deployment variables:
1106
+
1107
+ - `SMTP_ENVIRONMENT_VARIABLES` — the seven variable names.
1108
+ - `SMTP_BUILD_PLACEHOLDERS` — placeholder values for all seven variables so
1109
+ `medusa build` can load `medusa-config.ts` without deployment secrets. Use
1110
+ them only while building.
1111
+ - `resolveSmtp(values, allowInsecure)` — validates the values and returns the
1112
+ `ResolvedSmtpConfiguration` for `createSmtpNotificationModuleConfig`. It
1113
+ rejects a port outside 1–65535, a non-`"true"`/`"false"` boolean and an
1114
+ invalid `SMTP_FROM`. `allowInsecure` permits plain SMTP (development, test,
1115
+ build); otherwise `SMTP_SECURE` or `SMTP_REQUIRE_TLS` must be `true`.
1116
+
1117
+ Runtime requires `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_REQUIRE_TLS`,
1118
+ `SMTP_USER`, `SMTP_PASSWORD`, and `SMTP_FROM`. `SMTP_SECURE=true` selects
1119
+ implicit TLS; `SMTP_REQUIRE_TLS=true` requires STARTTLS. Outside development
1120
+ and test, at least one must be true. Certificate verification is never
1121
+ disabled. Development and test may set both to `false` for an isolated local
1122
+ SMTP server. SMTP endpoints and credentials never belong in the shop
1123
+ configuration.
1124
+
1125
+ ### Delivery Semantics
1126
+
1127
+ For ordinary durable messages, only characterized connection failures,
1128
+ temporary SMTP rejections, and temporary attachment access failures are
1129
+ automatically retried. There are at most three total SMTP attempts, scheduled
1130
+ after one and five minutes. Permanent rejection and invalid content become
1131
+ terminal `failed`.
1132
+
1133
+ `unknown` means SMTP may have accepted a message but Email cannot prove the
1134
+ outcome. A persisted successful Medusa Notification is accepted as proof of
1135
+ delivery; otherwise an expired attempt lease or uncharacterized transport
1136
+ result becomes terminal `unknown` and is never retried automatically. An Admin
1137
+ may consciously resend it as a new logical message.
1138
+
1139
+ Business keys deduplicate logical automatic messages, attempt claims serialize
1140
+ workers, and each attempt has its own Notification idempotency key. These
1141
+ controls do not promise exact-once SMTP: a server can accept mail before the
1142
+ process persists success, and a conscious resend can produce a duplicate.
1143
+
1144
+ Email transport retries and Event Bus retries are separate. Once an ordinary
1145
+ message outcome is persisted, its subscriber completes and the Email retry job
1146
+ owns any `retry_pending` work. Password-reset token and URL data remain
1147
+ ephemeral; password reset is excluded from the generic retry job and Admin
1148
+ resend, and retryable handling stays bounded by the original Event Bus event.
1149
+
1150
+ ### Admin API
1151
+
1152
+ Email exposes authenticated Admin operations at:
1153
+
1154
+ - `GET /admin/email/messages` lists safe message summaries. Pagination is
1155
+ bounded and deterministic; `kind` and `status` accept only the published
1156
+ allowlists.
1157
+ - `GET /admin/email/messages/:id` returns the safe message detail and delivery
1158
+ attempts.
1159
+ - `POST /admin/email/messages/:id/resends` makes one explicit resend decision
1160
+ using a request UUID.
1161
+ - `GET /admin/email/terms-versions` lists safe Terms version metadata in newest
1162
+ effective order.
1163
+ - `POST /admin/email/terms-versions` idempotently stores one private PDF of at
1164
+ most 5 MiB with its `effective_at` timestamp.
1165
+ - `GET /admin/email/terms-versions/:id/download` streams one authenticated,
1166
+ non-cacheable PDF download.
1167
+
1168
+ List and detail responses expose only safe identifiers and linkage metadata,
1169
+ kind/status and safe status codes, resource references, trigger, attempt
1170
+ counts, and timestamps. They never expose recipients, rendered content,
1171
+ template data, payloads, reset tokens, transport responses, or credentials.
1172
+ Terms responses expose filename, effective timestamp, byte size, SHA-256, and
1173
+ creation time, but never request IDs, file IDs, storage keys, URLs, or content.
1174
+
1175
+ An Admin resend is accepted only for a `sent`, `unknown`, or terminal `failed`
1176
+ source. Password-reset messages can never use the generic resend operation.
1177
+ The authenticated Admin actor is derived by the server and is not accepted
1178
+ from the request body. Replaying the same request UUID returns the same resend;
1179
+ a different UUID represents a separate, conscious Admin decision. The Admin UI
1180
+ therefore retains a UUID while retrying an ambiguous failed request and creates
1181
+ a new UUID only after a new confirmation.
1182
+
1183
+ ### Admin UI
1184
+
1185
+ The E-Mail module has one Admin page, `/settings/hartl-services/email`
1186
+ (**Settings → Hartl Services Plugins → E-Mail → Öffnen**; the descriptor's
1187
+ `admin_page` points the hub there) instead of a top-level sidebar item. It
1188
+ composes the base's `PluginPageShell` and `PluginFeatureSections` from
1189
+ `@hartl-services/medusa-base/admin-components` (the toggles above) and adds
1190
+ two operations sections below them, in this order: **AGB/PDF-Versionen** and,
1191
+ as the last section, the **E-Mail-Protokoll**. They show Terms PDF and mail
1192
+ log state to act on, not configuration the plugin reads at run time. The mail
1193
+ log provides bounded server pagination, allowlisted status/kind filters, safe
1194
+ message and attempt status, and an explicitly confirmed resend action.
1195
+ Recipient, template, and payload data are intentionally absent from the page.
1196
+
1197
+ The `AGB/PDF-Versionen` section manages the global Terms PDFs. Admins can
1198
+ upload a PDF of at most 5 MiB with its effective date, review versions newest
1199
+ first, and download a selected version. Uploads receive a fresh request UUID;
1200
+ downloads use the configured authenticated Admin SDK and never expose storage
1201
+ file IDs or URLs in the UI.
1202
+
1203
+ Admin consumers import wire DTOs and paths only from the Contracts package and
1204
+ reuse their configured Medusa SDK:
1205
+
1206
+ ```ts
1207
+ import type { ListAdminEmailMessagesResponse } from "@hartl-services/medusa-food-supplements-contracts/types/email-admin";
1208
+ import { emailAdminPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/email";
1209
+
1210
+ const response = await sdk.client.fetch<ListAdminEmailMessagesResponse>(
1211
+ emailAdminPaths.get.list,
1212
+ );
1213
+ ```
1214
+
1215
+ Do not import E-Mail module implementations, generated `.medusa` files, or
1216
+ duplicate runtime schemas in an Admin consumer. The base's own exports remain
1217
+ internal server and generated Admin integration surfaces; Email HTTP contracts
1218
+ are published only by `@hartl-services/medusa-food-supplements-contracts`.
1219
+
1220
+ ### Development and Rollout
1221
+
1222
+ For local development, provide the seven SMTP variables and omit `REDIS_URL` to
1223
+ use Medusa's local Event Bus. Set `REDIS_URL` only when intentionally exercising
1224
+ the direct Redis provider. The backend HTTP runner ignores ambient Redis and
1225
+ SMTP settings and injects an isolated Redis container, unique queue, and
1226
+ token-protected SMTP fixture for every child.
1227
+
1228
+ This architecture replaces the old Documents and Affiliate delivery baselines
1229
+ without a backfill or compatibility layer. Existing local databases from those
1230
+ baselines must be wiped once with:
1231
+
1232
+ ```bash
1233
+ pnpm --dir apps/backend db:recreate
1234
+ ```
1235
+
1236
+ The first production rollout must start from an empty database and run
1237
+ `pnpm --dir apps/backend db:migrate` before accepting traffic. This assumes no
1238
+ production business data exists; a populated database requires a separately
1239
+ reviewed migration. No old delivery rows or queued jobs are migrated or purged
1240
+ by Email.
1241
+
1242
+ ## Dokumente
1243
+
1244
+ The base issues order documents: one total invoice per order after the final
1245
+ shipment and one negative cancellation document for each eligible payment
1246
+ refund. An Admin user can regenerate issued documents in place for exceptional
1247
+ corrections. The base ships default German document markup, so a new shop needs
1248
+ no template code; a project can optionally replace the HTML/CSS factory. PDF
1249
+ generation, private file storage, customer authorization and durable
1250
+ document-readiness facts stay inside the `documents` module. There is no
1251
+ separate documents package since 1.0.0.
1252
+
1253
+ ### What it does
1254
+
1255
+ - Listens to Medusa's `shipment.created` event.
1256
+ - Creates an invoice only if the order is fully shipped and not cancelled.
1257
+ Partial shipments do not create a document.
1258
+ - Saves the rendered PDF and the exact source data as a document snapshot.
1259
+ - Reads eligible B2B evidence frozen under the order ID from Tax Compliance and
1260
+ persists the customer company, customer UID and exemption wording in that
1261
+ same snapshot. B2C and non-eligible evidence add no tax-proof statement.
1262
+ - Freezes a tax breakdown (`totals.tax_breakdown`) grouped by the order's
1263
+ item and shipping tax-line name and rate, as configured on the Medusa tax
1264
+ region. Amounts are rounded to the currency and add up exactly to the
1265
+ tax total; cancellations scale it proportionally. Snapshots issued before
1266
+ this field existed read as an empty breakdown.
1267
+ - Persists one immutable decision per shipped Fulfillment. A leased, Order-
1268
+ unique coordinator chooses exactly one invoice carrier without assuming
1269
+ event delivery order; all other shipments become `non_carrier`.
1270
+ - Uses a durable per-order issuance key and token-fenced finalization, so
1271
+ duplicate, concurrent or delayed events cannot create a second initial
1272
+ invoice or committed carrier.
1273
+ - Allocates financial document numbers from Documents-owned, transactional
1274
+ annual number ranges. Invoice, cancellation and future credit-note ranges are
1275
+ independent of Medusa Order display IDs and safe across backend workers.
1276
+ - Lets an Admin regenerate the invoice in place with a fresh order and active
1277
+ batch-allocation snapshot.
1278
+ - Listens to Medusa's successful `payment.refunded` event and issues one
1279
+ cancellation document per persisted refund, including partial refunds.
1280
+ - Uses the immutable Refund ID as both cancellation issuance key and resolution
1281
+ identity, so replayed payment events cannot create duplicate cancellation
1282
+ documents or readiness facts.
1283
+ - Reconciles the complete Refund set after Order cancellation, including Medusa
1284
+ bulk refunds that do not emit a singular refund event. A canceled Order with
1285
+ zero Refunds still receives a final empty cancellation resolution.
1286
+ - Publishes strict versioned shipment, Refund and Order-cancellation facts only
1287
+ after their database state and referenced immutable artifacts are committed.
1288
+ Publication markers and periodic reconciliation close the database-to-event
1289
+ enqueue gap.
1290
+ - Exposes only an authenticated customer Store API. A customer can list,
1291
+ inspect and download only documents belonging to their own customer ID.
1292
+ Guest documents deliberately have no Store lookup path.
1293
+ - Adds an Admin order-detail widget. Authenticated Admin users can view the
1294
+ issued documents belonging to the current order as a private inline PDF.
1295
+ - Supports `invoice`, `cancellation` and `credit_note` document kinds.
1296
+ Cancellation issuance is wired to payment refunds; `credit_note` remains a
1297
+ reserved type for a future, distinct business process.
1298
+ - Creates one internal packing list for every physical fulfillment, including
1299
+ optional fulfillment-scoped batch data. Partial fulfillments therefore
1300
+ produce independent packing lists.
1301
+ - Keeps packing lists in a separate Admin-only aggregate. They have no customer
1302
+ owner, Store route, communication artifact or customer-facing Documents event.
1303
+ - Reconciles non-canceled physical fulfillments periodically so a lost local
1304
+ event can be repaired idempotently, including after shipment.
1305
+
1306
+ ### Configuration
1307
+
1308
+ `@hartl-services/medusa-tax-compliance` is a required *registered plugin*,
1309
+ intentionally not a package dependency of the base: install it and list it in
1310
+ `medusa-config.ts` yourself. The invoice workflow resolves the
1311
+ `tax_compliance` module with no fallback; a shop that does not register Tax
1312
+ Compliance fails at invoice issuance time (the first `shipment.created`
1313
+ event) with an `UNEXPECTED_STATE` error naming the missing plugin, not at
1314
+ startup.
1315
+
1316
+ ```ts
1317
+ export default defineConfig({
1318
+ modules: [
1319
+ {
1320
+ resolve: "@medusajs/medusa/tax",
1321
+ options: {
1322
+ providers: [
1323
+ {
1324
+ resolve:
1325
+ "@hartl-services/medusa-tax-compliance/providers/tax-compliance",
1326
+ },
1327
+ ],
1328
+ },
1329
+ },
1330
+ ],
1331
+ plugins: [
1332
+ {
1333
+ resolve: "@hartl-services/medusa-base",
1334
+ options: {
1335
+ documents: {
1336
+ numbering: {
1337
+ timeZone: "Europe/Vienna", // default when omitted
1338
+ },
1339
+ pdf: {
1340
+ executablePath: process.env.DOCUMENT_PDF_EXECUTABLE, // default: "google-chrome"
1341
+ },
1342
+ },
1343
+ },
1344
+ },
1345
+ {
1346
+ resolve: "@hartl-services/medusa-tax-compliance",
1347
+ options: {
1348
+ merchant: {
1349
+ countryCode: process.env.MERCHANT_COUNTRY_CODE!,
1350
+ vatNumber: process.env.MERCHANT_VAT_NUMBER!,
1351
+ },
1352
+ },
1353
+ },
1354
+ ],
1355
+ });
1356
+ ```
1357
+
1358
+ Options of the `documents` namespace (all optional; `{}` is valid):
1359
+
1360
+ - `htmlFactory` — optional. A `(input: DocumentRenderInput) => string |
1361
+ Promise<string>` function that replaces the built-in German markup; the
1362
+ module owns PDF rendering and storage either way. When omitted, the module
1363
+ uses `createDefaultDocumentHtmlFactory({ timeZone })` with the resolved
1364
+ `numbering.timeZone`, so printed dates are shown in the same time zone that
1365
+ picks the numbering period. A value that is not a function is rejected at
1366
+ startup.
1367
+ - `numbering.timeZone` — an IANA time zone used to resolve the annual
1368
+ numbering period and the dates printed by the default markup. Defaults to
1369
+ `"Europe/Vienna"`; a given value (also a blank one) is validated and
1370
+ rejected at startup when invalid.
1371
+ - `pdf.executablePath` — Chromium/Chrome executable path or command name.
1372
+ Defaults to `"google-chrome"`.
1373
+ - `merchant` — optional fallback for the shop master data (see "Merchant
1374
+ data" below), shape `DocumentMerchantOption`:
1375
+ `{ countryCode, legalName, registeredAddress: { address1, address2?, city,
1376
+ countryCode, postalCode }, vatNumber? }`.
1377
+
1378
+ The `documents` module is always registered with the base (no `documentsEnabled`
1379
+ switch); the Admin switches it off at run time through the feature toggles
1380
+ below. `medusa db:migrate` creates its tables.
1381
+
1382
+ ### The `/config` subpath
1383
+
1384
+ `@hartl-services/medusa-base/config` exports, next to the environment helpers,
1385
+ build-time values for `medusa-config.ts` and tests:
1386
+
1387
+ - `createDefaultDocumentHtmlFactory({ timeZone })` — the built-in factory, to
1388
+ use as it is or to wrap (for example to add a header to the default HTML).
1389
+ - `DOCUMENTS_BUILD_PLACEHOLDERS` — `{ DOCUMENT_PDF_EXECUTABLE: "google-chrome" }`.
1390
+ `medusa build` evaluates `medusa-config.ts` without the runtime-only
1391
+ environment, so spread it under the real environment while building.
1392
+
1393
+ The document types (`DocumentMerchantOption`, `DocumentHtmlFactory`,
1394
+ `DocumentRenderInput`, ...) come from `@hartl-services/medusa-base/types`.
1395
+
1396
+ ### Merchant data
1397
+
1398
+ The legal sender printed on every document comes from the base plugin's shop
1399
+ master data (Admin → Settings → Hartl Services Plugins → **Shop-Stammdaten**).
1400
+ Each render reads it and passes it to the `htmlFactory` as `input.merchant`
1401
+ (`DocumentMerchant`: `legal_name`, `address_line1`, `address_line2`,
1402
+ `postal_code`, `city`, `country_code`, `vat_number`, `email`, `phone`,
1403
+ `website`, `iban`, `bic`, `company_register_number`,
1404
+ `company_register_court`; unset optional fields are `null`). Per field, a
1405
+ value stored in the Admin wins over the `merchant` option.
1406
+
1407
+ - `merchant` is not part of the stored snapshot: a regenerated document
1408
+ prints the master data current at regeneration time.
1409
+ - The development UID `ATU00000000` arrives as `vat_number: null` and is
1410
+ never printed.
1411
+ - Without `legal_name`, `address_line1`, `postal_code`, `city` and
1412
+ `country_code` a document cannot be issued: issuance fails with
1413
+ `UNEXPECTED_STATE` ("Shop-Stammdaten unvollständig: …" naming the missing
1414
+ fields). Invoices and cancellations check this before a document number
1415
+ and issue date are reserved, so no number is used up: the invoice workflow
1416
+ fails (the subscriber logs it, the shipment assignment stays pending), a
1417
+ refund resolution is marked failed. A packing list revision is marked
1418
+ failed with the message, which the Admin order widget shows. Once the data
1419
+ is complete, the existing paths issue it again (event redelivery,
1420
+ reconciliation jobs, the Admin issue and retry actions). A missing UID
1421
+ does not block issuance.
1422
+
1423
+ Requirements/couplings:
1424
+
1425
+ - A registered Tax Compliance module (`tax_compliance`) as shown above.
1426
+ - A Chromium/Chrome executable and a Medusa File provider that supports
1427
+ private files (see "Development" below for the sandbox/non-root note).
1428
+ Documents stores PDFs through `Modules.FILE` and streams them only through
1429
+ its own authenticated routes, so it works with any File provider and does
1430
+ not need the S3 file storage or its `/files` routes.
1431
+ **With Medusa's local File provider, set `private_upload_dir` to a
1432
+ directory outside `static/`:** its default stores private files in
1433
+ `static/`, which Medusa serves publicly under `/static`, making every
1434
+ invoice downloadable by its guessable file name. The base plugin still
1435
+ starts in that case (it ships the Documents module to every shop), but logs
1436
+ a warning at startup and refuses to write any document: issuing an invoice
1437
+ or packing list fails until the File Module stores private files outside
1438
+ `static/`.
1439
+ - Optionally, `@hartl-services/medusa-batch-management` 0.0.3+ for
1440
+ batch/lot data on invoices and packing lists (see "Optional Batch
1441
+ Management integration" below).
1442
+
1443
+
1444
+ ### Feature toggles
1445
+
1446
+ The Documents module declares its toggles to the base under the plugin key
1447
+ `documents`. An Admin switches them under Settings → "Hartl
1448
+ Services Plugins"; Storefronts read the effective values from
1449
+ `GET /store/plugin-features/documents`. A disabled plugin or feature answers
1450
+ its routes with HTTP 404, and its subscribers and jobs return without doing
1451
+ anything. Store routes answer 404 even without a customer login (the gate runs
1452
+ before the route's authentication); Admin routes still require an Admin login
1453
+ first, because Medusa authenticates every `/admin` request before any plugin
1454
+ middleware (401 without login, 404 afterwards). A feature is only effective while the plugin
1455
+ switch (default on) is on.
1456
+
1457
+ | Key | Label | Default | Gates | Keeps running when off |
1458
+ | --------------- | -------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
1459
+ | (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 |
1460
+ | `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 |
1461
+ | `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 |
1462
+
1463
+ **Events are not replayed.** While `invoices` is off, no invoice,
1464
+ cancellation or refund document is issued for new shipments, cancellations
1465
+ or refunds, and nothing is issued later for them either once the feature is
1466
+ switched back on; issue a missing invoice by hand with
1467
+ `POST /admin/orders/:id/documents`. While `packing_lists` is off, no packing
1468
+ list is created, revised or canceled; after switching it back on, the
1469
+ periodic `documents-packing-list-reconciliation` job creates the missing
1470
+ packing lists of non-canceled physical fulfillments (cancellations that
1471
+ happened meanwhile are not replayed).
1472
+
1473
+ Admin surfaces of a disabled feature are hidden. The Admin widgets on the
1474
+ order detail page (`order-documents`, `order-packing-lists`) stay registered
1475
+ but render nothing while their feature (`invoices`, `packing_lists`) is off,
1476
+ using `PluginFeatureGate` from
1477
+ `@hartl-services/medusa-base/admin-components`.
1478
+
1479
+ No workflow hook and nothing on the cart, checkout or fulfillment path reads
1480
+ a toggle.
1481
+
1482
+ ### Shop integration
1483
+
1484
+ The demo shop (`apps/backend`) gets everything from the preset (see
1485
+ "Shop-Config-Preset"): `documents.merchant` is its `merchant` option,
1486
+ `documents.numbering.timeZone` its `timeZone` option and
1487
+ `documents.pdf.executablePath` the `DOCUMENT_PDF_EXECUTABLE` variable. The
1488
+ development UID `ATU00000000` is a placeholder: Documents never prints it,
1489
+ and Tax Compliance does not transmit it to FinanzOnline. Enter the real
1490
+ merchant UID in the Shop-Stammdaten before issuing production invoices.
1491
+
1492
+ `htmlFactory` is intentionally the only presentation extension point. It gets
1493
+ a fully typed value snapshot (`DocumentTemplateInput` or
1494
+ `PackingListTemplateInput`) plus `merchant` (see "Merchant data"), never a
1495
+ live Medusa model. Escape every dynamic value in the factory; the HTML is
1496
+ rendered by a headless Chromium process before it is stored as a private PDF.
1497
+
1498
+ The consuming host needs a Chromium/Chrome executable and a Medusa File
1499
+ provider that supports private files. Run the Medusa process as a dedicated
1500
+ non-root user: the PDF renderer keeps Chromium's sandbox enabled and therefore
1501
+ refuses to start as root. Installation and Dokploy runtime checks are
1502
+ documented under `Chromium für Dokument-PDFs` in `apps/backend/README.md`.
1503
+ Each render uses an ephemeral Chrome profile below its private temporary
1504
+ directory and removes it afterward. The Chromium child is also disconnected
1505
+ from the user's session bus and legacy keyring socket, so it cannot access the
1506
+ operator's normal browser profile or desktop keyring, including under WSLg.
1507
+
1508
+ ### Restore check for private files
1509
+
1510
+ After restoring the object store (or the local file directory), check that
1511
+ every issued document's PDF can be read through the active Medusa File
1512
+ provider. The read-only script lives in the base but is deliberately not a
1513
+ migration script, so `db:migrate` never runs it:
1514
+
1515
+ ```bash
1516
+ pnpm exec medusa exec ./node_modules/@hartl-services/medusa-base/.medusa/server/src/documents/scripts/verify-documents-file-storage.js
1517
+ ```
1518
+
1519
+ It fails on the first issued document without a stored file and otherwise
1520
+ logs the number of verified files and bytes.
1521
+
1522
+ ### Clean baseline and first deployment
1523
+
1524
+ The module intentionally contains one generated eight-table baseline,
1525
+ `Migration20260827061749`. It removes the former `documents_delivery` table
1526
+ entirely and adds the Artifact, Shipment-assignment/coordinator and Refund-/
1527
+ Order-cancellation resolution tables. Old delivery rows are not migrated:
1528
+ Email now owns logical messages and attempts. The generated snapshot is not
1529
+ edited by hand, while migration-only constraints and indexes remain in the
1530
+ baseline.
1531
+
1532
+ `Migration20260830111207` is the forward migration that adds the
1533
+ `documents_document_number_sequence` table, makes every financial document's
1534
+ number and issue date mandatory, and protects the complete document number with
1535
+ an unconditional unique index. It deliberately does not renumber legacy rows.
1536
+ A disposable local database containing pre-migration Documents rows with null
1537
+ or duplicate numbers must be recreated rather than silently backfilled.
1538
+
1539
+ A disposable local database that has already run an older Documents baseline
1540
+ must be recreated once after stopping the backend and verifying the exact local
1541
+ `DATABASE_URL` target:
1542
+
1543
+ ```bash
1544
+ pnpm --dir apps/backend db:recreate
1545
+ ```
1546
+
1547
+ This command deletes all data and is restricted to local development. Never use
1548
+ it for staging, shared or production databases; those require an
1549
+ environment-owner-approved reset. A first production installation is supported
1550
+ only on a deliberately empty database. Run:
1551
+
1552
+ ```bash
1553
+ pnpm --dir apps/backend db:migrate
1554
+ pnpm --dir apps/backend exec medusa db:sync-links
1555
+ ```
1556
+
1557
+ `db:migrate` applies all module baselines and Medusa's pending initial seed once;
1558
+ do not invoke that seed separately. If any production database has already run
1559
+ an older Documents baseline or contains data that must survive, stop: this clean
1560
+ baseline is not a forward migration or backfill and a new forward migration is
1561
+ required instead.
1562
+
1563
+ Apply normal pending module migrations after later changes or upgrades with
1564
+ `pnpm --dir apps/backend db:migrate`. This plugin defines no module links, so it
1565
+ does not require link synchronization by itself; `db:sync-links` above is the
1566
+ safe shop-wide first-deployment step for links owned elsewhere.
1567
+
1568
+ ### Storefront contract
1569
+
1570
+ All three endpoints require a customer session or bearer token. There is no
1571
+ guest lookup endpoint and no direct File-provider URL.
1572
+
1573
+ - `GET /store/documents` – document summaries for the authenticated customer.
1574
+ - `GET /store/documents/:id` – one owned document summary.
1575
+ - `GET /store/documents/:id/download` – streams the owned private PDF.
1576
+
1577
+ The public DTO deliberately includes only the document number, kind, issue
1578
+ date, order display ID and total. It excludes the immutable snapshot, recipient
1579
+ address/email, file and artifact IDs, leases and resolution state.
1580
+
1581
+ For a typed Storefront integration:
1582
+
1583
+ ```ts
1584
+ import type { ListStoreDocumentsResponse } from "@hartl-services/medusa-food-supplements-contracts/types/documents-store";
1585
+ import { documentsPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/documents";
1586
+
1587
+ const { documents: issuedDocuments } =
1588
+ await sdk.client.fetch<ListStoreDocumentsResponse>(
1589
+ documentsPaths.store.get.list,
1590
+ { method: "GET" },
1591
+ );
1592
+ const downloadPath = documentsPaths.store.get.download(issuedDocuments[0].id);
1593
+ ```
1594
+
1595
+ The Storefront should request `downloadPath` with its authenticated SDK;
1596
+ it is deliberately a protected application route rather than an exposed object
1597
+ storage URL.
1598
+
1599
+ The same Contracts boundary applies to all Documents and Tax Compliance public
1600
+ routes: import transport DTOs only from
1601
+ `@hartl-services/medusa-food-supplements-contracts/types/<feature>`, paths only from
1602
+ `@hartl-services/medusa-food-supplements-contracts/paths/<feature>`, and call the
1603
+ Storefront's configured Medusa SDK. A consumer that needs a runtime guard may
1604
+ opt in to `@hartl-services/medusa-food-supplements-contracts/schemas/<feature>`; it must
1605
+ not import either the plugin implementation or generated `.medusa` output.
1606
+
1607
+ ### Admin order detail
1608
+
1609
+ When Documents is enabled, the Medusa Admin order detail shows a
1610
+ **Dokumente** widget. It lists the issued documents for that order and opens a
1611
+ private PDF preview. The widget obtains the PDF through the Medusa JS SDK, so
1612
+ both session and bearer-token Admin authentication work without exposing a
1613
+ File-provider URL.
1614
+
1615
+ The Admin API is deliberately scoped by both order and document ID:
1616
+
1617
+ - `GET /admin/orders/:id/documents`
1618
+ - `POST /admin/orders/:id/documents/regenerate`
1619
+ - `POST /admin/orders/:id/documents/:document_id/regenerate`
1620
+ - `GET /admin/orders/:id/documents/:document_id/download`
1621
+
1622
+ All authenticated Admin users can inspect order documents; a document from a
1623
+ different order returns `404` from the download endpoint.
1624
+
1625
+ #### Internal packing lists
1626
+
1627
+ The separate **Packlisten** order widget lists every physical fulfillment in
1628
+ creation order. A ready entry can be previewed and downloaded as a private PDF;
1629
+ missing or failed entries can be generated again through the same idempotent
1630
+ workflow used by the automatic subscriber.
1631
+
1632
+ - `GET /admin/orders/:id/packing-lists`
1633
+ - `POST /admin/orders/:id/packing-lists/:fulfillment_id/retry`
1634
+ - `GET /admin/orders/:id/packing-lists/:fulfillment_id/download`
1635
+
1636
+ The PDF freezes the fulfillment-item title, SKU, quantity and Medusa Stock
1637
+ Location name. Managed variants with multiple inventory items are listed as
1638
+ their concrete warehouse components. Their quantity is the concrete inventory
1639
+ quantity after Medusa's `required_quantity` multiplier, not necessarily the
1640
+ commercial order-line quantity.
1641
+
1642
+ Packing lists never pass through the Documents customer-fact pipeline. They are
1643
+ also stored outside `documents_document`, so the authenticated Store document
1644
+ queries cannot expose them accidentally.
1645
+
1646
+ #### Regenerating an invoice
1647
+
1648
+ Once an invoice exists, the widget changes **Rechnung ausstellen** to
1649
+ **Rechnung regenerieren**. This action retains the document ID, document number
1650
+ and original issue date, but replaces its persisted PDF and snapshot with a
1651
+ fresh render. A prior artifact that is locked by a committed communication fact
1652
+ and its private File are retained permanently. Only an unlocked superseded
1653
+ artifact File may be deleted on a best-effort basis after the replacement has
1654
+ been stored, so Storefront and Admin continue to show exactly one current PDF.
1655
+
1656
+ Regeneration re-applies the normal completed-shipment and cancellation checks,
1657
+ then reads the current order values and active batch withdrawals. It is a
1658
+ deliberate exception workflow and does not emit a customer communication event.
1659
+
1660
+ The document-specific regeneration route is used by the widget for every
1661
+ issued document. For invoices it has the same live-data behavior described
1662
+ above. A cancellation or future credit note is instead rendered from its
1663
+ persisted snapshot, retaining its original amount, source invoice reference,
1664
+ number and issue date. It also emits no customer communication event.
1665
+
1666
+ #### Financial document numbers
1667
+
1668
+ Documents assigns numbers in these annual ranges:
1669
+
1670
+ ```text
1671
+ RE-2026-1
1672
+ ST-2026-1
1673
+ GS-2026-1
1674
+ ```
1675
+
1676
+ The prefix identifies the document kind, the year is resolved in the selected
1677
+ shop's configured numbering time zone, and the final integer is allocated by a
1678
+ transactional PostgreSQL counter. A new range starts at `1` for each document
1679
+ kind and year. `order_display_id` remains a separate order reference in the
1680
+ snapshot and PDF; it never determines a financial document number.
1681
+
1682
+ Number and issue date are reserved in the same short transaction that creates
1683
+ the processing document. PDF rendering and File I/O remain outside database
1684
+ transactions. A retry reuses the reserved identity and regeneration never
1685
+ increments a range. A permanently failed document retains its number and
1686
+ failure reason as the explanation for that range's gap; reserved numbers are
1687
+ never recycled or reassigned.
1688
+
1689
+ #### Cancellation documents for payment refunds
1690
+
1691
+ Medusa's refund event identifies the payment, so the plugin enumerates its
1692
+ persisted refund records and claims one cancellation document using each
1693
+ immutable refund ID. This makes delivery at-least-once safe: repeated events
1694
+ or a retry leave an already issued cancellation unchanged.
1695
+
1696
+ When an invoice exists, the cancellation issue date is reserved when Documents
1697
+ starts issuing the cancellation and its amount is negative. Since a payment
1698
+ refund has no line-item allocation, the plugin proportionally derives the
1699
+ original invoice's subtotal and tax from the
1700
+ immutable invoice snapshot; the document has one explicit `Rückerstattung zu
1701
+ Rechnung …` line. The document, immutable artifact and final Refund resolution
1702
+ are committed in one short token-fenced transaction.
1703
+
1704
+ For a non-canceled Refund without an existing invoice, Documents finalizes a
1705
+ `not_applicable` resolution and publishes the Refund fact without an artifact.
1706
+ It does not wait forever for a future invoice, and later invoice issuance does
1707
+ not create a second customer fact. For a canceled Order, individual Refund
1708
+ facts are suppressed in favor of the authoritative Order-cancellation
1709
+ resolution and its sorted artifact list.
1710
+
1711
+ ### Optional Batch Management integration
1712
+
1713
+ If `@hartl-services/medusa-batch-management` version `0.0.3` or later is registered, Documents detects
1714
+ its `getOrderDocumentBatchSnapshot(orderId)` capability structurally. For every
1715
+ active withdrawal it prints the persisted **charge/lot**, **MHD** and quantity
1716
+ under the matching invoice line.
1717
+
1718
+ These values are captured at FEFO withdrawal time. Regenerating an invoice
1719
+ reads the active withdrawals again: reverse/correct the order's allocation
1720
+ first, then regenerate to put that corrected allocation on the document.
1721
+ Renaming a Batch master record alone deliberately does not change an already
1722
+ persisted withdrawal snapshot. A shipment received before the matching durable
1723
+ batch-withdrawal run is available fails safely and can be retried. An installed
1724
+ incompatible Batch version fails explicitly before a PDF is issued; upgrade
1725
+ Batch Management and apply its migration first.
1726
+
1727
+ Packing lists use the separate structural capability
1728
+ `getFulfillmentPackingListBatchSnapshot(fulfillmentId)`. It returns only active
1729
+ or already returned immutable LOT snapshots belonging to that exact
1730
+ fulfillment. Return receipts reconstruct the original picked quantity before a
1731
+ delayed first render. Documents validates that every managed fulfillment item's
1732
+ concrete quantity equals the sum of its LOT quantities before rendering. If
1733
+ Batch Management is not registered, the same packing list is issued without LOT
1734
+ rows. An installed but incompatible Batch version fails explicitly.
1735
+
1736
+ A manual allocation reassignment increments the fulfillment run's allocation
1737
+ revision and emits an internal event. Documents stores a new immutable packing
1738
+ list revision and serves the newest ready PDF. Returns do not rewrite the
1739
+ historical pick instruction. Fulfillment cancellation marks the packing list as
1740
+ canceled and prevents a concurrent render from being published as active.
1741
+
1742
+ ### Document facts and artifacts
1743
+
1744
+ Documents owns no Notification or delivery state. Its integration boundary is
1745
+ the strict versioned event contract exported from
1746
+ `@hartl-services/medusa-food-supplements-contracts/events/documents`. Shipment assignments, standalone Refund resolutions and Order-
1747
+ cancellation resolutions each carry an `event_emitted_at` marker that is set
1748
+ only after successful enqueue.
1749
+
1750
+ Every customer-facing attachment references an immutable `application/pdf`
1751
+ artifact. The artifact becomes permanently `communication_locked` in the same
1752
+ transaction that finalizes the fact. The publication reconciler first verifies
1753
+ the private File remains readable, and then safely republishes any final fact
1754
+ whose marker is still null. Refunds without an invoice and zero-Refund
1755
+ cancellations are the deliberate attachment-free cases.
1756
+
1757
+ Document issuance and resolution work uses ten-minute token leases. PDF and
1758
+ File I/O runs outside database transactions. If File creation succeeds after a
1759
+ worker has lost its lease, the unreferenced private File is deleted best-effort;
1760
+ a hard process exit in that external File/database gap can leave a storage
1761
+ orphan but cannot commit another Artifact, carrier or resolution.
1762
+
1763
+ #### Exact outgoing event contracts
1764
+
1765
+ The server-only Contracts subpath
1766
+ `@hartl-services/medusa-food-supplements-contracts/events/documents` exports the
1767
+ event-name constants,
1768
+ strict Zod schemas and inferred payload types for exactly these three events:
1769
+
1770
+ ```ts
1771
+ type DocumentsEmailArtifact = {
1772
+ artifact_id: string;
1773
+ document_id: string;
1774
+ document_number: string;
1775
+ file_id: string;
1776
+ filename: string;
1777
+ content_type: "application/pdf";
1778
+ };
1779
+
1780
+ type DocumentsOutgoingEmailEvent =
1781
+ | {
1782
+ name: "documents.shipment_invoice_assignment.decided.v1";
1783
+ data:
1784
+ | {
1785
+ schema_version: 1;
1786
+ order_id: string;
1787
+ fulfillment_id: string;
1788
+ decision: "non_carrier";
1789
+ }
1790
+ | {
1791
+ schema_version: 1;
1792
+ order_id: string;
1793
+ fulfillment_id: string;
1794
+ decision: "carrier";
1795
+ invoice_fulfillment_id: string;
1796
+ invoice_artifact: DocumentsEmailArtifact;
1797
+ };
1798
+ }
1799
+ | {
1800
+ name: "documents.order_cancellation_documents.ready.v1";
1801
+ data: {
1802
+ schema_version: 1;
1803
+ order_id: string;
1804
+ expected_refund_ids: string[];
1805
+ attachments: Array<{
1806
+ payment_id: string;
1807
+ refund_id: string;
1808
+ artifact: DocumentsEmailArtifact;
1809
+ }>;
1810
+ };
1811
+ }
1812
+ | {
1813
+ name: "documents.refund_document.ready.v1";
1814
+ data: {
1815
+ schema_version: 1;
1816
+ order_id: string;
1817
+ payment_id: string;
1818
+ refund_id: string;
1819
+ artifact: DocumentsEmailArtifact | null;
1820
+ };
1821
+ };
1822
+ ```
1823
+
1824
+ Every `string` above is trimmed and non-empty. For a `carrier`,
1825
+ `invoice_fulfillment_id` must equal `fulfillment_id`. Cancellation
1826
+ `expected_refund_ids` are lexicographically sorted and unique; `attachments`
1827
+ are sorted by unique `refund_id`, and every attached Refund belongs to the
1828
+ expected set. `artifact: null` is the final no-Invoice Refund result. The events
1829
+ contain no recipient, template, Notification ID, SMTP setting or delivery
1830
+ status.
1831
+
1832
+ Documents owns committed facts and their at-least-once publication. The E-Mail
1833
+ module owns recipient resolution, logical message deduplication, templates,
1834
+ attachments at dispatch, Medusa Notification records, SMTP attempts, status and
1835
+ explicit Admin resends. See the central
1836
+ [Email design](../../docs/superpowers/specs/2026-08-26-central-email-plugin-design.md)
1837
+ and
1838
+ [implementation plan](../../docs/superpowers/plans/2026-08-26-central-email-plugin.md).
1839
+
1840
+ Neither event publication nor SMTP promises exactly-once delivery. Publication
1841
+ reconciliation and the Redis Event Bus may deliver a fact more than once; Email
1842
+ deduplicates the logical message by its business key. SMTP may accept a message
1843
+ before the process can persist success, so an ambiguous attempt becomes
1844
+ `unknown` and is not automatically sent again. Any later resend is an explicit
1845
+ Email Admin action and a new logical message.
1846
+
1847
+ ### Development
1848
+
1849
+ #### Public Storefront and Admin contracts
1850
+
1851
+ The central contracts package exports the Documents Store and Admin DTOs, Zod
1852
+ schemas and route paths, but no second Storefront client. Consumers use their
1853
+ own Medusa SDK instance:
1854
+
1855
+ ```ts
1856
+ import type { GetStoreDocumentResponse } from "@hartl-services/medusa-food-supplements-contracts/types/documents-store";
1857
+ import { documentsPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/documents";
1858
+
1859
+ const response = await sdk.client.fetch<GetStoreDocumentResponse>(
1860
+ documentsPaths.store.get.retrieve(documentId),
1861
+ { method: "GET" },
1862
+ );
1863
+ ```
1864
+
1865
+ `@hartl-services/medusa-food-supplements-contracts/types/documents-store` and
1866
+ `@hartl-services/medusa-food-supplements-contracts/types/documents-admin` contain the
1867
+ respective type-only DTOs. Import
1868
+ `@hartl-services/medusa-food-supplements-contracts/schemas/documents` only for explicit
1869
+ runtime validation. The common Storefront case imports only types, which are
1870
+ erased by TypeScript, and the path module; it never imports the plugin's Medusa
1871
+ server code. Store and Admin responses use the same safe `DocumentSummary`
1872
+ transport DTO, while protected PDF download paths remain binary endpoints.
1873
+
1874
+ Add a DTO schema, central contract type and path entry, HTTP integration test,
1875
+ and README note together when introducing another Documents route.
1876
+
1877
+ Packing-list consumers stay Admin-only:
1878
+
1879
+ ```ts
1880
+ import type { ListAdminOrderPackingListsResponse } from "@hartl-services/medusa-food-supplements-contracts/types/documents-admin";
1881
+ import { packingListsPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/documents";
1882
+
1883
+ const response = await sdk.client.fetch<ListAdminOrderPackingListsResponse>(
1884
+ packingListsPaths.admin.get.listForOrder(orderId),
1885
+ { method: "GET" },
1886
+ );
1887
+ ```
1888
+
1889
+ ## Dateispeicher (S3)
1890
+
1891
+ The base provides a Medusa File Module provider for durable S3-compatible
1892
+ asset storage with a local, TTL-based read cache (`providers/s3-cache`), and
1893
+ the `/files` gateway routes every provider URL points at. It supports AWS S3,
1894
+ MinIO and compatible endpoints through AWS SDK v3. There is no separate S3
1895
+ package since 1.0.0; the provider identifier `s3-cache` and the resolve path
1896
+ `@hartl-services/medusa-base/providers/s3-cache` replace the old one.
1897
+
1898
+ S3 is always the source of truth. The local cache is a per-replica performance
1899
+ optimization and may be discarded at any time without data loss.
1900
+
1901
+ ### Coverage
1902
+
1903
+ The provider covers every asset that uses Medusa's native `Modules.FILE`
1904
+ service:
1905
+
1906
+ - public product images and other Medusa uploads;
1907
+ - private PDF documents of the Documents module, including its
1908
+ authorized download routes and email attachments;
1909
+ - future plugins that use the same File Module.
1910
+
1911
+ It cannot transparently back up a plugin that writes directly to its own local
1912
+ directory, own S3 client, or a third-party URL. Such integrations must be
1913
+ changed to use `Modules.FILE` or migrated explicitly.
1914
+
1915
+ ### Configuration
1916
+
1917
+ ```ts
1918
+ // medusa-config.ts
1919
+ import { createS3CacheStorageConfig } from "@hartl-services/medusa-base/config";
1920
+
1921
+ const s3CacheStorage = createS3CacheStorageConfig({
1922
+ s3: {
1923
+ bucket: process.env.S3_CACHE_STORAGE_BUCKET,
1924
+ region: process.env.S3_CACHE_STORAGE_REGION,
1925
+ // optional: MinIO or another S3-compatible endpoint
1926
+ endpoint: process.env.S3_CACHE_STORAGE_ENDPOINT,
1927
+ forcePathStyle: process.env.S3_CACHE_STORAGE_ENDPOINT ? true : undefined,
1928
+ // omit entirely to use the AWS SDK credential chain (e.g. an IAM role)
1929
+ credentials: process.env.S3_CACHE_STORAGE_ACCESS_KEY_ID
1930
+ ? {
1931
+ accessKeyId: process.env.S3_CACHE_STORAGE_ACCESS_KEY_ID,
1932
+ secretAccessKey: process.env.S3_CACHE_STORAGE_SECRET_ACCESS_KEY,
1933
+ }
1934
+ : undefined,
1935
+ },
1936
+ gateway: {
1937
+ baseUrl: `${process.env.MEDUSA_BACKEND_URL}/files`,
1938
+ signingSecret: process.env.FILE_GATEWAY_SIGNING_SECRET,
1939
+ },
1940
+ });
1941
+
1942
+ export default defineConfig({
1943
+ modules: [s3CacheStorage.fileModule /* , ...other modules */],
1944
+ plugins: [
1945
+ {
1946
+ resolve: "@hartl-services/medusa-base",
1947
+ options: { files: { gateway: s3CacheStorage.gateway } },
1948
+ },
1949
+ /* , ...other plugins */
1950
+ ],
1951
+ });
1952
+ ```
1953
+
1954
+ S3 Cache Storage needs **both** parts, which `createS3CacheStorageConfig`
1955
+ returns from one validated configuration:
1956
+
1957
+ - `fileModule` goes under `modules`. It registers the S3 provider as Medusa's
1958
+ File Module (`@medusajs/medusa/file`); Medusa accepts File providers only
1959
+ there, not from a plugin.
1960
+ - `gateway` goes into the base options as `files.gateway`. It carries the
1961
+ signing secret of the gateway routes every provider URL points at:
1962
+ `GET /files/:asset`, `GET /files/private/:token` and
1963
+ `PUT /files/upload/:token`. Without it, no public file, signed private
1964
+ download or presigned upload works (the routes answer that signing is not
1965
+ configured).
1966
+
1967
+ Both carry the same signing secret, so the routes accept every URL the
1968
+ provider signs. Upload CORS follows Medusa's `projectConfig.http.adminCors`.
1969
+
1970
+ `createS3CacheFileModuleConfig` remains for Medusa's stock local File Module
1971
+ (`enabled` omitted or `false`); with `enabled: true` it registers only the
1972
+ provider, without the gateway part, so use `createS3CacheStorageConfig`
1973
+ instead. The signing helpers of the routes (`createPrivateFileGatewayUrl`, ...)
1974
+ are exported from `@hartl-services/medusa-base/gateway`.
1975
+
1976
+ Required options and environment variables:
1977
+
1978
+ - `s3.bucket` (`S3_CACHE_STORAGE_BUCKET`) and `s3.region`
1979
+ (`S3_CACHE_STORAGE_REGION`).
1980
+ - `gateway.baseUrl` — the public Medusa origin plus `/files`, derived from
1981
+ `MEDUSA_BACKEND_URL`. It must never point at S3 or MinIO.
1982
+ - `gateway.signingSecret` — a strong, server-only secret used to sign private
1983
+ file and upload URLs. Changing it invalidates every outstanding signed URL.
1984
+
1985
+ Everything else (`s3.endpoint`, `s3.forcePathStyle`, `s3.storagePrefix`,
1986
+ `s3.credentials`, `gateway.privateDownloadDurationSeconds`, and the `cache.*`
1987
+ options) is optional; see
1988
+ [Active S3 options and defaults](#active-s3-options-and-defaults) below. The
1989
+ provider has no database migrations to run.
1990
+
1991
+ ### Environment helpers
1992
+
1993
+ `@hartl-services/medusa-base/config` also exports small helpers for reading
1994
+ the deployment variables in `medusa-config.ts`:
1995
+
1996
+ - `S3_BUILD_PLACEHOLDERS` — `{ S3_CACHE_STORAGE_BUCKET, S3_CACHE_STORAGE_REGION }`
1997
+ placeholder values so `medusa build` can load the configuration without
1998
+ deployment secrets. Use them only while building.
1999
+ - `resolveS3Credentials(process.env, phase)` — returns the static key pair
2000
+ from `S3_CACHE_STORAGE_ACCESS_KEY_ID`, `S3_CACHE_STORAGE_SECRET_ACCESS_KEY`
2001
+ and optional `S3_CACHE_STORAGE_SESSION_TOKEN`, or `undefined` for the AWS
2002
+ credential chain (and always during `"build"`). A declared but incomplete
2003
+ pair throws, naming the missing variables.
2004
+ - `resolveExpirationStrategy(process.env, defaultValue)` — reads
2005
+ `S3_CACHE_STORAGE_EXPIRATION_STRATEGY` (`"absolute"` or `"sliding"`).
2006
+
2007
+ ### Runtime configuration
2008
+
2009
+ The demo shop (`apps/backend`) enables S3 Cache Storage as the sole Medusa
2010
+ File Module provider through the preset; there is no per-deployment feature
2011
+ flag or local-provider fallback. Dokploy must provide
2012
+ `S3_CACHE_STORAGE_BUCKET`, `S3_CACHE_STORAGE_REGION`, and
2013
+ `MEDUSA_BACKEND_URL`. The backend URL is the public Medusa origin, for
2014
+ example `https://api.example.com`; the preset derives the `/files` gateway
2015
+ path from it. It must never point to S3 or MinIO.
2016
+
2017
+ Set `S3_CACHE_STORAGE_ACCESS_KEY_ID` and
2018
+ `S3_CACHE_STORAGE_SECRET_ACCESS_KEY` together, or omit both to use the AWS
2019
+ credential chain. `S3_CACHE_STORAGE_SESSION_TOKEN` is valid only with that
2020
+ complete static key pair.
2021
+
2022
+ Do not change a production shop from S3 to local storage after it has written
2023
+ S3 keys unless the matching database and storage rollback is being performed.
2024
+ The local provider cannot read S3-backed IDs. Treat a storage-provider change
2025
+ as a controlled deployment cutover, not as a runtime storage router.
2026
+
2027
+ #### Active S3 options and defaults
2028
+
2029
+ In this demo host, `bucket`, `region`, and the File Gateway base URL are
2030
+ required.
2031
+ Credentials are either a complete access-key pair or omitted, in which case the
2032
+ AWS SDK credential chain (for example an IAM role) is used.
2033
+
2034
+ | Option | Default | Meaning |
2035
+ | ---------------------------------------- | -------------------- | ---------------------------------------------------------------------------------- |
2036
+ | `s3.endpoint` | none | Internal HTTP(S) endpoint; use it for MinIO or another S3-compatible service. |
2037
+ | `s3.forcePathStyle` | `false` | Set to `true` for normal MinIO deployments. |
2038
+ | `s3.storagePrefix` | `""` | Environment namespace inside the bucket, for example `production/medusa`. |
2039
+ | `gateway.privateDownloadDurationSeconds` | `3600` | Lifetime of signed Medusa download URLs; `1`–`604800`. |
2040
+ | `cache.dir` | `./.medusa-s3-cache` | Writable local directory belonging to one replica. |
2041
+ | `cache.ttlMs` | `3600000` | Cache freshness window; `0`–`604800000`. |
2042
+ | `cache.expirationStrategy` | `absolute` | `absolute` keeps the original write timestamp; `sliding` refreshes mtime on a hit. |
2043
+ | `cache.cleanupIntervalMs` | `900000` | Startup cleanup plus this unreferenced timer; `1`–`86400000`. |
2044
+
2045
+ URLs may use only HTTP(S), with no credentials, query string, or fragment.
2046
+ Prefixes and provider keys reject absolute paths, traversal, control
2047
+ characters, and backslashes.
2048
+
2049
+ ### Object and access model
2050
+
2051
+ New uploads have opaque keys. The original filename is stored only as encoded
2052
+ object metadata:
2053
+
2054
+ ```text
2055
+ logical key: public/<ULID>.<ext> or private/<ULID>.<ext>
2056
+ S3 key: <storagePrefix>/<logical key>
2057
+ public URL: <MEDUSA_BACKEND_URL>/files/<ULID>.<ext>
2058
+ private URL: <MEDUSA_BACKEND_URL>/files/private/<signed-token>
2059
+ ```
2060
+
2061
+ No provider method returns an S3 URL. Public files receive stable Medusa gateway
2062
+ URLs and `Cache-Control: public, max-age=31536000, immutable`; their keys never
2063
+ change, so browser or an optional CDN in front of Medusa may cache them safely.
2064
+ Generic private files receive short-lived HMAC-signed Medusa URLs with
2065
+ `private, no-store`. Domain files such as invoices continue to use their
2066
+ authenticated Documents routes. Gateway responses use a restrictive sandbox
2067
+ Content Security Policy so active uploaded formats cannot execute under the
2068
+ Medusa API origin.
2069
+
2070
+ The provider sends no S3 ACL parameter. Keep the complete bucket and every
2071
+ prefix private. Only the Medusa runtime receives S3 credentials. Public and
2072
+ private presigned-upload requests receive a short-lived Medusa PUT capability
2073
+ under `/files/upload`; upload and download signatures are not interchangeable.
2074
+ The route accepts at most 100 MiB and allows configured `ADMIN_CORS` origins
2075
+ without exposing S3.
2076
+
2077
+ Backend reads (`getDownloadStream` and `getAsBuffer`) share an atomic cache:
2078
+ cache filenames are SHA-256 hashes, concurrent misses for one object download
2079
+ once per replica, and a failed refresh never serves an expired object. A
2080
+ successful remote write/delete evicts the affected entry only after S3 has
2081
+ accepted it. Do not share the cache directory between replicas; use a bounded
2082
+ `emptyDir` or per-pod volume and monitor free disk space.
2083
+
2084
+ ### Backup and restore
2085
+
2086
+ A complete restore requires both a PostgreSQL backup and the versioned object
2087
+ store. The cache is not a backup and is intentionally excluded.
2088
+
2089
+ Before production use, provision a dedicated bucket and prefix per
2090
+ environment, Block Public Access, SSE-S3 or KMS encryption, least-privilege
2091
+ application IAM, versioning, and lifecycle cleanup for old versions/abandoned
2092
+ multipart uploads. For stronger recovery objectives, add cross-account or
2093
+ cross-region replication and rehearse restores.
2094
+
2095
+ To restore on another server, restore the database and bucket/prefix, keep the
2096
+ same File Gateway base URL and signing secret, configure the provider, and let
2097
+ the replica caches start empty.
2098
+
2099
+ The Documents module ships a read-only restore drill. It streams
2100
+ every issued PDF through the active File provider and reports the verified file
2101
+ count and total bytes without modifying data:
2102
+
2103
+ ```sh
2104
+ medusa exec ./node_modules/@hartl-services/medusa-base/.medusa/server/src/documents/scripts/verify-documents-file-storage.js
2105
+ ```
2106
+
2107
+ ### HTTP contract
2108
+
2109
+ The gateway routes expose `GET /files/:asset` for public immutable files and
2110
+ `GET /files/private/:token` for signed private capability URLs. Both call
2111
+ `Modules.FILE.getDownloadStream`, so a fresh local cache entry is always used
2112
+ before S3. The private route validates its query through the Contracts schema.
2113
+ Paths, query types, and the runtime query schema are exported from the separate
2114
+ `/paths/files`, `/types/files`, and `/schemas/files` Contracts subpaths. Clients
2115
+ normally consume the complete URL returned by Medusa and never construct a
2116
+ private URL themselves.
2117
+
2118
+ ## Shop-Config-Preset
2119
+
2120
+ `@hartl-services/medusa-base/shop-config` turns a food shop's configuration
2121
+ from about 290 lines into about 30: the plugin list and order, the Redis event
2122
+ bus, SMTP notifications, S3 file storage, the tax and Stripe payment providers,
2123
+ the Admin path, the Sentry logger bridge and the environment validation are
2124
+ assembled there; the shop supplies only its own facts. It reuses the `/config`
2125
+ helpers above for environment parsing. It is a separate subpath because it
2126
+ loads the Sentry logger wrapper and with it `@sentry/node`; `/config` stays
2127
+ free of it.
2128
+
2129
+ The preset registers the base (with its E-Mail, Documents and `/files` options)
2130
+ plus five feature plugins, in this order: `medusa-base`,
2131
+ `medusa-tax-compliance`, `medusa-free-shipping`, `medusa-batch-management`,
2132
+ `medusa-affiliate`, `medusa-payload-sync`.
2133
+
2134
+ ### Installation in a shop
2135
+
2136
+ ```bash
2137
+ pnpm add @hartl-services/medusa-base \
2138
+ @hartl-services/medusa-tax-compliance \
2139
+ @hartl-services/medusa-free-shipping \
2140
+ @hartl-services/medusa-batch-management \
2141
+ @hartl-services/medusa-affiliate \
2142
+ @hartl-services/medusa-payload-sync \
2143
+ @medusajs/event-bus-redis @medusajs/payment-stripe \
2144
+ @sentry/node
2145
+ ```
2146
+
2147
+ ### Usage
2148
+
2149
+ ```ts
2150
+ // medusa-config.ts
2151
+ import { defineFoodShopConfig } from "@hartl-services/medusa-base/shop-config"
2152
+
2153
+ module.exports = defineFoodShopConfig({
2154
+ merchant: {
2155
+ countryCode: "at",
2156
+ legalName: "Example Shop GmbH",
2157
+ registeredAddress: {
2158
+ address1: "Musterstrasse 1",
2159
+ city: "Wien",
2160
+ countryCode: "at",
2161
+ postalCode: "1010",
2162
+ },
2163
+ vatNumber: "ATU12345678",
2164
+ },
2165
+ displayName: "EXAMPLE SHOP",
2166
+ orderNotificationEmail: "orders@example.com",
2167
+ timeZone: "Europe/Vienna",
2168
+ storefrontCountryCode: "de",
2169
+ })
2170
+ ```
2171
+
2172
+ A shop that has to adjust the result (a test harness, for example) builds the
2173
+ plain input, changes it and calls `defineConfig` itself:
2174
+
2175
+ ```ts
2176
+ import { defineConfig } from "@medusajs/framework/utils"
2177
+ import { buildFoodShopConfig } from "@hartl-services/medusa-base/shop-config"
2178
+
2179
+ const config = buildFoodShopConfig(options)
2180
+ config.plugins = config.plugins.filter(
2181
+ (plugin) => plugin.resolve !== "@hartl-services/medusa-batch-management",
2182
+ )
2183
+ module.exports = defineConfig(config)
2184
+ ```
2185
+
2186
+ ### API
2187
+
2188
+ - `defineFoodShopConfig(options, environment = process.env)` returns
2189
+ `defineConfig(buildFoodShopConfig(options, environment))`.
2190
+ - `buildFoodShopConfig(options, environment = process.env)` returns the plain
2191
+ config input (`admin`, `logger`, `projectConfig`, `modules`, `plugins`)
2192
+ before `defineConfig`. `modules` and `plugins` are ordinary arrays.
2193
+ - Types: `FoodShopConfigOptions`, `FoodShopConfigInput`, `FoodShopPlugin`,
2194
+ `FoodShopModule`.
2195
+
2196
+ Both functions validate the options first and throw a `TypeError` naming the
2197
+ option (`Food shop option "timeZone" must be ...`), so a misconfigured shop
2198
+ fails at boot. They then load the `.env` file (unless disabled) and read and
2199
+ validate the environment; a missing variable fails with the variable's name.
2200
+
2201
+ ### Options
2202
+
2203
+ | Option | Meaning |
2204
+ | --- | --- |
2205
+ | `merchant` | `DocumentMerchantOption` (`countryCode`, `legalName`, `registeredAddress`, optional `vatNumber`). Fallback for the Shop-Stammdaten (Admin, "Hartl Services Plugins") that documents and tax compliance read per field; `legalName` is also the seeded store name. Country codes are 2-letter lowercase. |
2206
+ | `displayName` | Shop name in the default e-mail templates. |
2207
+ | `orderNotificationEmail` | Receives order notification e-mails; must look like an e-mail address. |
2208
+ | `timeZone` | IANA time zone (validated with `Intl`). Chooses the annual document-number period, the printed dates on documents and the affiliate consignment reminder time zone. |
2209
+ | `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=`. |
2210
+ | `loadEnvFile` | Default `true`: calls `loadEnv(NODE_ENV \|\| "development", process.cwd())`. Pass `false` when the environment is already complete (tests, isolated child processes). |
2211
+
2212
+ ### Environment variables
2213
+
2214
+ Required at runtime (during `medusa build`, with `MEDUSA_BUILD_PHASE=true`, the
2215
+ plugins' build placeholders stand in for them):
2216
+
2217
+ - Medusa core: `DATABASE_URL`, `STORE_CORS`, `ADMIN_CORS`, `AUTH_CORS`,
2218
+ `JWT_SECRET`, `COOKIE_SECRET`, `MEDUSA_BACKEND_URL`, `STOREFRONT_URL` (plain
2219
+ HTTP origins are accepted only in development and test).
2220
+ - Redis: `REDIS_URL` (optional in development, where Medusa's local event bus
2221
+ is used; required elsewhere, `rediss://` with authentication in production
2222
+ unless `REDIS_ALLOW_INSECURE_PRIVATE_NETWORK=true`). Never used during a
2223
+ build.
2224
+ - SMTP: `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`,
2225
+ `SMTP_FROM`, `SMTP_REQUIRE_TLS` (outside development and test either
2226
+ `SMTP_SECURE` or `SMTP_REQUIRE_TLS` must be `true`).
2227
+ - Stripe: `STRIPE_API_KEY` (`sk_...`), `STRIPE_WEBHOOK_SECRET` (`whsec_...`).
2228
+ - S3: `S3_CACHE_STORAGE_BUCKET`, `S3_CACHE_STORAGE_REGION`.
2229
+ - Documents: `DOCUMENT_PDF_EXECUTABLE`.
2230
+
2231
+ Optional: `S3_CACHE_STORAGE_ENDPOINT`, `S3_CACHE_STORAGE_FORCE_PATH_STYLE`,
2232
+ `S3_CACHE_STORAGE_PREFIX`, `S3_CACHE_STORAGE_ACCESS_KEY_ID`,
2233
+ `S3_CACHE_STORAGE_SECRET_ACCESS_KEY`, `S3_CACHE_STORAGE_SESSION_TOKEN` (omit
2234
+ the keys to use the AWS credential chain), `S3_CACHE_STORAGE_CACHE_DIR`
2235
+ (default `./.medusa-s3-cache`), `S3_CACHE_STORAGE_TTL_MS`,
2236
+ `S3_CACHE_STORAGE_CLEANUP_INTERVAL_MS`,
2237
+ `S3_CACHE_STORAGE_EXPIRATION_STRATEGY`,
2238
+ `FILE_GATEWAY_PRIVATE_DOWNLOAD_DURATION_SECONDS`, and `SENTRY_DSN` /
2239
+ `SENTRY_ENVIRONMENT` for error tracking (see the base's
2240
+ `/instrumentation`).
2241
+
2242
+ ### Fixed configuration
2243
+
2244
+ Not configurable on purpose: the plugin order (base first), the Redis event
2245
+ bus job policy (3 attempts, exponential backoff from 1 s), S3 cache defaults
2246
+ (15 min cleanup, 1 h TTL, absolute expiration), Stripe immediate capture with
2247
+ automatic payment methods, the 24 h session lifetime, Admin path `/app`,
2248
+ consignment reminders due on day 10, and the Documents module always being part of the base.