@hartl-services/medusa-affiliate
v0.11.0
Published
Reusable affiliate management plugin for Medusa.
Readme
Medusa Affiliate Plugin
Medusa v2.19-compatible plugin for affiliate applications,
independent coupon codes, referrals, immutable order attribution, commission
accounting, distribution, consignment, and the customer-authenticated partner
portal API. The host shop may call affiliates partners, therapists, creators,
or another business-specific name, but code, tables, services, and wire DTOs
consistently use affiliate.
This plugin is published as the standalone npm package
@hartl-services/medusa-affiliate, but it is not itself the public HTTP
contract package: Storefronts consume only
@hartl-services/medusa-food-supplements-contracts; this plugin's DML
models, services, workflows, migrations, and generated .medusa output are
internal implementation detail and are not exported for direct import.
Installation in a shop
pnpm add @hartl-services/medusa-affiliate @hartl-services/medusa-base @sentry/node @tanstack/react-query@hartl-services/medusa-base (>= 1.0.0) is a required peer: it stores the
Affiliate feature toggles (see below), runs the Affiliate checkout validation
and order attribution through its complete-cart hooks (section 6), and must be
registered as a plugin as well.
@medusajs/admin-sdk, @medusajs/framework, @medusajs/js-sdk,
@medusajs/medusa, @medusajs/ui, react, and react-router-dom are peer
dependencies already provided by a standard Medusa 2.19 shop and admin
install. @sentry/node and @tanstack/react-query are not part of a bare
Medusa scaffold and must be added explicitly.
// medusa-config.ts
export default defineConfig({
// ...
plugins: [
{ resolve: "@hartl-services/medusa-base", options: {} },
{
resolve: "@hartl-services/medusa-affiliate",
options: {
consignment_reminders: {
due_day: 10, // 1-28, defaults to 10
timezone: "Europe/Vienna", // valid IANA timezone, defaults to "UTC"
},
portal: {
referral_base_url: "https://store.example.com/en", // absolute http(s) URL, needed for partner-portal referral links
},
},
},
],
});- All options are optional;
consignment_remindersfalls back to safe defaults. A configuredportal.referral_base_urlmust be an absolutehttp(s)URL or the module fails to start. Without it the partner-portal slug routes answer with an unexpected-state error, because they cannot build referral links. Whether the partner portal is available at all is thepartner_portalfeature toggle, not a config option (the formerportal.enabledoption is gone; a leftover key is ignored and logged as a warning at boot). - No plugin-specific environment variables are required.
@sentry/nodeis a peer, not a dependency: this plugin only callsSentry.captureException/captureMessageon its cart hot path and never callsSentry.init()itself. The shop must initialize Sentry itself (typically from its own Medusainstrumentation.tsregister()export, gated by aSENTRY_DSNenvironment variable); without an init call these calls are harmless no-ops. - Optional coupling: if a
batch_managementservice is registered in the container (i.e.@hartl-services/medusa-batch-managementis also installed and its service exposestransferBatches/retrieveBatch), consignment transfers use it for lot/expiry-aware FEFO transfers; otherwise they fall back to native Medusa inventory transfers. - Partner registration requires
@hartl-services/medusa-tax-compliance(>= 1.0.0) as a registered plugin with its switch on (resolved by module key, not a package dependency).POST /store/affiliates/registertakes acustomer_address_id: a saved address of the applying customer with a company and either a VAT ID whose current check found it real and matching (eligibleorineligible), or no VAT ID at all together withconfirmed_without_vat_number: true(companies without a VAT ID, e.g. sole traders not in the commercial register; statuswithout_vat_number, nothing is checked, the address must be complete). A VAT ID on the address is always checked; the confirmation never bypasses it. Otherwise the route answers 400 (also while tax compliance is missing or off). The application stores a snapshot of that address and check (company_verification); the Admin application list and review show it ("Kein Nachweis" for older applications, "Keine UID-Nummer" for confirmed ones without VAT ID). - Run
medusa db:migrateafter installing (the module ships its own migration).
Feature toggles
The plugin declares its toggles to @hartl-services/medusa-base under the
plugin key affiliate. An Admin switches them under Settings → "Hartl
Services Plugins"; Storefronts read the effective values from
GET /store/plugin-features/affiliate (see the Contracts README). A disabled
plugin or feature answers its routes with HTTP 404; Medusa's own /admin
authentication still runs first, so a logged-out Admin call gets 401.
A feature is only effective while the plugin switch (default on) and its
parent feature are on; a route listed under several features needs all of
them.
| Key | Label | Default | Gates |
| ---------------------- | --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| (plugin switch) | Affiliate-Programm | on | every Affiliate route: /store/affiliates/*, /store/affiliate-referrals/*, /admin/affiliates/*, /admin/affiliate-applications/*, /admin/affiliate-program-settings, /admin/customers/:id/affiliate, /admin/orders/:id/affiliate-attribution. The maintenance jobs affiliate-attribution-expiry, affiliate-promotion-freshness and the product-eligibility subscriber keep running (see below) |
| own_order_discount | Rabatt auf Eigenbestellungen | on | /store/affiliates/own-order-discount, /store/affiliates/own-order-promotion, /store/affiliates/coupon-labels (it labels only own-order codes). While off, own-order promotions are deactivated and removed from open carts (see below); the Admin hides the own-discount field and column |
| consignment | Kommissionsvertrieb | on | child of own_order_discount (consignment settles through the own-order promotion); /store/affiliates/consignment-inventory, /store/affiliates/consignment-reports/*, /admin/consignment-reports/*, /admin/affiliates/:id/distribution, /admin/affiliates/:id/consignment-transfers, /admin/affiliates/:id/consignment-reminders, /admin/affiliate-distribution-reports; job affiliate-consignment-report-reminders |
| commissions | Provisionen | on | /store/affiliates/me/commissions, /store/affiliates/me/dashboard (both together with partner_portal), /admin/affiliate-reports/*, i.e. the partner dashboard/commission list and the admin reports. Ledger writes (order attribution at checkout, subscriber affiliate-order-placed) and commission corrections continue |
| coupons | Gutscheinsystem | on | /store/affiliate-referrals/apply-coupon, /admin/affiliates/:id/coupon-codes/* |
| coupons_self_service | Gutscheine selbst verwalten | on | child of coupons; /store/affiliates/me/coupon-codes/* (together with partner_portal) |
| partner_portal | Partnerportal | off | GET/POST /store/affiliates/me, /store/affiliates/register, /store/affiliates/me/slugs/*, /store/affiliates/me/coupon-codes/* |
| referral_links | Empfehlungslinks | on | /store/affiliate-referrals/capture, /store/affiliates/resolve-referral, /store/affiliates/me/slugs/* (together with partner_portal), /admin/affiliates/:id/slugs/* |
/store/affiliate-referrals/carts/:cart_id follows only the plugin switch.
The plugin has one Admin settings page, /settings/hartl-services/affiliate
(Settings → Hartl Services Plugins → Affiliate-Programm → Öffnen; the
descriptor's admin_page points the hub there). It composes the base's
PluginPageShell and PluginFeatureSections from
@hartl-services/medusa-base/admin-components (toggles, and base settings and
actions when declared) and adds the Programmeinstellungen form (commission
base, promotion behavior, referral window, policy version) below them. There
is no other menu entry for plugin settings. The Affiliates, applications, and
report pages stay top-level operational routes under /affiliates/*; they
are not settings.
Admin surfaces of a disabled feature are hidden. The customer and order
widgets (customer-affiliate-widget, order-affiliate-attribution-widget)
render nothing while the plugin switch is off. The pages under
/affiliates/* cannot hide their sidebar entry, so they show
PluginFeatureDisabledNotice instead of their content when the plugin switch
(and, for the consignment and report pages, consignment or commissions)
is off; the applications page stays gated on the plugin switch only. The
settings page keeps PluginFeatureSections visible (so the plugin can be
switched back on) and gates only the Programmeinstellungen form.
partner_portal is off after installing or upgrading; enable it in the Admin
before a Storefront uses the portal.
The cart path ignores the toggles. The cart middlewares (referral-cookie
binding, cart request locks, registered-email checkout guards), every
workflow hook (Affiliate-coupon lock on POST /store/carts/:id/promotions,
complete-cart referral and own-order validation, order attribution),
preventConsignmentOrderCancellation, the
affiliate-cart-updated subscriber and the commission corrections on order
cancellation and return never read a toggle. Neither do the jobs
affiliate-attribution-expiry and the product-eligibility subscriber, and
affiliate-promotion-freshness keeps refreshing while the plugin is off:
the checkout keeps honouring existing attribution and Affiliate coupon
promotions, so their rates, variant allowlists and expiries stay
maintained. With the plugin switched off, attribution of existing Affiliates
keeps applying at checkout, while Affiliate coupons cannot be applied
(apply-coupon answers 404 and the standard promotion route stays blocked
by the hook).
Own-order discounts follow own_order_discount (and the plugin switch)
through a stored mirror. Because the cart path never reads a toggle, the
effective own_order_discount value is copied into
affiliate_program_settings.own_order_discount_active: right away by the
affiliate-plugin-features-updated subscriber on
plugin-features.updated.v1, and at the start of every
affiliate-promotion-freshness run (catching up on a missed event, at most
15 minutes late). Provisioning, the freshness refresh, applying the
promotion to a cart and the complete-cart validation all decide through
isOwnOrderDiscountActive(own_order_discount_active, rate); a failed read on
the cart path keeps the discount active. While off, every own-order
promotion is inactive, none is applied or required at checkout, and the sync
removes it from open carts whose payment has not been authorized yet. A cart
whose payment was already authorized keeps the discount it was priced with
and completes with it. Switching the feature back on during such a checkout
can make an authorized cart without the promotion fail at completion (the
promotion is mandatory again); the customer restarts the checkout.
1. Terms and invariants
| Term | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------ | | Referral signal | A validated active affiliate link or coupon competing for a future order. | | Referral session | Short-lived server-side state that carries a link visit until a cart is known. | | Cart referral | The currently selected, mutable referral signal for one open cart. | | Affiliate coupon | An immutable, globally unique code and its one native Medusa Promotion; it is not a referral slug. | | Applied coupon | The coupon currently supplying the discount, which may differ from a same-affiliate link referral source. | | Customer assignment | A lifetime or fixed-duration binding of a Customer/e-mail identity to one affiliate. | | Order attribution | The immutable record of the affiliate decision for one successfully created order. | | Coupon redemption | The immutable one-time use of an affiliate welcome discount by a normalized e-mail and, when present, Customer ID. | | Commission entry | An append-only positive or negative financial booking. |
The backend owns every priority, expiry, coupon, assignment, and commission decision. The central invariants are:
- a valid existing Customer/e-mail assignment wins over a different cart referral at checkout;
- link and coupon are equal referral signals under one global First-/Last-Touch policy;
- referral slugs and coupon codes are independent identities; changing a slug never changes a coupon;
- each coupon owns exactly one native Promotion, and a cart has at most one applied public Affiliate coupon Promotion;
- a normalized e-mail and a non-null Customer ID can each redeem at most one Affiliate coupon across the whole program;
- successful checkout creates at most one attribution per
order_id; - the order attribution is the historical source for the affiliate decision, while the ledger is the historical source for money;
- assignments never rewrite attributions, and attributions never rewrite ledger entries;
- refunds and cancellations append negative corrections instead of mutating their original entries;
- an affiliate's own order never earns direct self-commission. Its mandatory own-order promotion and the optional one-level referrer bonus are separate paths.
Global program settings select before_discounts or after_discounts as the
direct commission base, order_base or after_direct_commission as the
referrer base, allow_stacking or exclusive promotion behavior, the default
customer discount (initially 5%), First-/Last-Touch, the referral window
(initially 30 days), and a monotonically increasing policy version.
2. Assignment models and non-renewing durations
Every affiliate has exactly one customer_assignment_mode:
| Mode | Duration setting | Result after a successful referred order |
| -------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
| lifetime | assignment_duration_days = null | Creates a non-expiring Customer/e-mail assignment. |
| time_limited | Any positive integer | Creates an assignment ending at the order time plus the snapshotted number of days. |
| order_only | assignment_duration_days = null | Attributes only this order and never creates a Customer assignment. |
The Admin UI offers 30, 90, and 180 days as convenient presets but preserves any other valid positive duration returned by the API. A later order never extends an existing fixed deadline. Changing an affiliate's duration affects only new assignments, and changing the mode cannot rewrite an existing order or assignment snapshot.
A manual Admin assignment begins at the Admin action time and uses the
affiliate's current mode. order_only cannot be manually assigned; the Admin
must first change the affiliate mode. An assignment is valid only while
active = true and valid_until IS NULL OR valid_until > now(). Reassignment
ends the prior row but retains it in history.
3. First-/Last-Touch matrix for links and coupons
The global referral_overwrite_policy applies under a cart lock. In the table,
A is the selected affiliate and B is a different incoming affiliate.
| Existing signal | Incoming signal | first_touch | last_touch |
| --------------- | --------------- | ------------------------------------------------ | ---------------------------------------------------------------------------- |
| Link A | Link B | Keep Link A; record no replacement. | Mark Link A replaced; select Link B. |
| 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. |
| 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. |
| Coupon A | Link B | Keep Coupon A. | Replace Coupon A with Link B and reconcile the old affiliate promotion away. |
When both signals belong to the same affiliate, the affiliate selection is kept under either policy. A valid incoming coupon may replace the applied coupon while a link remains the attribution source. If the source itself is a coupon, source and applied coupon always identify the same resource. A coupon waiting for an e-mail is not active and cannot replace an active referral. The configured referral window expires independently of assignment duration.
At checkout, the second priority layer is:
| Valid assignment | Valid cart referral | Authoritative result | | ------------------ | ------------------- | ------------------------------------------- | | Affiliate A | none or A | Attribute A; do not renew its deadline. | | Affiliate A | Affiliate B | Ignore B completely and attribute A. | | none | Affiliate A | Attribute A and apply A's assignment model. | | expired assignment | Affiliate B | End the old assignment, then attribute B. | | none | none | Create no direct affiliate attribution. |
A Customer who already owns any non-rejected Affiliate record is never a
direct-referral candidate. Link capture records an ignored result, cart
reconciliation invalidates a link captured before sign-in, and finalization is
the transactional backstop: it creates neither a direct assignment nor an
affiliate_order_attribution. Only the separate own-order/referrer ledger rule
can apply.
4. Capture with and without a cart
The storefront recognizes ?ref=<slug> and calls the capture route through
its already configured Medusa SDK. It does not implement First-/Last-Touch.
With a cart, the backend resolves the active slug and affiliate, verifies the
cart, and selects the signal under a cart lock. The response status is
captured, kept_existing, replaced, or
ignored_existing_assignment.
Without a cart, capture creates an affiliate_referral_session for the global
referral window and returns an opaque, cryptographically random token in an
HttpOnly cookie. Only its SHA-256 hash is stored. The cookie is transport only:
it contains no affiliate data, is not the source of business truth, and is
never read by storefront JavaScript. It uses Path=/, SameSite=Lax,
HttpOnly, and Secure in production.
No Medusa cart is created merely because somebody opened a referral URL. After
the storefront creates a cart for normal shopping reasons, its next
credentialed Core Cart request under /store/carts/:id... is enough: the
middleware binds the server-side session to that cart under the same policy
and clears the transport cookie. Binding also runs immediately before cart
completion, closing the last-request gap without creating orphan carts.
import type {
CaptureAffiliateReferralRequest,
CaptureAffiliateReferralResponse,
} from "@hartl-services/medusa-food-supplements-contracts/types/affiliate-store";
import { affiliateStorePaths } from "@hartl-services/medusa-food-supplements-contracts/paths/affiliate";
await sdk.client.fetch<CaptureAffiliateReferralResponse>(
affiliateStorePaths.post.captureReferral,
{
method: "POST",
credentials: "include",
body: {
slug: referralSlug,
...(cartId ? { cart_id: cartId } : {}),
} satisfies CaptureAffiliateReferralRequest,
},
);5. Independent coupon lifecycle and one-time identity redemption
The first transition of an Affiliate to active creates exactly one default
coupon if that Affiliate has never had a coupon. Its eight-character code is
cryptographically random and uses ABCDEFGHJKMNPQRSTVWXYZ23456789; it is never
derived from a slug, name, e-mail, or ID. Manual codes are canonical uppercase,
3-32 characters, start alphanumeric, and otherwise contain only ASCII letters,
digits, and hyphens. Codes are globally unique and permanently reserved.
An Affiliate has at most five logical enabled coupon slots. Pending and failed coupons count. During rotation, one enabled successor and its predecessor share one logical slot. A code is never renamed: rotation provisions the successor first and then atomically makes it ready while irrevocably revoking the predecessor. Revocation never deletes or re-enables a code.
| Lifecycle | Provisioning | Effect |
| --------- | ------------ | -------------------------------------------------------------------------------------------------- |
| enabled | pending | Local identity exists; Promotion creation or repair is in progress and the code is not redeemable. |
| enabled | ready | Promotion is consistent; the code is redeemable only while its Affiliate is active. |
| enabled | failed | Fail-closed; an Admin reconcile of the coupon must retry. |
| revoked | pending | Identity is permanently blocked; Promotion/cart cleanup is in progress. |
| revoked | ready | Identity is blocked and known cleanup completed. |
| revoked | failed | Identity remains blocked; cleanup is retryable. |
Wire status derives to active, affiliate_inactive, provisioning,
provisioning_failed, or revoked. Affiliate deactivation disables enabled
coupon Promotions without revoking their identities; reactivation reconciles
the same non-revoked coupons and never creates a replacement default.
Applying a coupon requires a cart. The backend resolves the coupon aggregate and native Promotion, normalizes the cart e-mail by trimming and lowercasing it, and deliberately preserves provider-specific dots and plus tags.
If the cart has no usable e-mail, the coupon becomes
pending_email_validation. It is neither the active referral nor applied as a
discount. A newer pending coupon may replace an older pending coupon for the
same cart, but it cannot replace an active referral. Cart Customer, e-mail, and
promotion changes trigger reconciliation; only after successful validation can
the coupon enter the matrix in chapter 3 and be applied by Medusa's native
Promotion workflow.
Typing a code does not redeem it. A successful order atomically creates the
affiliate_code_redemption. A normalized e-mail and each non-null Customer ID
may each appear only once across the program; registered carts check and lock
both identities, while guests can only be identified by e-mail. Concurrent
checkouts are guarded by identity locks and database uniqueness. A repeated
use returns the semantic status coupon_already_redeemed, HTTP 409, and exactly:
Dieser Gutscheincode wurde bereits verwendet.
A valid existing assignment permits only the same affiliate's coupon. A different affiliate's coupon is ignored rather than applied or consumed. A successful redemption creates a lifetime, fixed-duration, or no assignment according to the attributed affiliate's mode.
6. Authoritative checkout and compensation
Cart mutation subscribers reconcile Customer, e-mail, and affiliate
promotions before checkout. On /complete, the lock middleware reconciles each
applied coupon under its provisioning locks, so the order uses the current rate
and variant allowlist; if that changes the total, Medusa requires a new payment
session as for any discount change. The complete-cart validation then
checks again and blocks inconsistent or unvalidated state instead of creating
an order with the wrong discount or affiliate.
This plugin registers no complete-cart hook itself: Medusa 2.19.0 allows one
handler per hook, and @hartl-services/medusa-base owns both (see its README,
"Complete-cart hook composition"). The Affiliate module service declares two
participants with order 100, which the base runs before tax compliance
(200): the validate workflow affiliate-validate-complete-cart (own-order
discount check, then referral/coupon check) and the orderCreated workflow
finalize-affiliate-order-attribution. Without the base plugin neither runs.
Both report failures to Sentry (affiliate-complete-cart-validate,
affiliate-complete-cart-order-created) and rethrow.
The compensable, idempotent orderCreated participant then:
- loads the Order, Cart, Customer ID, and normalized order e-mail;
- takes operation locks (cart, Customer/e-mail, order), then sorted Affiliate-state locks, before assignment/referral row locks;
- lazily ends expired active assignments;
- resolves a valid assignment, otherwise the active unexpired cart referral;
- validates that the chosen affiliate is still active;
- snapshots affiliate, referrer, assignment model, and program policy;
- creates an assignment only for
lifetimeortime_limited, without renewing an existing one; - consumes at most one exact applied coupon and records its coupon ID, Promotion ID, code, Customer ID, and normalized e-mail redemption snapshot;
- creates exactly one immutable
affiliate_order_attributionfor the order; - marks the cart referral
consumed; - reads Product eligibility exactly at this checkout boundary and
synchronously materializes the immutable
commission_entrybase family, including standalone and own-order referrer bonuses.
If no assignment or referral exists, the participant creates no direct
attribution. If a later participant (the tax-evidence freeze) or the
surrounding complete-cart workflow fails, the base cancels this workflow and
its compensation removes only
the state created by that attempt and restores the prior referral/assignment
state where safe, and removes only the still-unreferenced base entries created
by that attempt. An aborted checkout therefore does not permanently consume a
referral, coupon, or commission idempotency key. The later order.placed
subscriber consumes the persisted base family idempotently. It never reads
current Product metadata or re-resolves today's affiliate or assignment; a
missing base family fails closed.
7. Data model: all 16 tables
The DML models and Migration20260827085154 are the structural source of
truth. This table describes their purpose and key relationship/uniqueness or
expiry rule without duplicating every column.
| Table | Purpose and relationships | Key uniqueness, lifecycle, or expiry rule |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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. |
| 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. |
| 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. |
| 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. |
| affiliate_program_settings | Singleton global commission, promotion, overwrite, window, and version policy. | Exactly one live scope = global; positive referral window and policy version. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| consignment_transfer | Auditable outbound/return stock transfer header for one affiliate. | Live idempotency key is unique; status is pending/completed/failed. |
| consignment_transfer_line | Inventory item, source/destination batch, quantity, and lot/expiry snapshots for a transfer. | Positive quantity; belongs to exactly one transfer. |
| 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. |
| 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. |
Core cart_id, order_id, customer_id, inventory, location, channel, and
promotion identifiers remain scalar indexed references following this
plugin's module convention; none is exported as a consumer model.
8. Order attribution, assignment, and commission entry
These records answer different questions and must not be substituted for one another:
customer_affiliate_assignment: "Who wins for a future order at this instant?" It can expire, be ended by an Admin, or be replaced for future orders.affiliate_order_attribution: "Why did this particular order belong to this affiliate?" It is created once, includes 0-EUR/no-eligible-line decisions, and never follows later configuration changes.affiliate_code_redemption: "Which exact coupon supplied the discount?" It freezes coupon, Promotion, code, and Customer/e-mail identity and is linked from attribution when a coupon was actually consumed.commission_entry: "What money was booked?" It is append-only and can have multiple positive/negative rows for one attributed order.
The presence of an assignment does not prove that any historical order was attributed, and the absence of a positive commission does not erase a valid order attribution. Reports never infer historical orders from today's assignment.
9. Immutable snapshots, refunds, and cancellations
Order attribution snapshots the source, slug/code, currency, assignment mode and duration, direct and one-level referrer rates, policy version, commission base strategy, and referrer base strategy at order time. The ledger additionally freezes the eligible physical line IDs and quantities, undiscounted and discounted net bases, discounts, direct/referrer bases, booked rates, currency, and amounts. Tax, shipping, gift cards, non-physical lines, and explicitly excluded products do not enter the direct eligible base.
Changing an affiliate rate, program policy, product metadata, or assignment after the order cannot change either snapshot. Refund and cancellation workflows calculate only the remaining delta against the original eligible line/base snapshots and append idempotent negative entries. They never recalculate the original booking from current products or prices.
Original order commission +20.00 EUR
First partial return -8.00 EUR
Second partial return -4.00 EUR
Net commission +8.00 EURFull cancellation/refund is capped at the remaining historical amount. One-level referral bonuses receive corresponding historical negative corrections. There is no "recalculate commissions" action.
10. Reporting counters and currency separation
All monetary totals come only from commission_entry; order-attribution counts
come only from affiliate_order_attribution. Every aggregation is partitioned
by currency_code, so EUR and USD are never added.
attributed_order_countcounts all distinct direct order attributions for that affiliate/currency, including 0-EUR orders and orders without eligible lines;commissioned_order_countcounts distinct orders with a positive directorder_commissionentry;- direct, referral-bonus, and signed correction totals sum the append-only ledger, while net is their sum.
Admin overview/detail and partner dashboard use bounded indexed scans over the immutable sources. The portal exposes safe paginated ledger rows and currency-separated totals for active and inactive affiliates. A net ledger sum is an accounting view, not a payout status, payment promise, or guarantee.
11. Deactivation, recovery, lazy expiry, and the daily job
Affiliate deactivation is immediate and historical-safe. Its workflow ends all
active assignments with ended_reason = affiliate_inactive, invalidates open
referral sessions and cart referrals, and reconciles matching affiliate
Promotions away from open carts. Enabled coupons are not revoked: their native
Promotions become inactive and can be reconciled on reactivation. New orders
cannot use the inactive Affiliate; existing attributions, redemptions, and
ledger entries remain unchanged.
Every correctness-sensitive read/write and checkout path checks time directly.
When it finds an expired active fixed assignment, it ends it under the identity
lock with ended_reason = expired before selecting a new affiliate. Session and
cart-referral reads similarly refuse expired state. Correctness never depends
on scheduling.
The daily affiliate-attribution-expiry job provides bounded, idempotent
maintenance: it ends expired assignments and marks expired sessions/referrals
in batches. Applied coupon bindings remain durable cleanup work until the job
has removed the matching native Promotion from each open cart; completed carts
are not mutated, and failed Core cleanup is retried on the next run. The job
never deletes attribution or ledger history. Reactivating an affiliate does not
reactivate assignments previously ended by deactivation.
Deactivation, link/coupon mutation, and assignment finalization share the same
affiliate-state:<id> transaction lock. The global acquisition
order is operation advisory keys first, sorted Affiliate-state keys second,
then assignment/referral rows. Affiliate status is re-read while that lock is
held. Deactivation keeps it through the inactive transition and dependent-state
scan, so a writer linearizing before it is cleaned up and a writer linearizing
after it observes the inactive state.
Coupon provisioning is a cross-module saga, not a claimed database transaction. Create, rotate, revoke, Affiliate status/rate changes, program-default discount changes, Product eligibility updates, and Admin reconciliation serialize on process-independent advisory locks. Local state goes fail-closed before Core or cart cleanup. A retry uses the same command idempotency key, adopts only an exact code in the plugin Campaign, and otherwise repairs the one bound Promotion. An Admin reconcile retries one coupon, and approving, creating or updating an Affiliate provisions its default coupon and own-order promotion directly. One failure is recorded and isolated rather than making another coupon redeemable.
The affiliate-promotion-freshness job (every 15 minutes, worker instances)
re-provisions every enabled/ready coupon promotion and every own-order
promotion against the stored rates and the current variant allowlist, computed
once per run. It never touches carts and leaves pending/failed coupons,
rotations and revoked-coupon cleanup to the Admin reconcile. Against current
promotions a run only reads: a ready coupon is updated in place and never
passes through pending, and an own-order promotion is rewritten only when it
differs (under the Affiliate's saga lock, so deactivation always wins).
12. Store and Admin contracts
Public request/query/response schemas, DTOs, and encoded paths live only in
@hartl-services/medusa-food-supplements-contracts. Storefronts normally import types and
paths; runtime schema imports from /schemas/affiliate are opt-in. Routes use
Medusa middleware validation and consume validatedBody/validatedQuery.
Referral Store routes:
| Method and path | Purpose |
| ------------------------------------------------- | -------------------------------------------------- |
| POST /store/affiliate-referrals/capture | Capture a validated link with or without a cart. |
| POST /store/affiliate-referrals/apply-coupon | Validate/reconcile an affiliate coupon for a cart. |
| GET /store/affiliate-referrals/carts/:cart_id | Read the selected Store-safe referral status. |
| GET /store/affiliates/resolve-referral?slug=... | Public active-slug preview. |
Authenticated partner coupon routes:
| Method and path | Purpose |
| ---------------------------------------------------------- | --------------------------------------------------------------- |
| GET /store/affiliates/me/coupon-codes | List owned codes with pagination and optional lifecycle filter. |
| POST /store/affiliates/me/coupon-codes | Create one generated or caller-supplied immutable code. |
| POST /store/affiliates/me/coupon-codes/:coupon_id/rotate | Provision a successor and revoke the predecessor. |
| POST /store/affiliates/me/coupon-codes/:coupon_id/revoke | Irrevocably revoke an owned code; retries are idempotent. |
Other Store routes are POST /store/affiliates/register, GET|POST
/store/affiliates/me, GET|POST /store/affiliates/me/slugs, POST
/store/affiliates/me/slugs/:slug_id, GET
/store/affiliates/me/dashboard, GET /store/affiliates/me/commissions, GET
/store/affiliates/own-order-discount, POST
/store/affiliates/own-order-promotion, GET
/store/affiliates/consignment-inventory, GET|POST
/store/affiliates/consignment-reports, and POST
/store/affiliates/consignment-reports/:id/submit. Authenticated routes derive
Customer ownership from the actor and never accept an arbitrary Customer or
affiliate ID.
Coupon Admin routes are:
| Method and path | Purpose |
| -------------------------------------------------------------- | ---------------------------------------------------------------------- |
| GET /admin/affiliates/:id/coupon-codes | List codes with lifecycle, provisioning, Promotion, and audit details. |
| POST /admin/affiliates/:id/coupon-codes | Create a generated or caller-supplied code. |
| POST /admin/affiliates/:id/coupon-codes/:coupon_id/rotate | Rotate a code. |
| POST /admin/affiliates/:id/coupon-codes/:coupon_id/revoke | Revoke a code idempotently. |
| POST /admin/affiliates/:id/coupon-codes/:coupon_id/reconcile | Retry Promotion provisioning or cleanup. |
Other Admin contracts cover applications (list/approve/reject), Affiliate CRUD and inactivation, slug management, Customer assignment GET/POST/DELETE, immutable Order attribution reads, report overview/detail, program settings, distribution reports/configuration/transfers, the POST-only consignment reminder request, and consignment report list/detail/approve/reject/correct. They are the only public boundary for rates, policies, other Customers, internal notes, and operational decisions.
Use the host's existing SDK client, including credentials when cookies or authenticated state are involved:
import type {
ApplyAffiliateCouponRequest,
ApplyAffiliateCouponResponse,
GetAffiliateCartReferralResponse,
} from "@hartl-services/medusa-food-supplements-contracts/types/affiliate-store";
import { affiliateStorePaths } from "@hartl-services/medusa-food-supplements-contracts/paths/affiliate";
const coupon = await sdk.client.fetch<ApplyAffiliateCouponResponse>(
affiliateStorePaths.post.applyAffiliateCoupon,
{
method: "POST",
credentials: "include",
body: { cart_id: cartId, code } satisfies ApplyAffiliateCouponRequest,
},
);
const status = await sdk.client.fetch<GetAffiliateCartReferralResponse>(
affiliateStorePaths.get.cartReferral(cartId),
{ method: "GET", credentials: "include" },
);Do not instantiate a second SDK client, import from a plugin .medusa path, or
reimplement these path strings and policies in the storefront.
13. Same-site deployment, CORS, and credentials
The supported deployment is same-site, for example Storefront domain.at and
Medusa API shop.domain.at. The HttpOnly SameSite=Lax cookie can travel in
that first-party site context, but a browser call between those different
origins still requires:
- the exact Storefront origin in Medusa Store CORS configuration;
credentials: "include"on capture and subsequent Core Cart requests;- HTTPS in production so the
Securecookie is accepted.
If Medusa is reverse-proxied under domain.at/app/*, the browser communication
is same-origin, while credentials should still be kept explicit. Cross-site
third-party-cookie designs are unsupported. The cookie's only job is carrying
the opaque session token until a cart is known; server-side session and cart
rows remain authoritative.
14. Fresh baseline and controlled database reset
The module intentionally contains exactly one generated baseline migration,
Migration20260827085154, creating the complete final 16-table schema. Its
generated snapshot is left untouched; migration-only checks, foreign keys,
permanent identities, and the global settings seed are restored in the
migration. The clean baseline removes the former
consignment_notification_delivery table; old delivery rows and synthetic
Notification IDs are not migrated because Email now owns messages and attempts.
There is no supported upgrade or backfill from earlier experimental Affiliate
schemas, and there are no incremental legacy migrations to replay.
A database that has already run an older experimental Affiliate migration must be reset through a controlled operator procedure before this baseline is deployed:
- identify and confirm the exact local or staging database target;
- retain any backup required by the environment owner;
- stop writers and drop/recreate or otherwise reset that confirmed database with the deployment platform's approved database procedure;
- run the normal backend migration and seed/bootstrap steps against the empty database;
- verify the Affiliate HTTP integration suite on the fresh schema.
Nothing in this repository automatically performs that destructive reset.
Production data is neither assumed nor silently discarded. For a disposable
local database that has run the prior baseline, stop the backend, verify the
exact local DATABASE_URL, and perform the required one-time rebuild with:
pnpm --dir apps/backend db:recreateThis deletes all local data. Never run it for staging, shared or production databases; use only the environment owner's approved external reset there. The first production installation is supported only on a deliberately empty database. Run:
pnpm --dir apps/backend db:migrate
pnpm --dir apps/backend exec medusa db:sync-linksdb:migrate applies all module baselines and Medusa's pending initial seed once;
do not invoke that seed separately. If a production database has already run an
older Affiliate baseline or contains data that must survive, stop and add a
forward migration instead. Affiliate itself defines no module links;
db:sync-links remains the safe shop-wide first-deployment step for links owned
by the backend or other plugins.
15. Test and operating commands
Feature choices and non-secret plugin options belong directly in the
Affiliate plugin entry in apps/backend/medusa-config.ts; deployment secrets
and URLs belong in the deployment environment.
# Public contract
pnpm --filter @hartl-services/medusa-food-supplements-contracts build
pnpm --filter @hartl-services/medusa-food-supplements-contracts typecheck
# Affiliate plugin
pnpm --filter @hartl-services/medusa-affiliate test:unit --runInBand
pnpm --filter @hartl-services/medusa-affiliate typecheck
pnpm --filter @hartl-services/medusa-affiliate build
# Host and real HTTP integration coverage
pnpm --dir apps/backend build
pnpm --dir apps/backend test:integration:http
# Database operations
pnpm --dir apps/backend exec medusa db:migrate
pnpm --dir apps/backend exec medusa db:sync-linksThe HTTP suite creates isolated test databases and exercises real validation, workflows, and persistence. The default-branch GitLab job only builds and publishes a new Contracts version; it does not run tests and is not verification evidence.
Cart-facing code is fail-safe
This plugin sits in the hot path of every store cart request: the lock
middleware wraps every POST/PUT/DELETE /store/carts/:id*, the
updateCartPromotionsWorkflow validate hook runs inside every line-item
add/update/remove (Medusa's cart refresh re-applies the cart's codes), and the
cart.updated subscriber reconciles afterwards. A bug here is a bug in the
shop's checkout, so the following invariants are non-negotiable:
cart.promotionsmay containnullentries: Medusa only soft-deletes a promotion and leaves thecart_promotionlink behind. Every read goes throughreadCartPromotions()(src/utils/cart-promotions.ts); never iteratecart.promotionsdirectly or add?.guards downstream. The host app removes such links on deletion (promotionsDeletedhook) and repairs old ones once, but the plugin must not depend on that.- The validate hook only fails a request that would change the applied code
set. If the cart's affiliate state cannot even be read, a request that merely
re-applies what is already there (Medusa's own refresh) is let through and
reported to Sentry (
area: affiliate-cart-hook); introducing or dropping a code stays strict. The complete-cart hook remains the authoritative gate. - Binding a pending referral cookie is tolerated (reported, cookie kept for a
retry) on every plain cart read and write and fatal only on
/complete. - A cart lock timeout is answered with
NOT_ALLOWED(400) and the customer-readable "Der Warenkorb wird gerade aktualisiert" message, never a 500.CONFLICTis unusable here because Medusa replaces its message. - Cart and checkout accept any authentic Affiliate promotion
(
assertAuthenticAffiliateCouponPromotion: plugin campaign and code, active, non-automatic, one-use-per-e-mail budget, percentage on anitems.variant.idallowlist). Whether its rate and allowlist are current is not a cart concern: after a rate or product change every promotion lags until provisioning, the product-eligibility subscriber or the freshness job reaches it, and failing carts in that window would strip customers of their coupon. The own-order checkout gate decides eligibility by the promotion's own allowlist for the same reason. - Provisioning never moves a
readycoupon out ofready: every cart update that sees another state removes the coupon from that cart. A transient failure before the first write leaves a ready coupon untouched; only a detected inconsistency still fails it closed. - The
cart.updatedreconciliation removes a coupon only for a definite rejection (not allowed, or the promotion no longer exists). Any other error is rethrown so the event is retried with the coupon still in place. apps/backend/integration-tests/http/store-cart-line-items.spec.tscovers the guest and customer line-item path plus the deleted-promotion cases. It must stay green before any change to a cart hook, subscriber, or middleware is merged.
Promotion codes are unique ignoring case
Medusa's unique index on promotion.code is case sensitive, but customers
never see case: the storefront upper-cases every typed code and this plugin
canonicalizes coupon codes to upper case. A native promotion christian and
an affiliate coupon CHRISTIAN are therefore the same code and must not
coexist. src/utils/promotion-code-availability.ts checks with an ILIKE
lookup, and it is enforced in both directions:
createAffiliateCoupon/rotateAffiliateCouponrefuse a requested code that a native promotion already uses (DUPLICATE_ERROR, German message naming the owner); coupon provisioning re-checks before it creates the promotion and fails closed withcoupon_code_conflict.src/workflows/hooks/enforce-unique-promotion-codes.tshookspromotionsCreated/promotionsUpdatedof Medusa's own workflows, so an Admin-created or renamed promotion whose code collides with any existing promotion (affiliate or not) is rolled back with the same error.apps/backend/integration-tests/http/promotion-code-uniqueness.spec.tscovers both directions and the rename case.
16. Preserved distribution, consignment, partner portal, and scope
Order attribution does not replace the existing distribution/consignment or partner-portal features.
Distribution activation creates exactly one isolated partner Stock Location and internal Sales Channel. It creates no Publishable API Key and no public Fulfillment-Set link, so partner stock is not Storefront inventory. Native inventory is authoritative. Batch Management is optional; when compatible it transfers lot name/expiry and uses FEFO. If installed without the required capabilities, the transfer fails before stock mutation with:
Batch Management ist installiert, unterstützt aber keine chargensicheren Transfers. Aktualisiere das Batch-Management-Plugin.
Manual outbound/return transfers remain idempotent and auditable. Monthly expected/count snapshots become native Draft Orders at current undiscounted shop prices with the mandatory own-order promotion. Admin approval uses native reservation and fulfillment from the partner location. Approved shortages are not treated as returned stock; later corrections use auditable Order/Credit operations. Cancellation of consignment settlement orders remains blocked.
The customer-authenticated portal API keeps application state, safe
profile/slugs/coupon codes, authoritative referral URLs, historical
currency-separated ledger reads, inventory, and owned monthly reports. Active
Affiliates may mutate their own profile, slugs, coupon codes, and reports;
inactive Affiliates retain allowed historical reads. Foreign IDs are
indistinguishable from missing ones. The portal is available only while the
partner_portal feature toggle is on (default off, see "Feature toggles");
otherwise its routes answer 404. Slug routes additionally need the configured
absolute HTTP(S) portal.referral_base_url.
Affiliate owns reminder calendar rules and eligibility, not delivery. Its
consignment_reminders options contain only a due day from 1 to 28 and a valid
IANA timezone. Every eligible scheduled scan and accepted manual request emits
the strict versioned affiliate.consignment_report_reminder_due.v1 event with
the deterministic affiliate-consignment:<affiliate_id>:<period_start> key.
The key contains no template name. Affiliate stores no reminder delivery,
recipient, Notification ID, template, or status; Email owns those concerns and
explicit resend operations.
Exact outgoing reminder event contract
The server-only Contracts subpath
@hartl-services/medusa-food-supplements-contracts/events/affiliate exports the
event-name constant,
strict Zod schema, deterministic key builder and inferred payload type. This is
the complete outgoing Email event:
type AffiliateConsignmentReportReminderDueEvent = {
schema_version: 1;
reminder_key: string;
affiliate_id: string;
customer_id: string | null;
affiliate_display_name: string;
period_start: string; // valid YYYY-MM-01
due_date: string; // valid YYYY-MM-DD
locale: string | null;
trigger: "scheduled" | "manual";
};
const eventName = "affiliate.consignment_report_reminder_due.v1";All non-null strings are trimmed and non-empty, locale has at most 35
characters, and the schema requires
reminder_key === affiliate-consignment:<affiliate_id>:<period_start>.
Affiliate does not resolve or publish an email address. The scheduled scanner
may republish the same due fact on every eligible run, and an accepted first
manual request emits the same logical key; Email deduplicates both. A conscious
additional send belongs to Email's Admin resend operation and receives a new
request UUID and business key.
The central Email plugin alone resolves the current Customer address, renders the shop template, owns attachments, Medusa Notification records, SMTP attempts, delivery status and explicit resends. See the central Email design and implementation plan.
There is no exactly-once SMTP promise. Redis delivers events at least once and
Email's business key prevents a duplicate logical message, but an SMTP server
can accept a message before local success is persisted. Email records that
ambiguous attempt as unknown, never retries it automatically, and requires an
explicit Admin resend decision for another message.
Explicitly out of scope are Storefront UI implementation, payout execution or status, tax/bank/accounting exports, PDF generation, multi-level referral beyond the existing single referrer, campaign/UTM/clickstream analytics, fingerprinting, third-party-cookie tracking, hard deletion of historical affiliate records, dynamic rewriting of historical commission data, a second Storefront SDK client, and any automatic destructive database reset.
