@hartl-services/medusa-affiliate 0.3.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 (169) hide show
  1. package/.medusa/server/src/admin/index.js +3739 -0
  2. package/.medusa/server/src/admin/index.mjs +3738 -0
  3. package/.medusa/server/src/api/admin/affiliate-applications/[id]/approve/route.js +28 -0
  4. package/.medusa/server/src/api/admin/affiliate-applications/[id]/reject/route.js +19 -0
  5. package/.medusa/server/src/api/admin/affiliate-applications/route.js +23 -0
  6. package/.medusa/server/src/api/admin/affiliate-distribution-reports/query.js +171 -0
  7. package/.medusa/server/src/api/admin/affiliate-distribution-reports/route.js +13 -0
  8. package/.medusa/server/src/api/admin/affiliate-program-settings/route.js +27 -0
  9. package/.medusa/server/src/api/admin/affiliate-reports/[id]/route.js +45 -0
  10. package/.medusa/server/src/api/admin/affiliate-reports/route.js +27 -0
  11. package/.medusa/server/src/api/admin/affiliates/[id]/consignment-reminders/route.js +31 -0
  12. package/.medusa/server/src/api/admin/affiliates/[id]/consignment-transfers/route.js +41 -0
  13. package/.medusa/server/src/api/admin/affiliates/[id]/coupon-codes/[coupon_id]/reconcile/route.js +34 -0
  14. package/.medusa/server/src/api/admin/affiliates/[id]/coupon-codes/[coupon_id]/revoke/route.js +36 -0
  15. package/.medusa/server/src/api/admin/affiliates/[id]/coupon-codes/[coupon_id]/rotate/route.js +40 -0
  16. package/.medusa/server/src/api/admin/affiliates/[id]/coupon-codes/route.js +56 -0
  17. package/.medusa/server/src/api/admin/affiliates/[id]/distribution/route.js +45 -0
  18. package/.medusa/server/src/api/admin/affiliates/[id]/inactive/route.js +20 -0
  19. package/.medusa/server/src/api/admin/affiliates/[id]/route.js +50 -0
  20. package/.medusa/server/src/api/admin/affiliates/[id]/slugs/[slug_id]/route.js +19 -0
  21. package/.medusa/server/src/api/admin/affiliates/[id]/slugs/route.js +26 -0
  22. package/.medusa/server/src/api/admin/affiliates/route.js +43 -0
  23. package/.medusa/server/src/api/admin/consignment-report-response.js +36 -0
  24. package/.medusa/server/src/api/admin/consignment-reports/[id]/approve/route.js +25 -0
  25. package/.medusa/server/src/api/admin/consignment-reports/[id]/correct/route.js +18 -0
  26. package/.medusa/server/src/api/admin/consignment-reports/[id]/reject/route.js +15 -0
  27. package/.medusa/server/src/api/admin/consignment-reports/[id]/route.js +11 -0
  28. package/.medusa/server/src/api/admin/consignment-reports/route.js +27 -0
  29. package/.medusa/server/src/api/admin/customers/[id]/affiliate/route.js +50 -0
  30. package/.medusa/server/src/api/admin/orders/[id]/affiliate-attribution/route.js +30 -0
  31. package/.medusa/server/src/api/affiliate-coupon-http.js +152 -0
  32. package/.medusa/server/src/api/affiliate-referral-cookie.js +34 -0
  33. package/.medusa/server/src/api/affiliate-self-service-http.js +37 -0
  34. package/.medusa/server/src/api/middlewares.js +786 -0
  35. package/.medusa/server/src/api/store/affiliate-referrals/apply-coupon/route.js +47 -0
  36. package/.medusa/server/src/api/store/affiliate-referrals/capture/route.js +76 -0
  37. package/.medusa/server/src/api/store/affiliate-referrals/carts/[cart_id]/route.js +20 -0
  38. package/.medusa/server/src/api/store/affiliates/consignment-inventory/route.js +53 -0
  39. package/.medusa/server/src/api/store/affiliates/consignment-report-response.js +31 -0
  40. package/.medusa/server/src/api/store/affiliates/consignment-reports/[id]/submit/route.js +40 -0
  41. package/.medusa/server/src/api/store/affiliates/consignment-reports/route.js +43 -0
  42. package/.medusa/server/src/api/store/affiliates/coupon-labels/route.js +14 -0
  43. package/.medusa/server/src/api/store/affiliates/me/commissions/route.js +35 -0
  44. package/.medusa/server/src/api/store/affiliates/me/coupon-codes/[coupon_id]/revoke/route.js +36 -0
  45. package/.medusa/server/src/api/store/affiliates/me/coupon-codes/[coupon_id]/rotate/route.js +40 -0
  46. package/.medusa/server/src/api/store/affiliates/me/coupon-codes/route.js +55 -0
  47. package/.medusa/server/src/api/store/affiliates/me/dashboard/route.js +15 -0
  48. package/.medusa/server/src/api/store/affiliates/me/route.js +85 -0
  49. package/.medusa/server/src/api/store/affiliates/me/slugs/[slug_id]/route.js +29 -0
  50. package/.medusa/server/src/api/store/affiliates/me/slugs/route.js +34 -0
  51. package/.medusa/server/src/api/store/affiliates/own-order-discount/route.js +22 -0
  52. package/.medusa/server/src/api/store/affiliates/own-order-promotion/route.js +30 -0
  53. package/.medusa/server/src/api/store/affiliates/register/route.js +27 -0
  54. package/.medusa/server/src/api/store/affiliates/resolve-referral/route.js +34 -0
  55. package/.medusa/server/src/api/validators.js +52 -0
  56. package/.medusa/server/src/api/wire-response.js +10 -0
  57. package/.medusa/server/src/integration/email-events.js +51 -0
  58. package/.medusa/server/src/integrations/inventory-transfer/batch-management-transfer-adapter.js +36 -0
  59. package/.medusa/server/src/integrations/inventory-transfer/native-inventory-transfer-adapter.js +30 -0
  60. package/.medusa/server/src/integrations/inventory-transfer/resolve-inventory-transfer-adapter.js +46 -0
  61. package/.medusa/server/src/integrations/inventory-transfer/types.js +3 -0
  62. package/.medusa/server/src/jobs/affiliate-attribution-expiry.js +20 -0
  63. package/.medusa/server/src/jobs/affiliate-consignment-report-reminders.js +15 -0
  64. package/.medusa/server/src/jobs/affiliate-promotion-freshness.js +24 -0
  65. package/.medusa/server/src/lib/query-string.js +15 -0
  66. package/.medusa/server/src/modules/affiliate/domain/affiliate-attribution-policy.js +81 -0
  67. package/.medusa/server/src/modules/affiliate/domain/affiliate-coupon-policy.js +61 -0
  68. package/.medusa/server/src/modules/affiliate/domain/commission-policy.js +33 -0
  69. package/.medusa/server/src/modules/affiliate/domain/consignment-reminder-rules.js +74 -0
  70. package/.medusa/server/src/modules/affiliate/domain/email-normalization.js +20 -0
  71. package/.medusa/server/src/modules/affiliate/domain/own-order-coupon-code.js +37 -0
  72. package/.medusa/server/src/modules/affiliate/domain/plugin-options.js +40 -0
  73. package/.medusa/server/src/modules/affiliate/index.js +13 -0
  74. package/.medusa/server/src/modules/affiliate/migrations/Migration20260827085154.js +423 -0
  75. package/.medusa/server/src/modules/affiliate/models/affiliate-application.js +38 -0
  76. package/.medusa/server/src/modules/affiliate/models/affiliate-cart-referral.js +65 -0
  77. package/.medusa/server/src/modules/affiliate/models/affiliate-code-redemption.js +41 -0
  78. package/.medusa/server/src/modules/affiliate/models/affiliate-coupon.js +55 -0
  79. package/.medusa/server/src/modules/affiliate/models/affiliate-distribution-settings.js +22 -0
  80. package/.medusa/server/src/modules/affiliate/models/affiliate-order-attribution.js +49 -0
  81. package/.medusa/server/src/modules/affiliate/models/affiliate-program-settings.js +28 -0
  82. package/.medusa/server/src/modules/affiliate/models/affiliate-referral-session.js +34 -0
  83. package/.medusa/server/src/modules/affiliate/models/affiliate-slug.js +27 -0
  84. package/.medusa/server/src/modules/affiliate/models/affiliate.js +49 -0
  85. package/.medusa/server/src/modules/affiliate/models/commission-entry.js +62 -0
  86. package/.medusa/server/src/modules/affiliate/models/consignment-report-line.js +21 -0
  87. package/.medusa/server/src/modules/affiliate/models/consignment-report.js +31 -0
  88. package/.medusa/server/src/modules/affiliate/models/consignment-transfer-line.js +16 -0
  89. package/.medusa/server/src/modules/affiliate/models/consignment-transfer.js +20 -0
  90. package/.medusa/server/src/modules/affiliate/models/customer-affiliate-assignment.js +58 -0
  91. package/.medusa/server/src/modules/affiliate/service.js +2321 -0
  92. package/.medusa/server/src/modules/affiliate/services/affiliate-application-manager.js +171 -0
  93. package/.medusa/server/src/modules/affiliate/services/affiliate-coupon-manager.js +441 -0
  94. package/.medusa/server/src/modules/affiliate/services/affiliate-discount.service.js +55 -0
  95. package/.medusa/server/src/modules/affiliate/services/affiliate-manager.js +242 -0
  96. package/.medusa/server/src/modules/affiliate/services/affiliate-partner-portal.service.js +138 -0
  97. package/.medusa/server/src/modules/affiliate/services/affiliate-report.service.js +436 -0
  98. package/.medusa/server/src/modules/affiliate/services/affiliate-slug-manager.js +163 -0
  99. package/.medusa/server/src/modules/affiliate/services/commission-ledger.service.js +729 -0
  100. package/.medusa/server/src/modules/affiliate/services/customer-affiliate-assignment-manager.js +350 -0
  101. package/.medusa/server/src/modules/affiliate/services/order-attribution-manager.js +1051 -0
  102. package/.medusa/server/src/modules/affiliate/services/referral-lifecycle-manager.js +1420 -0
  103. package/.medusa/server/src/modules/affiliate/services/utils.js +49 -0
  104. package/.medusa/server/src/modules/affiliate/types/internal.js +3 -0
  105. package/.medusa/server/src/paths/affiliate.js +7 -0
  106. package/.medusa/server/src/paths/index.js +18 -0
  107. package/.medusa/server/src/schemas/index.js +18 -0
  108. package/.medusa/server/src/schemas/store.js +24 -0
  109. package/.medusa/server/src/subscribers/affiliate-cart-updated.js +92 -0
  110. package/.medusa/server/src/subscribers/affiliate-order-canceled.js +70 -0
  111. package/.medusa/server/src/subscribers/affiliate-order-placed.js +63 -0
  112. package/.medusa/server/src/subscribers/affiliate-order-return-received.js +67 -0
  113. package/.medusa/server/src/subscribers/affiliate-product-eligibility-updated.js +87 -0
  114. package/.medusa/server/src/types/admin-api.js +3 -0
  115. package/.medusa/server/src/types/enums.js +53 -0
  116. package/.medusa/server/src/types/index.js +32 -0
  117. package/.medusa/server/src/types/public.js +3 -0
  118. package/.medusa/server/src/types/store-api.js +3 -0
  119. package/.medusa/server/src/utils/affiliate-cart-operation-lock.js +316 -0
  120. package/.medusa/server/src/utils/affiliate-promotion-mutation-authorization.js +49 -0
  121. package/.medusa/server/src/utils/cart-promotions.js +9 -0
  122. package/.medusa/server/src/utils/money.js +79 -0
  123. package/.medusa/server/src/utils/promotion-code-availability.js +34 -0
  124. package/.medusa/server/src/utils/registered-email-checkout.js +101 -0
  125. package/.medusa/server/src/utils/resolve-services.js +7 -0
  126. package/.medusa/server/src/utils/settle-cart-promotions.js +44 -0
  127. package/.medusa/server/src/workflows/affiliate/affiliate-coupon-cart.js +215 -0
  128. package/.medusa/server/src/workflows/affiliate/affiliate-coupon-command-authorization.js +20 -0
  129. package/.medusa/server/src/workflows/affiliate/apply-affiliate-coupon.js +174 -0
  130. package/.medusa/server/src/workflows/affiliate/apply-own-order-promotion.js +89 -0
  131. package/.medusa/server/src/workflows/affiliate/approve-consignment-report.js +186 -0
  132. package/.medusa/server/src/workflows/affiliate/correct-approved-consignment-report.js +263 -0
  133. package/.medusa/server/src/workflows/affiliate/create-affiliate-coupon.js +35 -0
  134. package/.medusa/server/src/workflows/affiliate/create-commission-correction-for-cancellation.js +21 -0
  135. package/.medusa/server/src/workflows/affiliate/create-commission-correction-for-refund.js +24 -0
  136. package/.medusa/server/src/workflows/affiliate/create-commission-for-order.js +17 -0
  137. package/.medusa/server/src/workflows/affiliate/deactivate-affiliate.js +89 -0
  138. package/.medusa/server/src/workflows/affiliate/enable-distribution.js +120 -0
  139. package/.medusa/server/src/workflows/affiliate/enrich-affiliate-report.js +75 -0
  140. package/.medusa/server/src/workflows/affiliate/ensure-affiliate-coupon-promotion.js +410 -0
  141. package/.medusa/server/src/workflows/affiliate/ensure-affiliate-own-order-promotion.js +240 -0
  142. package/.medusa/server/src/workflows/affiliate/ensure-default-affiliate-coupon.js +32 -0
  143. package/.medusa/server/src/workflows/affiliate/expire-affiliate-attribution-state.js +131 -0
  144. package/.medusa/server/src/workflows/affiliate/finalize-affiliate-order-attribution.js +82 -0
  145. package/.medusa/server/src/workflows/affiliate/payment-authorization.js +33 -0
  146. package/.medusa/server/src/workflows/affiliate/promotion-eligibility.js +179 -0
  147. package/.medusa/server/src/workflows/affiliate/reconcile-affiliate-cart-referral.js +219 -0
  148. package/.medusa/server/src/workflows/affiliate/reconcile-affiliate-coupon.js +388 -0
  149. package/.medusa/server/src/workflows/affiliate/refresh-affiliate-promotions.js +114 -0
  150. package/.medusa/server/src/workflows/affiliate/resolve-coupon-labels.js +40 -0
  151. package/.medusa/server/src/workflows/affiliate/revoke-affiliate-coupon.js +29 -0
  152. package/.medusa/server/src/workflows/affiliate/rotate-affiliate-coupon.js +36 -0
  153. package/.medusa/server/src/workflows/affiliate/run-consignment-report-reminders.js +58 -0
  154. package/.medusa/server/src/workflows/affiliate/submit-consignment-report.js +279 -0
  155. package/.medusa/server/src/workflows/affiliate/transfer-batch-aware-inventory.js +172 -0
  156. package/.medusa/server/src/workflows/affiliate/transfer-consignment-stock.js +106 -0
  157. package/.medusa/server/src/workflows/affiliate/update-affiliate-coupon-discounts.js +129 -0
  158. package/.medusa/server/src/workflows/affiliate/utils.js +118 -0
  159. package/.medusa/server/src/workflows/hooks/complete-cart-affiliate-attribution.js +57 -0
  160. package/.medusa/server/src/workflows/hooks/complete-cart-order-created-composition.js +111 -0
  161. package/.medusa/server/src/workflows/hooks/complete-cart-own-order-discount.js +75 -0
  162. package/.medusa/server/src/workflows/hooks/enforce-unique-promotion-codes.js +33 -0
  163. package/.medusa/server/src/workflows/hooks/validate-affiliate-customer-code.js +239 -0
  164. package/.medusa/server/src/workflows/hooks/validate-complete-cart-affiliate-referral.js +137 -0
  165. package/LICENSE +21 -0
  166. package/README.md +799 -0
  167. package/dist/integration/email-events.d.ts +20 -0
  168. package/dist/integration/email-events.js +50 -0
  169. package/package.json +84 -0
