npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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_reminders falls back to safe defaults. A configured portal.referral_base_url must be an absolute http(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 the partner_portal feature toggle, not a config option (the former portal.enabled option is gone; a leftover key is ignored and logged as a warning at boot).
  • No plugin-specific environment variables are required. @sentry/node is a peer, not a dependency: this plugin only calls Sentry.captureException/ captureMessage on its cart hot path and never calls Sentry.init() itself. The shop must initialize Sentry itself (typically from its own Medusa instrumentation.ts register() export, gated by a SENTRY_DSN environment variable); without an init call these calls are harmless no-ops.
  • Optional coupling: if a batch_management service is registered in the container (i.e. @hartl-services/medusa-batch-management is also installed and its service exposes transferBatches/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/register takes a customer_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 (eligible or ineligible), or no VAT ID at all together with confirmed_without_vat_number: true (companies without a VAT ID, e.g. sole traders not in the commercial register; status without_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:migrate after 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:

  1. loads the Order, Cart, Customer ID, and normalized order e-mail;
  2. takes operation locks (cart, Customer/e-mail, order), then sorted Affiliate-state locks, before assignment/referral row locks;
  3. lazily ends expired active assignments;
  4. resolves a valid assignment, otherwise the active unexpired cart referral;
  5. validates that the chosen affiliate is still active;
  6. snapshots affiliate, referrer, assignment model, and program policy;
  7. creates an assignment only for lifetime or time_limited, without renewing an existing one;
  8. consumes at most one exact applied coupon and records its coupon ID, Promotion ID, code, Customer ID, and normalized e-mail redemption snapshot;
  9. creates exactly one immutable affiliate_order_attribution for the order;
  10. marks the cart referral consumed;
  11. reads Product eligibility exactly at this checkout boundary and synchronously materializes the immutable commission_entry base 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 EUR

Full 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_count counts all distinct direct order attributions for that affiliate/currency, including 0-EUR orders and orders without eligible lines;
  • commissioned_order_count counts distinct orders with a positive direct order_commission entry;
  • 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 Secure cookie 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:

  1. identify and confirm the exact local or staging database target;
  2. retain any backup required by the environment owner;
  3. stop writers and drop/recreate or otherwise reset that confirmed database with the deployment platform's approved database procedure;
  4. run the normal backend migration and seed/bootstrap steps against the empty database;
  5. 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:recreate

This 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-links

db: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-links

The 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.promotions may contain null entries: Medusa only soft-deletes a promotion and leaves the cart_promotion link behind. Every read goes through readCartPromotions() (src/utils/cart-promotions.ts); never iterate cart.promotions directly or add ?. guards downstream. The host app removes such links on deletion (promotionsDeleted hook) 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. CONFLICT is 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 an items.variant.id allowlist). 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 ready coupon out of ready: 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.updated reconciliation 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.ts covers 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 / rotateAffiliateCoupon refuse 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 with coupon_code_conflict.
  • src/workflows/hooks/enforce-unique-promotion-codes.ts hooks promotionsCreated / promotionsUpdated of 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.ts covers 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.