@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.
- package/.medusa/server/src/admin/index.js +3739 -0
- package/.medusa/server/src/admin/index.mjs +3738 -0
- package/.medusa/server/src/api/admin/affiliate-applications/[id]/approve/route.js +28 -0
- package/.medusa/server/src/api/admin/affiliate-applications/[id]/reject/route.js +19 -0
- package/.medusa/server/src/api/admin/affiliate-applications/route.js +23 -0
- package/.medusa/server/src/api/admin/affiliate-distribution-reports/query.js +171 -0
- package/.medusa/server/src/api/admin/affiliate-distribution-reports/route.js +13 -0
- package/.medusa/server/src/api/admin/affiliate-program-settings/route.js +27 -0
- package/.medusa/server/src/api/admin/affiliate-reports/[id]/route.js +45 -0
- package/.medusa/server/src/api/admin/affiliate-reports/route.js +27 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/consignment-reminders/route.js +31 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/consignment-transfers/route.js +41 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/coupon-codes/[coupon_id]/reconcile/route.js +34 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/coupon-codes/[coupon_id]/revoke/route.js +36 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/coupon-codes/[coupon_id]/rotate/route.js +40 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/coupon-codes/route.js +56 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/distribution/route.js +45 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/inactive/route.js +20 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/route.js +50 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/slugs/[slug_id]/route.js +19 -0
- package/.medusa/server/src/api/admin/affiliates/[id]/slugs/route.js +26 -0
- package/.medusa/server/src/api/admin/affiliates/route.js +43 -0
- package/.medusa/server/src/api/admin/consignment-report-response.js +36 -0
- package/.medusa/server/src/api/admin/consignment-reports/[id]/approve/route.js +25 -0
- package/.medusa/server/src/api/admin/consignment-reports/[id]/correct/route.js +18 -0
- package/.medusa/server/src/api/admin/consignment-reports/[id]/reject/route.js +15 -0
- package/.medusa/server/src/api/admin/consignment-reports/[id]/route.js +11 -0
- package/.medusa/server/src/api/admin/consignment-reports/route.js +27 -0
- package/.medusa/server/src/api/admin/customers/[id]/affiliate/route.js +50 -0
- package/.medusa/server/src/api/admin/orders/[id]/affiliate-attribution/route.js +30 -0
- package/.medusa/server/src/api/affiliate-coupon-http.js +152 -0
- package/.medusa/server/src/api/affiliate-referral-cookie.js +34 -0
- package/.medusa/server/src/api/affiliate-self-service-http.js +37 -0
- package/.medusa/server/src/api/middlewares.js +786 -0
- package/.medusa/server/src/api/store/affiliate-referrals/apply-coupon/route.js +47 -0
- package/.medusa/server/src/api/store/affiliate-referrals/capture/route.js +76 -0
- package/.medusa/server/src/api/store/affiliate-referrals/carts/[cart_id]/route.js +20 -0
- package/.medusa/server/src/api/store/affiliates/consignment-inventory/route.js +53 -0
- package/.medusa/server/src/api/store/affiliates/consignment-report-response.js +31 -0
- package/.medusa/server/src/api/store/affiliates/consignment-reports/[id]/submit/route.js +40 -0
- package/.medusa/server/src/api/store/affiliates/consignment-reports/route.js +43 -0
- package/.medusa/server/src/api/store/affiliates/coupon-labels/route.js +14 -0
- package/.medusa/server/src/api/store/affiliates/me/commissions/route.js +35 -0
- package/.medusa/server/src/api/store/affiliates/me/coupon-codes/[coupon_id]/revoke/route.js +36 -0
- package/.medusa/server/src/api/store/affiliates/me/coupon-codes/[coupon_id]/rotate/route.js +40 -0
- package/.medusa/server/src/api/store/affiliates/me/coupon-codes/route.js +55 -0
- package/.medusa/server/src/api/store/affiliates/me/dashboard/route.js +15 -0
- package/.medusa/server/src/api/store/affiliates/me/route.js +85 -0
- package/.medusa/server/src/api/store/affiliates/me/slugs/[slug_id]/route.js +29 -0
- package/.medusa/server/src/api/store/affiliates/me/slugs/route.js +34 -0
- package/.medusa/server/src/api/store/affiliates/own-order-discount/route.js +22 -0
- package/.medusa/server/src/api/store/affiliates/own-order-promotion/route.js +30 -0
- package/.medusa/server/src/api/store/affiliates/register/route.js +27 -0
- package/.medusa/server/src/api/store/affiliates/resolve-referral/route.js +34 -0
- package/.medusa/server/src/api/validators.js +52 -0
- package/.medusa/server/src/api/wire-response.js +10 -0
- package/.medusa/server/src/integration/email-events.js +51 -0
- package/.medusa/server/src/integrations/inventory-transfer/batch-management-transfer-adapter.js +36 -0
- package/.medusa/server/src/integrations/inventory-transfer/native-inventory-transfer-adapter.js +30 -0
- package/.medusa/server/src/integrations/inventory-transfer/resolve-inventory-transfer-adapter.js +46 -0
- package/.medusa/server/src/integrations/inventory-transfer/types.js +3 -0
- package/.medusa/server/src/jobs/affiliate-attribution-expiry.js +20 -0
- package/.medusa/server/src/jobs/affiliate-consignment-report-reminders.js +15 -0
- package/.medusa/server/src/jobs/affiliate-promotion-freshness.js +24 -0
- package/.medusa/server/src/lib/query-string.js +15 -0
- package/.medusa/server/src/modules/affiliate/domain/affiliate-attribution-policy.js +81 -0
- package/.medusa/server/src/modules/affiliate/domain/affiliate-coupon-policy.js +61 -0
- package/.medusa/server/src/modules/affiliate/domain/commission-policy.js +33 -0
- package/.medusa/server/src/modules/affiliate/domain/consignment-reminder-rules.js +74 -0
- package/.medusa/server/src/modules/affiliate/domain/email-normalization.js +20 -0
- package/.medusa/server/src/modules/affiliate/domain/own-order-coupon-code.js +37 -0
- package/.medusa/server/src/modules/affiliate/domain/plugin-options.js +40 -0
- package/.medusa/server/src/modules/affiliate/index.js +13 -0
- package/.medusa/server/src/modules/affiliate/migrations/Migration20260827085154.js +423 -0
- package/.medusa/server/src/modules/affiliate/models/affiliate-application.js +38 -0
- package/.medusa/server/src/modules/affiliate/models/affiliate-cart-referral.js +65 -0
- package/.medusa/server/src/modules/affiliate/models/affiliate-code-redemption.js +41 -0
- package/.medusa/server/src/modules/affiliate/models/affiliate-coupon.js +55 -0
- package/.medusa/server/src/modules/affiliate/models/affiliate-distribution-settings.js +22 -0
- package/.medusa/server/src/modules/affiliate/models/affiliate-order-attribution.js +49 -0
- package/.medusa/server/src/modules/affiliate/models/affiliate-program-settings.js +28 -0
- package/.medusa/server/src/modules/affiliate/models/affiliate-referral-session.js +34 -0
- package/.medusa/server/src/modules/affiliate/models/affiliate-slug.js +27 -0
- package/.medusa/server/src/modules/affiliate/models/affiliate.js +49 -0
- package/.medusa/server/src/modules/affiliate/models/commission-entry.js +62 -0
- package/.medusa/server/src/modules/affiliate/models/consignment-report-line.js +21 -0
- package/.medusa/server/src/modules/affiliate/models/consignment-report.js +31 -0
- package/.medusa/server/src/modules/affiliate/models/consignment-transfer-line.js +16 -0
- package/.medusa/server/src/modules/affiliate/models/consignment-transfer.js +20 -0
- package/.medusa/server/src/modules/affiliate/models/customer-affiliate-assignment.js +58 -0
- package/.medusa/server/src/modules/affiliate/service.js +2321 -0
- package/.medusa/server/src/modules/affiliate/services/affiliate-application-manager.js +171 -0
- package/.medusa/server/src/modules/affiliate/services/affiliate-coupon-manager.js +441 -0
- package/.medusa/server/src/modules/affiliate/services/affiliate-discount.service.js +55 -0
- package/.medusa/server/src/modules/affiliate/services/affiliate-manager.js +242 -0
- package/.medusa/server/src/modules/affiliate/services/affiliate-partner-portal.service.js +138 -0
- package/.medusa/server/src/modules/affiliate/services/affiliate-report.service.js +436 -0
- package/.medusa/server/src/modules/affiliate/services/affiliate-slug-manager.js +163 -0
- package/.medusa/server/src/modules/affiliate/services/commission-ledger.service.js +729 -0
- package/.medusa/server/src/modules/affiliate/services/customer-affiliate-assignment-manager.js +350 -0
- package/.medusa/server/src/modules/affiliate/services/order-attribution-manager.js +1051 -0
- package/.medusa/server/src/modules/affiliate/services/referral-lifecycle-manager.js +1420 -0
- package/.medusa/server/src/modules/affiliate/services/utils.js +49 -0
- package/.medusa/server/src/modules/affiliate/types/internal.js +3 -0
- package/.medusa/server/src/paths/affiliate.js +7 -0
- package/.medusa/server/src/paths/index.js +18 -0
- package/.medusa/server/src/schemas/index.js +18 -0
- package/.medusa/server/src/schemas/store.js +24 -0
- package/.medusa/server/src/subscribers/affiliate-cart-updated.js +92 -0
- package/.medusa/server/src/subscribers/affiliate-order-canceled.js +70 -0
- package/.medusa/server/src/subscribers/affiliate-order-placed.js +63 -0
- package/.medusa/server/src/subscribers/affiliate-order-return-received.js +67 -0
- package/.medusa/server/src/subscribers/affiliate-product-eligibility-updated.js +87 -0
- package/.medusa/server/src/types/admin-api.js +3 -0
- package/.medusa/server/src/types/enums.js +53 -0
- package/.medusa/server/src/types/index.js +32 -0
- package/.medusa/server/src/types/public.js +3 -0
- package/.medusa/server/src/types/store-api.js +3 -0
- package/.medusa/server/src/utils/affiliate-cart-operation-lock.js +316 -0
- package/.medusa/server/src/utils/affiliate-promotion-mutation-authorization.js +49 -0
- package/.medusa/server/src/utils/cart-promotions.js +9 -0
- package/.medusa/server/src/utils/money.js +79 -0
- package/.medusa/server/src/utils/promotion-code-availability.js +34 -0
- package/.medusa/server/src/utils/registered-email-checkout.js +101 -0
- package/.medusa/server/src/utils/resolve-services.js +7 -0
- package/.medusa/server/src/utils/settle-cart-promotions.js +44 -0
- package/.medusa/server/src/workflows/affiliate/affiliate-coupon-cart.js +215 -0
- package/.medusa/server/src/workflows/affiliate/affiliate-coupon-command-authorization.js +20 -0
- package/.medusa/server/src/workflows/affiliate/apply-affiliate-coupon.js +174 -0
- package/.medusa/server/src/workflows/affiliate/apply-own-order-promotion.js +89 -0
- package/.medusa/server/src/workflows/affiliate/approve-consignment-report.js +186 -0
- package/.medusa/server/src/workflows/affiliate/correct-approved-consignment-report.js +263 -0
- package/.medusa/server/src/workflows/affiliate/create-affiliate-coupon.js +35 -0
- package/.medusa/server/src/workflows/affiliate/create-commission-correction-for-cancellation.js +21 -0
- package/.medusa/server/src/workflows/affiliate/create-commission-correction-for-refund.js +24 -0
- package/.medusa/server/src/workflows/affiliate/create-commission-for-order.js +17 -0
- package/.medusa/server/src/workflows/affiliate/deactivate-affiliate.js +89 -0
- package/.medusa/server/src/workflows/affiliate/enable-distribution.js +120 -0
- package/.medusa/server/src/workflows/affiliate/enrich-affiliate-report.js +75 -0
- package/.medusa/server/src/workflows/affiliate/ensure-affiliate-coupon-promotion.js +410 -0
- package/.medusa/server/src/workflows/affiliate/ensure-affiliate-own-order-promotion.js +240 -0
- package/.medusa/server/src/workflows/affiliate/ensure-default-affiliate-coupon.js +32 -0
- package/.medusa/server/src/workflows/affiliate/expire-affiliate-attribution-state.js +131 -0
- package/.medusa/server/src/workflows/affiliate/finalize-affiliate-order-attribution.js +82 -0
- package/.medusa/server/src/workflows/affiliate/payment-authorization.js +33 -0
- package/.medusa/server/src/workflows/affiliate/promotion-eligibility.js +179 -0
- package/.medusa/server/src/workflows/affiliate/reconcile-affiliate-cart-referral.js +219 -0
- package/.medusa/server/src/workflows/affiliate/reconcile-affiliate-coupon.js +388 -0
- package/.medusa/server/src/workflows/affiliate/refresh-affiliate-promotions.js +114 -0
- package/.medusa/server/src/workflows/affiliate/resolve-coupon-labels.js +40 -0
- package/.medusa/server/src/workflows/affiliate/revoke-affiliate-coupon.js +29 -0
- package/.medusa/server/src/workflows/affiliate/rotate-affiliate-coupon.js +36 -0
- package/.medusa/server/src/workflows/affiliate/run-consignment-report-reminders.js +58 -0
- package/.medusa/server/src/workflows/affiliate/submit-consignment-report.js +279 -0
- package/.medusa/server/src/workflows/affiliate/transfer-batch-aware-inventory.js +172 -0
- package/.medusa/server/src/workflows/affiliate/transfer-consignment-stock.js +106 -0
- package/.medusa/server/src/workflows/affiliate/update-affiliate-coupon-discounts.js +129 -0
- package/.medusa/server/src/workflows/affiliate/utils.js +118 -0
- package/.medusa/server/src/workflows/hooks/complete-cart-affiliate-attribution.js +57 -0
- package/.medusa/server/src/workflows/hooks/complete-cart-order-created-composition.js +111 -0
- package/.medusa/server/src/workflows/hooks/complete-cart-own-order-discount.js +75 -0
- package/.medusa/server/src/workflows/hooks/enforce-unique-promotion-codes.js +33 -0
- package/.medusa/server/src/workflows/hooks/validate-affiliate-customer-code.js +239 -0
- package/.medusa/server/src/workflows/hooks/validate-complete-cart-affiliate-referral.js +137 -0
- package/LICENSE +21 -0
- package/README.md +799 -0
- package/dist/integration/email-events.d.ts +20 -0
- package/dist/integration/email-events.js +50 -0
- 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.
|