package/README.md ADDED
@@ -0,0 +1,799 @@
1
+ # Medusa Affiliate Plugin
2
+
3
+ Medusa v2.19-compatible plugin for affiliate applications,
4
+ independent coupon codes, referrals, immutable order attribution, commission
5
+ accounting, distribution, consignment, and the customer-authenticated partner
6
+ portal API. The host shop may call affiliates partners, therapists, creators,
7
+ or another business-specific name, but code, tables, services, and wire DTOs
8
+ consistently use `affiliate`.
9
+
10
+ This plugin is published as the standalone npm package
11
+ `@hartl-services/medusa-affiliate`, but it is not itself the public HTTP
12
+ contract package: Storefronts consume only
13
+ `@hartl-services/medusa-food-supplements-contracts`; this plugin's DML
14
+ models, services, workflows, migrations, and generated `.medusa` output are
15
+ internal implementation detail and are not exported for direct import.
16
+
17
+ ## Installation in a shop
18
+
19
+ ```bash
20
+ pnpm add @hartl-services/medusa-affiliate @sentry/node @tanstack/react-query
21
+ ```
22
+
23
+ `@medusajs/admin-sdk`, `@medusajs/framework`, `@medusajs/js-sdk`,
24
+ `@medusajs/medusa`, `@medusajs/ui`, `react`, and `react-router-dom` are peer
25
+ dependencies already provided by a standard Medusa 2.19 shop and admin
26
+ install. `@sentry/node` and `@tanstack/react-query` are not part of a bare
27
+ Medusa scaffold and must be added explicitly.
28
+
29
+ ```ts
30
+ // medusa-config.ts
31
+ export default defineConfig({
32
+ // ...
33
+ plugins: [
34
+ {
35
+ resolve: "@hartl-services/medusa-affiliate",
36
+ options: {
37
+ consignment_reminders: {
38
+ due_day: 10, // 1-28, defaults to 10
39
+ timezone: "Europe/Vienna", // valid IANA timezone, defaults to "UTC"
40
+ },
41
+ portal: {
42
+ enabled: true,
43
+ referral_base_url: "https://store.example.com/en", // absolute http(s) URL, required when enabled
44
+ },
45
+ },
46
+ },
47
+ ],
48
+ });
49
+ ```
50
+
51
+ - All options are optional; `consignment_reminders` and `portal` each fall
52
+ back to safe defaults (`portal.enabled` defaults to `false`). When
53
+ `portal.enabled` is `true`, `portal.referral_base_url` must be an absolute
54
+ `http(s)` URL or the module fails to start.
55
+ - No plugin-specific environment variables are required. `@sentry/node` is a
56
+ peer, not a dependency: this plugin only calls `Sentry.captureException`/
57
+ `captureMessage` on its cart hot path and never calls `Sentry.init()`
58
+ itself. The shop must initialize Sentry itself (typically from its own
59
+ Medusa `instrumentation.ts` `register()` export, gated by a `SENTRY_DSN`
60
+ environment variable); without an init call these calls are harmless
61
+ no-ops.
62
+ - Optional coupling: if a `batch_management` service is registered in the
63
+ container (i.e. `@hartl-services/medusa-batch-management` is also
64
+ installed and its service exposes `transferBatches`/`retrieveBatch`),
65
+ consignment transfers use it for lot/expiry-aware FEFO transfers;
66
+ otherwise they fall back to native Medusa inventory transfers.
67
+ - Run `medusa db:migrate` after installing (the module ships its own
68
+ migration).
69
+
70
+ ## 1. Terms and invariants
71
+
72
+ | Term | Meaning |
73
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
74
+ | Referral signal | A validated active affiliate link or coupon competing for a future order. |
75
+ | Referral session | Short-lived server-side state that carries a link visit until a cart is known. |
76
+ | Cart referral | The currently selected, mutable referral signal for one open cart. |
77
+ | Affiliate coupon | An immutable, globally unique code and its one native Medusa Promotion; it is not a referral slug. |
78
+ | Applied coupon | The coupon currently supplying the discount, which may differ from a same-affiliate link referral source. |
79
+ | Customer assignment | A lifetime or fixed-duration binding of a Customer/e-mail identity to one affiliate. |
80
+ | Order attribution | The immutable record of the affiliate decision for one successfully created order. |
81
+ | Coupon redemption | The immutable one-time use of an affiliate welcome discount by a normalized e-mail and, when present, Customer ID. |
82
+ | Commission entry | An append-only positive or negative financial booking. |
83
+
84
+ The backend owns every priority, expiry, coupon, assignment, and commission
85
+ decision. The central invariants are:
86
+
87
+ - a valid existing Customer/e-mail assignment wins over a different cart
88
+ referral at checkout;
89
+ - link and coupon are equal referral signals under one global First-/Last-Touch
90
+ policy;
91
+ - referral slugs and coupon codes are independent identities; changing a slug
92
+ never changes a coupon;
93
+ - each coupon owns exactly one native Promotion, and a cart has at most one
94
+ applied public Affiliate coupon Promotion;
95
+ - a normalized e-mail and a non-null Customer ID can each redeem at most one
96
+ Affiliate coupon across the whole program;
97
+ - successful checkout creates at most one attribution per `order_id`;
98
+ - the order attribution is the historical source for the affiliate decision,
99
+ while the ledger is the historical source for money;
100
+ - assignments never rewrite attributions, and attributions never rewrite
101
+ ledger entries;
102
+ - refunds and cancellations append negative corrections instead of mutating
103
+ their original entries;
104
+ - an affiliate's own order never earns direct self-commission. Its mandatory
105
+ own-order promotion and the optional one-level referrer bonus are separate
106
+ paths.
107
+
108
+ Global program settings select `before_discounts` or `after_discounts` as the
109
+ direct commission base, `order_base` or `after_direct_commission` as the
110
+ referrer base, `allow_stacking` or `exclusive` promotion behavior, the default
111
+ customer discount (initially 5%), First-/Last-Touch, the referral window
112
+ (initially 30 days), and a monotonically increasing policy version.
113
+
114
+ ## 2. Assignment models and non-renewing durations
115
+
116
+ Every affiliate has exactly one `customer_assignment_mode`:
117
+
118
+ | Mode | Duration setting | Result after a successful referred order |
119
+ | -------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
120
+ | `lifetime` | `assignment_duration_days = null` | Creates a non-expiring Customer/e-mail assignment. |
121
+ | `time_limited` | Any positive integer | Creates an assignment ending at the order time plus the snapshotted number of days. |
122
+ | `order_only` | `assignment_duration_days = null` | Attributes only this order and never creates a Customer assignment. |
123
+
124
+ The Admin UI offers 30, 90, and 180 days as convenient presets but preserves
125
+ any other valid positive duration returned by the API. A later order never
126
+ extends an existing fixed deadline. Changing an affiliate's duration affects
127
+ only new assignments, and changing the mode cannot rewrite an existing order
128
+ or assignment snapshot.
129
+
130
+ A manual Admin assignment begins at the Admin action time and uses the
131
+ affiliate's current mode. `order_only` cannot be manually assigned; the Admin
132
+ must first change the affiliate mode. An assignment is valid only while
133
+ `active = true` and `valid_until IS NULL OR valid_until > now()`. Reassignment
134
+ ends the prior row but retains it in history.
135
+
136
+ ## 3. First-/Last-Touch matrix for links and coupons
137
+
138
+ The global `referral_overwrite_policy` applies under a cart lock. In the table,
139
+ A is the selected affiliate and B is a different incoming affiliate.
140
+
141
+ | Existing signal | Incoming signal | `first_touch` | `last_touch` |
142
+ | --------------- | --------------- | ------------------------------------------------ | ---------------------------------------------------------------------------- |
143
+ | Link A | Link B | Keep Link A; record no replacement. | Mark Link A `replaced`; select Link B. |
144
+ | Coupon A | Coupon B | Keep Coupon A; do not apply or consume Coupon B. | Replace Coupon A with Coupon B and apply B's valid native promotion. |
145
+ | Link A | Coupon B | Keep Link A; do not apply or consume Coupon B. | Replace Link A with Coupon B and apply B's valid native promotion. |
146
+ | Coupon A | Link B | Keep Coupon A. | Replace Coupon A with Link B and reconcile the old affiliate promotion away. |
147
+
148
+ When both signals belong to the same affiliate, the affiliate selection is
149
+ kept under either policy. A valid incoming coupon may replace the applied
150
+ coupon while a link remains the attribution source. If the source itself is a
151
+ coupon, source and applied coupon always identify the same resource. A coupon
152
+ waiting for an e-mail is not active and cannot replace an active referral. The
153
+ configured referral window expires independently of assignment duration.
154
+
155
+ At checkout, the second priority layer is:
156
+
157
+ | Valid assignment | Valid cart referral | Authoritative result |
158
+ | ------------------ | ------------------- | ------------------------------------------- |
159
+ | Affiliate A | none or A | Attribute A; do not renew its deadline. |
160
+ | Affiliate A | Affiliate B | Ignore B completely and attribute A. |
161
+ | none | Affiliate A | Attribute A and apply A's assignment model. |
162
+ | expired assignment | Affiliate B | End the old assignment, then attribute B. |
163
+ | none | none | Create no direct affiliate attribution. |
164
+
165
+ A Customer who already owns any non-rejected Affiliate record is never a
166
+ direct-referral candidate. Link capture records an ignored result, cart
167
+ reconciliation invalidates a link captured before sign-in, and finalization is
168
+ the transactional backstop: it creates neither a direct assignment nor an
169
+ `affiliate_order_attribution`. Only the separate own-order/referrer ledger rule
170
+ can apply.
171
+
172
+ ## 4. Capture with and without a cart
173
+
174
+ The storefront recognizes `?ref=<slug>` and calls the capture route through
175
+ its already configured Medusa SDK. It does not implement First-/Last-Touch.
176
+
177
+ With a cart, the backend resolves the active slug and affiliate, verifies the
178
+ cart, and selects the signal under a cart lock. The response status is
179
+ `captured`, `kept_existing`, `replaced`, or
180
+ `ignored_existing_assignment`.
181
+
182
+ Without a cart, capture creates an `affiliate_referral_session` for the global
183
+ referral window and returns an opaque, cryptographically random token in an
184
+ HttpOnly cookie. Only its SHA-256 hash is stored. The cookie is transport only:
185
+ it contains no affiliate data, is not the source of business truth, and is
186
+ never read by storefront JavaScript. It uses `Path=/`, `SameSite=Lax`,
187
+ `HttpOnly`, and `Secure` in production.
188
+
189
+ No Medusa cart is created merely because somebody opened a referral URL. After
190
+ the storefront creates a cart for normal shopping reasons, its next
191
+ credentialed Core Cart request under `/store/carts/:id...` is enough: the
192
+ middleware binds the server-side session to that cart under the same policy
193
+ and clears the transport cookie. Binding also runs immediately before cart
194
+ completion, closing the last-request gap without creating orphan carts.
195
+
196
+ ```ts
197
+ import type {
198
+ CaptureAffiliateReferralRequest,
199
+ CaptureAffiliateReferralResponse,
200
+ } from "@hartl-services/medusa-food-supplements-contracts/types/affiliate-store";
201
+ import { affiliateStorePaths } from "@hartl-services/medusa-food-supplements-contracts/paths/affiliate";
202
+
203
+ await sdk.client.fetch<CaptureAffiliateReferralResponse>(
204
+ affiliateStorePaths.post.captureReferral,
205
+ {
206
+ method: "POST",
207
+ credentials: "include",
208
+ body: {
209
+ slug: referralSlug,
210
+ ...(cartId ? { cart_id: cartId } : {}),
211
+ } satisfies CaptureAffiliateReferralRequest,
212
+ },
213
+ );
214
+ ```
215
+
216
+ ## 5. Independent coupon lifecycle and one-time identity redemption
217
+
218
+ The first transition of an Affiliate to `active` creates exactly one default
219
+ coupon if that Affiliate has never had a coupon. Its eight-character code is
220
+ cryptographically random and uses `ABCDEFGHJKMNPQRSTVWXYZ23456789`; it is never
221
+ derived from a slug, name, e-mail, or ID. Manual codes are canonical uppercase,
222
+ 3-32 characters, start alphanumeric, and otherwise contain only ASCII letters,
223
+ digits, and hyphens. Codes are globally unique and permanently reserved.
224
+
225
+ An Affiliate has at most five logical enabled coupon slots. Pending and failed
226
+ coupons count. During rotation, one enabled successor and its predecessor share
227
+ one logical slot. A code is never renamed: rotation provisions the successor
228
+ first and then atomically makes it ready while irrevocably revoking the
229
+ predecessor. Revocation never deletes or re-enables a code.
230
+
231
+ | Lifecycle | Provisioning | Effect |
232
+ | --------- | ------------ | -------------------------------------------------------------------------------------------------- |
233
+ | `enabled` | `pending` | Local identity exists; Promotion creation or repair is in progress and the code is not redeemable. |
234
+ | `enabled` | `ready` | Promotion is consistent; the code is redeemable only while its Affiliate is active. |
235
+ | `enabled` | `failed` | Fail-closed; an Admin reconcile of the coupon must retry. |
236
+ | `revoked` | `pending` | Identity is permanently blocked; Promotion/cart cleanup is in progress. |
237
+ | `revoked` | `ready` | Identity is blocked and known cleanup completed. |
238
+ | `revoked` | `failed` | Identity remains blocked; cleanup is retryable. |
239
+
240
+ Wire status derives to `active`, `affiliate_inactive`, `provisioning`,
241
+ `provisioning_failed`, or `revoked`. Affiliate deactivation disables enabled
242
+ coupon Promotions without revoking their identities; reactivation reconciles
243
+ the same non-revoked coupons and never creates a replacement default.
244
+
245
+ Applying a coupon requires a cart. The backend resolves the coupon aggregate
246
+ and native Promotion, normalizes the cart e-mail by trimming and lowercasing
247
+ it, and deliberately preserves provider-specific dots and plus tags.
248
+
249
+ If the cart has no usable e-mail, the coupon becomes
250
+ `pending_email_validation`. It is neither the active referral nor applied as a
251
+ discount. A newer pending coupon may replace an older pending coupon for the
252
+ same cart, but it cannot replace an active referral. Cart Customer, e-mail, and
253
+ promotion changes trigger reconciliation; only after successful validation can
254
+ the coupon enter the matrix in chapter 3 and be applied by Medusa's native
255
+ Promotion workflow.
256
+
257
+ Typing a code does not redeem it. A successful order atomically creates the
258
+ `affiliate_code_redemption`. A normalized e-mail and each non-null Customer ID
259
+ may each appear only once across the program; registered carts check and lock
260
+ both identities, while guests can only be identified by e-mail. Concurrent
261
+ checkouts are guarded by identity locks and database uniqueness. A repeated
262
+ use returns the semantic status `coupon_already_redeemed`, HTTP 409, and exactly:
263
+
264
+ > Dieser Gutscheincode wurde bereits verwendet.
265
+
266
+ A valid existing assignment permits only the same affiliate's coupon. A
267
+ different affiliate's coupon is ignored rather than applied or consumed. A
268
+ successful redemption creates a lifetime, fixed-duration, or no assignment
269
+ according to the attributed affiliate's mode.
270
+
271
+ ## 6. Authoritative checkout and compensation
272
+
273
+ Cart mutation subscribers reconcile Customer, e-mail, and affiliate
274
+ promotions before checkout. On `/complete`, the lock middleware reconciles each
275
+ applied coupon under its provisioning locks, so the order uses the current rate
276
+ and variant allowlist; if that changes the total, Medusa requires a new payment
277
+ session as for any discount change. The complete-cart validation hook then
278
+ checks again and blocks inconsistent or unvalidated state instead of creating
279
+ an order with the wrong discount or affiliate.
280
+
281
+ The compensable, idempotent `completeCartWorkflow.orderCreated` hook then:
282
+
283
+ 1. loads the Order, Cart, Customer ID, and normalized order e-mail;
284
+ 2. takes operation locks (cart, Customer/e-mail, order), then sorted
285
+ Affiliate-state locks, before assignment/referral row locks;
286
+ 3. lazily ends expired active assignments;
287
+ 4. resolves a valid assignment, otherwise the active unexpired cart referral;
288
+ 5. validates that the chosen affiliate is still active;
289
+ 6. snapshots affiliate, referrer, assignment model, and program policy;
290
+ 7. creates an assignment only for `lifetime` or `time_limited`, without
291
+ renewing an existing one;
292
+ 8. consumes at most one exact applied coupon and records its coupon ID,
293
+ Promotion ID, code, Customer ID, and normalized e-mail redemption snapshot;
294
+ 9. creates exactly one immutable `affiliate_order_attribution` for the order;
295
+ 10. marks the cart referral `consumed`;
296
+ 11. reads Product eligibility exactly at this checkout boundary and
297
+ synchronously materializes the immutable `commission_entry` base family,
298
+ including standalone and own-order referrer bonuses.
299
+
300
+ If no assignment or referral exists, the hook creates no direct attribution.
301
+ If the surrounding complete-cart workflow fails, compensation removes only
302
+ the state created by that attempt and restores the prior referral/assignment
303
+ state where safe, and removes only the still-unreferenced base entries created
304
+ by that attempt. An aborted checkout therefore does not permanently consume a
305
+ referral, coupon, or commission idempotency key. The later `order.placed`
306
+ subscriber consumes the persisted base family idempotently. It never reads
307
+ current Product metadata or re-resolves today's affiliate or assignment; a
308
+ missing base family fails closed.
309
+
310
+ ## 7. Data model: all 16 tables
311
+
312
+ The DML models and `Migration20260827085154` are the structural source of
313
+ truth. This table describes their purpose and key relationship/uniqueness or
314
+ expiry rule without duplicating every column.
315
+
316
+ | Table | Purpose and relationships | Key uniqueness, lifecycle, or expiry rule |
317
+ | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
318
+ | `affiliate` | Affiliate identity, status, Customer, rates, assignment model, own-order Promotion reference, primary slug, and optional one-level referrer. | One live affiliate per Customer; rates are 0–100; `time_limited` alone requires a positive duration. |
319
+ | `affiliate_coupon` | Immutable code identity, owner, native Promotion binding, provisioning state, audit actors, and optional rotation predecessor. | Code stays globally unique after revocation; Promotion and command idempotency bindings are unique; at most one enabled successor per predecessor. |
320
+ | `affiliate_application` | Customer application and Admin review history; optionally links to the approved affiliate. | At most one live pending application per Customer; approved/rejected history remains. |
321
+ | `affiliate_slug` | Globally resolvable public slugs belonging to an affiliate. | Slug is globally unique; at most one live primary slug per affiliate, and the primary slug must be active. |
322
+ | `affiliate_program_settings` | Singleton global commission, promotion, overwrite, window, and version policy. | Exactly one live `scope = global`; positive referral window and policy version. |
323
+ | `customer_affiliate_assignment` | Current and historical Customer/e-mail binding to an affiliate. | At most one explicit active row per Customer and normalized e-mail; lifetime or fixed-duration only; reads/writes lazily end expired rows. |
324
+ | `affiliate_referral_session` | Pre-cart link state containing only a token hash plus affiliate/slug snapshot. | Token hash is unique; active state expires at `expires_at` and may be consumed, expired, or invalidated/replaced. |
325
+ | `affiliate_cart_referral` | Mutable link/coupon source plus separate applied Coupon/Promotion binding for an open cart. | At most one active referral and one pending coupon per cart; an active coupon source must equal its applied coupon; terminal history remains. |
326
+ | `affiliate_code_redemption` | Immutable successful coupon consumption, linked to coupon, affiliate, Promotion, cart, order, optional Customer and assignment. | Normalized e-mail, non-null Customer ID, and order are each globally unique. |
327
+ | `affiliate_order_attribution` | Immutable order-level affiliate decision and all attribution policy/rate references and snapshots. | `order_id` is unique; records may exist when commission is zero; historical rows are not edited or deleted by Admin routes. |
328
+ | `commission_entry` | Append-only direct commission, one-level bonuses, manual corrections, refund corrections, and cancellation corrections. | Deterministic live `idempotency_key` is unique; corrections link to their base booking and retain currency/policy/amount snapshots. |
329
+ | `affiliate_distribution_settings` | One affiliate's isolated Medusa Stock Location, internal Sales Channel, region, shipping option, and own-order promotion. | One live configuration per affiliate, Stock Location, and internal Sales Channel. |
330
+ | `consignment_transfer` | Auditable outbound/return stock transfer header for one affiliate. | Live idempotency key is unique; status is pending/completed/failed. |
331
+ | `consignment_transfer_line` | Inventory item, source/destination batch, quantity, and lot/expiry snapshots for a transfer. | Positive quantity; belongs to exactly one transfer. |
332
+ | `consignment_report` | Monthly partner stock count and resulting Draft Order/Order/Fulfillment lifecycle. | At most one live draft/submitted/approved report per affiliate/month; submission keys are idempotent. |
333
+ | `consignment_report_line` | Expected/count/sale quantities, exception classification, price/currency snapshot, and resulting order line. | Belongs to one report; non-sale reasons are constrained and approved financial values are retained. |
334
+
335
+ Core `cart_id`, `order_id`, `customer_id`, inventory, location, channel, and
336
+ promotion identifiers remain scalar indexed references following this
337
+ plugin's module convention; none is exported as a consumer model.
338
+
339
+ ## 8. Order attribution, assignment, and commission entry
340
+
341
+ These records answer different questions and must not be substituted for one
342
+ another:
343
+
344
+ - `customer_affiliate_assignment`: "Who wins for a future order at this
345
+ instant?" It can expire, be ended by an Admin, or be replaced for future
346
+ orders.
347
+ - `affiliate_order_attribution`: "Why did this particular order belong to this
348
+ affiliate?" It is created once, includes 0-EUR/no-eligible-line decisions,
349
+ and never follows later configuration changes.
350
+ - `affiliate_code_redemption`: "Which exact coupon supplied the discount?" It
351
+ freezes coupon, Promotion, code, and Customer/e-mail identity and is linked
352
+ from attribution when a coupon was actually consumed.
353
+ - `commission_entry`: "What money was booked?" It is append-only and can have
354
+ multiple positive/negative rows for one attributed order.
355
+
356
+ The presence of an assignment does not prove that any historical order was
357
+ attributed, and the absence of a positive commission does not erase a valid
358
+ order attribution. Reports never infer historical orders from today's
359
+ assignment.
360
+
361
+ ## 9. Immutable snapshots, refunds, and cancellations
362
+
363
+ Order attribution snapshots the source, slug/code, currency, assignment mode
364
+ and duration, direct and one-level referrer rates, policy version, commission
365
+ base strategy, and referrer base strategy at order time. The ledger additionally
366
+ freezes the eligible physical line IDs and quantities, undiscounted and
367
+ discounted net bases, discounts, direct/referrer bases, booked rates, currency,
368
+ and amounts. Tax, shipping, gift cards, non-physical lines, and explicitly
369
+ excluded products do not enter the direct eligible base.
370
+
371
+ Changing an affiliate rate, program policy, product metadata, or assignment
372
+ after the order cannot change either snapshot. Refund and cancellation
373
+ workflows calculate only the remaining delta against the original eligible
374
+ line/base snapshots and append idempotent negative entries. They never
375
+ recalculate the original booking from current products or prices.
376
+
377
+ ```text
378
+ Original order commission +20.00 EUR
379
+ First partial return -8.00 EUR
380
+ Second partial return -4.00 EUR
381
+ Net commission +8.00 EUR
382
+ ```
383
+
384
+ Full cancellation/refund is capped at the remaining historical amount.
385
+ One-level referral bonuses receive corresponding historical negative
386
+ corrections. There is no "recalculate commissions" action.
387
+
388
+ ## 10. Reporting counters and currency separation
389
+
390
+ All monetary totals come only from `commission_entry`; order-attribution counts
391
+ come only from `affiliate_order_attribution`. Every aggregation is partitioned
392
+ by `currency_code`, so EUR and USD are never added.
393
+
394
+ - `attributed_order_count` counts all distinct direct order attributions for
395
+ that affiliate/currency, including 0-EUR orders and orders without eligible
396
+ lines;
397
+ - `commissioned_order_count` counts distinct orders with a positive direct
398
+ `order_commission` entry;
399
+ - direct, referral-bonus, and signed correction totals sum the append-only
400
+ ledger, while net is their sum.
401
+
402
+ Admin overview/detail and partner dashboard use bounded indexed scans over the
403
+ immutable sources. The portal exposes safe paginated ledger rows and
404
+ currency-separated totals for active and inactive affiliates. A net ledger sum
405
+ is an accounting view, not a payout status, payment promise, or guarantee.
406
+
407
+ ## 11. Deactivation, recovery, lazy expiry, and the daily job
408
+
409
+ Affiliate deactivation is immediate and historical-safe. Its workflow ends all
410
+ active assignments with `ended_reason = affiliate_inactive`, invalidates open
411
+ referral sessions and cart referrals, and reconciles matching affiliate
412
+ Promotions away from open carts. Enabled coupons are not revoked: their native
413
+ Promotions become inactive and can be reconciled on reactivation. New orders
414
+ cannot use the inactive Affiliate; existing attributions, redemptions, and
415
+ ledger entries remain unchanged.
416
+
417
+ Every correctness-sensitive read/write and checkout path checks time directly.
418
+ When it finds an expired active fixed assignment, it ends it under the identity
419
+ lock with `ended_reason = expired` before selecting a new affiliate. Session and
420
+ cart-referral reads similarly refuse expired state. Correctness never depends
421
+ on scheduling.
422
+
423
+ The daily `affiliate-attribution-expiry` job provides bounded, idempotent
424
+ maintenance: it ends expired assignments and marks expired sessions/referrals
425
+ in batches. Applied coupon bindings remain durable cleanup work until the job
426
+ has removed the matching native Promotion from each open cart; completed carts
427
+ are not mutated, and failed Core cleanup is retried on the next run. The job
428
+ never deletes attribution or ledger history. Reactivating an affiliate does not
429
+ reactivate assignments previously ended by deactivation.
430
+
431
+ Deactivation, link/coupon mutation, and assignment finalization share the same
432
+ `affiliate-state:<id>` transaction lock. The global acquisition
433
+ order is operation advisory keys first, sorted Affiliate-state keys second,
434
+ then assignment/referral rows. Affiliate status is re-read while that lock is
435
+ held. Deactivation keeps it through the inactive transition and dependent-state
436
+ scan, so a writer linearizing before it is cleaned up and a writer linearizing
437
+ after it observes the inactive state.
438
+
439
+ Coupon provisioning is a cross-module saga, not a claimed database transaction.
440
+ Create, rotate, revoke, Affiliate status/rate changes, program-default discount
441
+ changes, Product eligibility updates, and Admin reconciliation serialize on
442
+ process-independent advisory locks. Local state goes fail-closed before Core or
443
+ cart cleanup. A retry uses the same command idempotency key, adopts only an
444
+ exact code in the plugin Campaign, and otherwise repairs the one bound
445
+ Promotion. An Admin reconcile retries one coupon, and approving, creating or
446
+ updating an Affiliate provisions its default coupon and own-order promotion
447
+ directly. One failure is recorded and isolated rather than making another
448
+ coupon redeemable.
449
+
450
+ The `affiliate-promotion-freshness` job (every 15 minutes, worker instances)
451
+ re-provisions every `enabled`/`ready` coupon promotion and every own-order
452
+ promotion against the stored rates and the current variant allowlist, computed
453
+ once per run. It never touches carts and leaves pending/failed coupons,
454
+ rotations and revoked-coupon cleanup to the Admin reconcile. Against current
455
+ promotions a run only reads: a ready coupon is updated in place and never
456
+ passes through `pending`, and an own-order promotion is rewritten only when it
457
+ differs (under the Affiliate's saga lock, so deactivation always wins).
458
+
459
+ ## 12. Store and Admin contracts
460
+
461
+ Public request/query/response schemas, DTOs, and encoded paths live only in
462
+ `@hartl-services/medusa-food-supplements-contracts`. Storefronts normally import types and
463
+ paths; runtime schema imports from `/schemas/affiliate` are opt-in. Routes use
464
+ Medusa middleware validation and consume `validatedBody`/`validatedQuery`.
465
+
466
+ Referral Store routes:
467
+
468
+ | Method and path | Purpose |
469
+ | ------------------------------------------------- | -------------------------------------------------- |
470
+ | `POST /store/affiliate-referrals/capture` | Capture a validated link with or without a cart. |
471
+ | `POST /store/affiliate-referrals/apply-coupon` | Validate/reconcile an affiliate coupon for a cart. |
472
+ | `GET /store/affiliate-referrals/carts/:cart_id` | Read the selected Store-safe referral status. |
473
+ | `GET /store/affiliates/resolve-referral?slug=...` | Public active-slug preview. |
474
+
475
+ Authenticated partner coupon routes:
476
+
477
+ | Method and path | Purpose |
478
+ | ---------------------------------------------------------- | --------------------------------------------------------------- |
479
+ | `GET /store/affiliates/me/coupon-codes` | List owned codes with pagination and optional lifecycle filter. |
480
+ | `POST /store/affiliates/me/coupon-codes` | Create one generated or caller-supplied immutable code. |
481
+ | `POST /store/affiliates/me/coupon-codes/:coupon_id/rotate` | Provision a successor and revoke the predecessor. |
482
+ | `POST /store/affiliates/me/coupon-codes/:coupon_id/revoke` | Irrevocably revoke an owned code; retries are idempotent. |
483
+
484
+ Other Store routes are `POST /store/affiliates/register`, `GET|POST
485
+ /store/affiliates/me`, `GET|POST /store/affiliates/me/slugs`, `POST
486
+ /store/affiliates/me/slugs/:slug_id`, `GET
487
+ /store/affiliates/me/dashboard`, `GET /store/affiliates/me/commissions`, `GET
488
+ /store/affiliates/own-order-discount`, `POST
489
+ /store/affiliates/own-order-promotion`, `GET
490
+ /store/affiliates/consignment-inventory`, `GET|POST
491
+ /store/affiliates/consignment-reports`, and `POST
492
+ /store/affiliates/consignment-reports/:id/submit`. Authenticated routes derive
493
+ Customer ownership from the actor and never accept an arbitrary Customer or
494
+ affiliate ID.
495
+
496
+ Coupon Admin routes are:
497
+
498
+ | Method and path | Purpose |
499
+ | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
500
+ | `GET /admin/affiliates/:id/coupon-codes` | List codes with lifecycle, provisioning, Promotion, and audit details. |
501
+ | `POST /admin/affiliates/:id/coupon-codes` | Create a generated or caller-supplied code. |
502
+ | `POST /admin/affiliates/:id/coupon-codes/:coupon_id/rotate` | Rotate a code. |
503
+ | `POST /admin/affiliates/:id/coupon-codes/:coupon_id/revoke` | Revoke a code idempotently. |
504
+ | `POST /admin/affiliates/:id/coupon-codes/:coupon_id/reconcile` | Retry Promotion provisioning or cleanup. |
505
+
506
+ Other Admin contracts cover applications (list/approve/reject), Affiliate CRUD
507
+ and inactivation, slug management, Customer assignment GET/POST/DELETE, immutable
508
+ Order attribution reads, report overview/detail, program settings,
509
+ distribution reports/configuration/transfers, the POST-only consignment reminder
510
+ request, and consignment report list/detail/approve/reject/correct. They are the
511
+ only public boundary for rates, policies, other Customers, internal notes, and
512
+ operational decisions.
513
+
514
+ Use the host's existing SDK client, including credentials when cookies or
515
+ authenticated state are involved:
516
+
517
+ ```ts
518
+ import type {
519
+ ApplyAffiliateCouponRequest,
520
+ ApplyAffiliateCouponResponse,
521
+ GetAffiliateCartReferralResponse,
522
+ } from "@hartl-services/medusa-food-supplements-contracts/types/affiliate-store";
523
+ import { affiliateStorePaths } from "@hartl-services/medusa-food-supplements-contracts/paths/affiliate";
524
+
525
+ const coupon = await sdk.client.fetch<ApplyAffiliateCouponResponse>(
526
+ affiliateStorePaths.post.applyAffiliateCoupon,
527
+ {
528
+ method: "POST",
529
+ credentials: "include",
530
+ body: { cart_id: cartId, code } satisfies ApplyAffiliateCouponRequest,
531
+ },
532
+ );
533
+
534
+ const status = await sdk.client.fetch<GetAffiliateCartReferralResponse>(
535
+ affiliateStorePaths.get.cartReferral(cartId),
536
+ { method: "GET", credentials: "include" },
537
+ );
538
+ ```
539
+
540
+ Do not instantiate a second SDK client, import from a plugin `.medusa` path, or
541
+ reimplement these path strings and policies in the storefront.
542
+
543
+ ## 13. Same-site deployment, CORS, and credentials
544
+
545
+ The supported deployment is same-site, for example Storefront `domain.at` and
546
+ Medusa API `shop.domain.at`. The HttpOnly `SameSite=Lax` cookie can travel in
547
+ that first-party site context, but a browser call between those different
548
+ origins still requires:
549
+
550
+ - the exact Storefront origin in Medusa Store CORS configuration;
551
+ - `credentials: "include"` on capture and subsequent Core Cart requests;
552
+ - HTTPS in production so the `Secure` cookie is accepted.
553
+
554
+ If Medusa is reverse-proxied under `domain.at/app/*`, the browser communication
555
+ is same-origin, while credentials should still be kept explicit. Cross-site
556
+ third-party-cookie designs are unsupported. The cookie's only job is carrying
557
+ the opaque session token until a cart is known; server-side session and cart
558
+ rows remain authoritative.
559
+
560
+ ## 14. Fresh baseline and controlled database reset
561
+
562
+ The module intentionally contains exactly one generated baseline migration,
563
+ `Migration20260827085154`, creating the complete final 16-table schema. Its
564
+ generated snapshot is left untouched; migration-only checks, foreign keys,
565
+ permanent identities, and the global settings seed are restored in the
566
+ migration. The clean baseline removes the former
567
+ `consignment_notification_delivery` table; old delivery rows and synthetic
568
+ Notification IDs are not migrated because Email now owns messages and attempts.
569
+ There is no supported upgrade or backfill from earlier experimental Affiliate
570
+ schemas, and there are no incremental legacy migrations to replay.
571
+
572
+ A database that has already run an older experimental Affiliate migration must
573
+ be reset through a controlled operator procedure before this baseline is
574
+ deployed:
575
+
576
+ 1. identify and confirm the exact local or staging database target;
577
+ 2. retain any backup required by the environment owner;
578
+ 3. stop writers and drop/recreate or otherwise reset that confirmed database
579
+ with the deployment platform's approved database procedure;
580
+ 4. run the normal backend migration and seed/bootstrap steps against the empty
581
+ database;
582
+ 5. verify the Affiliate HTTP integration suite on the fresh schema.
583
+
584
+ Nothing in this repository automatically performs that destructive reset.
585
+ Production data is neither assumed nor silently discarded. For a disposable
586
+ local database that has run the prior baseline, stop the backend, verify the
587
+ exact local `DATABASE_URL`, and perform the required one-time rebuild with:
588
+
589
+ ```bash
590
+ pnpm --dir apps/backend db:recreate
591
+ ```
592
+
593
+ This deletes all local data. Never run it for staging, shared or production
594
+ databases; use only the environment owner's approved external reset there. The
595
+ first production installation is supported only on a deliberately empty
596
+ database. Run:
597
+
598
+ ```bash
599
+ pnpm --dir apps/backend db:migrate
600
+ pnpm --dir apps/backend exec medusa db:sync-links
601
+ ```
602
+
603
+ `db:migrate` applies all module baselines and Medusa's pending initial seed once;
604
+ do not invoke that seed separately. If a production database has already run an
605
+ older Affiliate baseline or contains data that must survive, stop and add a
606
+ forward migration instead. Affiliate itself defines no module links;
607
+ `db:sync-links` remains the safe shop-wide first-deployment step for links owned
608
+ by the backend or other plugins.
609
+
610
+ ## 15. Test and operating commands
611
+
612
+ Feature choices and non-secret plugin options belong directly in the
613
+ Affiliate plugin entry in `apps/backend/medusa-config.ts`; deployment secrets
614
+ and URLs belong in the deployment environment.
615
+
616
+ ```bash
617
+ # Public contract
618
+ pnpm --filter @hartl-services/medusa-food-supplements-contracts build
619
+ pnpm --filter @hartl-services/medusa-food-supplements-contracts typecheck
620
+
621
+ # Affiliate plugin
622
+ pnpm --filter @hartl-services/medusa-affiliate test:unit --runInBand
623
+ pnpm --filter @hartl-services/medusa-affiliate typecheck
624
+ pnpm --filter @hartl-services/medusa-affiliate build
625
+
626
+ # Host and real HTTP integration coverage
627
+ pnpm --dir apps/backend build
628
+ pnpm --dir apps/backend test:integration:http
629
+
630
+ # Database operations
631
+ pnpm --dir apps/backend exec medusa db:migrate
632
+ pnpm --dir apps/backend exec medusa db:sync-links
633
+ ```
634
+
635
+ The HTTP suite creates isolated test databases and exercises real validation,
636
+ workflows, and persistence. The default-branch GitLab job only builds and
637
+ publishes a new Contracts version; it does not run tests and is not verification
638
+ evidence.
639
+
640
+ ### Cart-facing code is fail-safe
641
+
642
+ This plugin sits in the hot path of every store cart request: the lock
643
+ middleware wraps every `POST/PUT/DELETE /store/carts/:id*`, the
644
+ `updateCartPromotionsWorkflow` validate hook runs inside every line-item
645
+ add/update/remove (Medusa's cart refresh re-applies the cart's codes), and the
646
+ `cart.updated` subscriber reconciles afterwards. A bug here is a bug in the
647
+ shop's checkout, so the following invariants are non-negotiable:
648
+
649
+ - `cart.promotions` may contain `null` entries: Medusa only soft-deletes a
650
+ promotion and leaves the `cart_promotion` link behind. Every read goes
651
+ through `readCartPromotions()` (`src/utils/cart-promotions.ts`); never
652
+ iterate `cart.promotions` directly or add `?.` guards downstream. The host
653
+ app removes such links on deletion (`promotionsDeleted` hook) and repairs old
654
+ ones once, but the plugin must not depend on that.
655
+ - The validate hook only fails a request that would *change* the applied code
656
+ set. If the cart's affiliate state cannot even be read, a request that merely
657
+ re-applies what is already there (Medusa's own refresh) is let through and
658
+ reported to Sentry (`area: affiliate-cart-hook`); introducing or dropping a
659
+ code stays strict. The complete-cart hook remains the authoritative gate.
660
+ - Binding a pending referral cookie is tolerated (reported, cookie kept for a
661
+ retry) on every plain cart read and write and fatal only on `/complete`.
662
+ - A cart lock timeout is answered with `NOT_ALLOWED` (400) and the
663
+ customer-readable "Der Warenkorb wird gerade aktualisiert" message, never a
664
+ 500. `CONFLICT` is unusable here because Medusa replaces its message.
665
+ - Cart and checkout accept any *authentic* Affiliate promotion
666
+ (`assertAuthenticAffiliateCouponPromotion`: plugin campaign and code, active,
667
+ non-automatic, one-use-per-e-mail budget, percentage on an
668
+ `items.variant.id` allowlist). Whether its rate and allowlist are current is
669
+ not a cart concern: after a rate or product change every promotion lags until
670
+ provisioning, the product-eligibility subscriber or the freshness job reaches
671
+ it, and failing carts in that window would strip customers of their coupon.
672
+ The own-order checkout gate decides eligibility by the promotion's own
673
+ allowlist for the same reason.
674
+ - Provisioning never moves a `ready` coupon out of `ready`: every cart update
675
+ that sees another state removes the coupon from that cart. A transient
676
+ failure before the first write leaves a ready coupon untouched; only a
677
+ detected inconsistency still fails it closed.
678
+ - The `cart.updated` reconciliation removes a coupon only for a definite
679
+ rejection (not allowed, or the promotion no longer exists). Any other error
680
+ is rethrown so the event is retried with the coupon still in place.
681
+ - `apps/backend/integration-tests/http/store-cart-line-items.spec.ts` covers
682
+ the guest and customer line-item path plus the deleted-promotion cases. It
683
+ must stay green before any change to a cart hook, subscriber, or middleware
684
+ is merged.
685
+
686
+ ### Promotion codes are unique ignoring case
687
+
688
+ Medusa's unique index on `promotion.code` is case sensitive, but customers
689
+ never see case: the storefront upper-cases every typed code and this plugin
690
+ canonicalizes coupon codes to upper case. A native promotion `christian` and
691
+ an affiliate coupon `CHRISTIAN` are therefore the same code and must not
692
+ coexist. `src/utils/promotion-code-availability.ts` checks with an `ILIKE`
693
+ lookup, and it is enforced in both directions:
694
+
695
+ - `createAffiliateCoupon` / `rotateAffiliateCoupon` refuse a requested code
696
+ that a native promotion already uses (`DUPLICATE_ERROR`, German message
697
+ naming the owner); coupon provisioning re-checks before it creates the
698
+ promotion and fails closed with `coupon_code_conflict`.
699
+ - `src/workflows/hooks/enforce-unique-promotion-codes.ts` hooks
700
+ `promotionsCreated` / `promotionsUpdated` of Medusa's own workflows, so an
701
+ Admin-created or renamed promotion whose code collides with any existing
702
+ promotion (affiliate or not) is rolled back with the same error.
703
+ - `apps/backend/integration-tests/http/promotion-code-uniqueness.spec.ts`
704
+ covers both directions and the rename case.
705
+
706
+ ## 16. Preserved distribution, consignment, partner portal, and scope
707
+
708
+ Order attribution does not replace the existing distribution/consignment or
709
+ partner-portal features.
710
+
711
+ Distribution activation creates exactly one isolated partner Stock Location
712
+ and internal Sales Channel. It creates no Publishable API Key and no public
713
+ Fulfillment-Set link, so partner stock is not Storefront inventory. Native
714
+ inventory is authoritative. Batch Management is optional; when compatible it
715
+ transfers lot name/expiry and uses FEFO. If installed without the required
716
+ capabilities, the transfer fails before stock mutation with:
717
+
718
+ > Batch Management ist installiert, unterstützt aber keine chargensicheren Transfers. Aktualisiere das Batch-Management-Plugin.
719
+
720
+ Manual outbound/return transfers remain idempotent and auditable. Monthly
721
+ expected/count snapshots become native Draft Orders at current undiscounted
722
+ shop prices with the mandatory own-order promotion. Admin approval uses native
723
+ reservation and fulfillment from the partner location. Approved shortages are
724
+ not treated as returned stock; later corrections use auditable Order/Credit
725
+ operations. Cancellation of consignment settlement orders remains blocked.
726
+
727
+ The customer-authenticated portal API keeps application state, safe
728
+ profile/slugs/coupon codes, authoritative referral URLs, historical
729
+ currency-separated ledger reads, inventory, and owned monthly reports. Active
730
+ Affiliates may mutate their own profile, slugs, coupon codes, and reports;
731
+ inactive Affiliates retain allowed historical reads. Foreign IDs are
732
+ indistinguishable from missing ones. `portal.enabled: true` requires an absolute
733
+ HTTP(S) `portal.referral_base_url`. `GET /store/affiliates/me` reports this
734
+ configuration as `self_service.enabled`; when disabled it also supplies the
735
+ stable code `partner_portal_disabled` and a readable message for the
736
+ Storefront. Disabled self-service operations return the same code and message
737
+ with `NOT_ALLOWED`. The demo shop has `portal.enabled: true` and a configured
738
+ referral base URL, so its partner endpoints are available.
739
+
740
+ Affiliate owns reminder calendar rules and eligibility, not delivery. Its
741
+ `consignment_reminders` options contain only a due day from 1 to 28 and a valid
742
+ IANA timezone. Every eligible scheduled scan and accepted manual request emits
743
+ the strict versioned `affiliate.consignment_report_reminder_due.v1` event with
744
+ the deterministic `affiliate-consignment:<affiliate_id>:<period_start>` key.
745
+ The key contains no template name. Affiliate stores no reminder delivery,
746
+ recipient, Notification ID, template, or status; Email owns those concerns and
747
+ explicit resend operations.
748
+
749
+ ### Exact outgoing reminder event contract
750
+
751
+ The dedicated server-side integration subpath
752
+ `@hartl-services/medusa-affiliate/integration` exports the event-name constant,
753
+ strict Zod schema, deterministic key builder and inferred payload type. This is
754
+ the complete outgoing Email event:
755
+
756
+ ```ts
757
+ type AffiliateConsignmentReportReminderDueEvent = {
758
+ schema_version: 1;
759
+ reminder_key: string;
760
+ affiliate_id: string;
761
+ customer_id: string | null;
762
+ affiliate_display_name: string;
763
+ period_start: string; // valid YYYY-MM-01
764
+ due_date: string; // valid YYYY-MM-DD
765
+ locale: string | null;
766
+ trigger: "scheduled" | "manual";
767
+ };
768
+
769
+ const eventName = "affiliate.consignment_report_reminder_due.v1";
770
+ ```
771
+
772
+ All non-null strings are trimmed and non-empty, `locale` has at most 35
773
+ characters, and the schema requires
774
+ `reminder_key === affiliate-consignment:<affiliate_id>:<period_start>`.
775
+ Affiliate does not resolve or publish an email address. The scheduled scanner
776
+ may republish the same due fact on every eligible run, and an accepted first
777
+ manual request emits the same logical key; Email deduplicates both. A conscious
778
+ additional send belongs to Email's Admin resend operation and receives a new
779
+ request UUID and business key.
780
+
781
+ The central Email plugin alone resolves the current Customer address, renders
782
+ the shop template, owns attachments, Medusa Notification records, SMTP attempts,
783
+ delivery status and explicit resends. See the central
784
+ [Email design](../../docs/superpowers/specs/2026-08-26-central-email-plugin-design.md)
785
+ and
786
+ [implementation plan](../../docs/superpowers/plans/2026-08-26-central-email-plugin.md).
787
+
788
+ There is no exactly-once SMTP promise. Redis delivers events at least once and
789
+ Email's business key prevents a duplicate logical message, but an SMTP server
790
+ can accept a message before local success is persisted. Email records that
791
+ ambiguous attempt as `unknown`, never retries it automatically, and requires an
792
+ explicit Admin resend decision for another message.
793
+
794
+ Explicitly out of scope are Storefront UI implementation, payout execution or
795
+ status, tax/bank/accounting exports, PDF generation, multi-level referral beyond
796
+ the existing single referrer, campaign/UTM/clickstream analytics,
797
+ fingerprinting, third-party-cookie tracking, hard deletion of historical
798
+ affiliate records, dynamic rewriting of historical commission data, a second
799
+ Storefront SDK client, and any automatic destructive database reset.