Affiliates

Commerce engineStable
@wabbit/tome-affiliatesv0.6.2

Tome affiliates layer — referral attribution by link and promotion code, commission snapshots held through the refund window, refund reversal with carry-forward, and payouts through the economy payouts extension.

Install
  1. Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
  3. Then install:

    npm install @wabbit/tome-affiliates

Overview

@wabbit/tome-affiliates

Tome affiliates layer — referral attribution by link and promotion code, commission snapshots held through the refund window, refund reversal with carry-forward, and payouts through the economy payouts extension. Description copied verbatim from package.json.

Layer: domain (per ARCHITECTURE.md).

An approved affiliate gets a code, a share link, and optionally a Stripe promotion code. The layer attributes referred purchases and subscription renewals to that affiliate, computes a commission snapshot per sale, holds it through the refund window, reverses it on refund, and batches approved commissions into payouts via @wabbit/tome-economy's payouts extension. It ships no end-user UI in v1 — each consumer builds its own affiliate dashboard in its own design system.

Install

pnpm add @wabbit/tome-affiliates

Peer ranges, copied from package.json:

| Peer | Range | |---|---| | payload | >=3.67.0 | | @wabbit/tome-core | >=1.16.0 <2.0.0 | | @wabbit/tome-economy | >=0.12.0 <1.0.0 |

@wabbit/tome-economy must be installed with its payouts extension enabled (createEconomyLayer({ payouts: true })) — assertEconomyPayoutsCollections (below) fails loudly from your onInit if it is not. stripe is not a peer at any level: nothing in this package imports it. syncAffiliatePromotionCode accepts a structural stripe argument shape instead, matching crowdfund's settlement-job pattern.

Quickstart

Four pieces of wiring, all required. The layer factory alone gives you collections and a click endpoint that records clicks; commissions are only computed once the economy handlers are registered, and only approved and paid out once the jobs are mounted and scheduled.

import { buildConfig } from 'payload'
import { createEconomyLayer } from '@wabbit/tome-economy'
import {
  createAffiliatesLayer,
  resolveAffiliatesConfig,
  type AffiliatesLayerConfig,
} from '@wabbit/tome-affiliates'
import { assertEconomyPayoutsCollections, setAffiliatesPayloadResolver } from '@wabbit/tome-affiliates/server'

const affiliatesConfig: AffiliatesLayerConfig = {
  program: {
    currency: 'USD',
    defaultRateBps: 2000, // 20%
    commissionableProductTypes: ['course', 'membership'],
    recurringMonths: 6,
    attributionWindowDays: 30,
    holdDays: 30,
    minPayoutCents: 2500,
  },
  currentTermsVersion: '1',
  // Both secrets must be at least 32 characters, or the factory throws AffiliatesConfigError.
  cookieSecret: process.env.AFFILIATE_COOKIE_SECRET!,
  clickHashSecret: process.env.AFFILIATE_CLICK_HASH_SECRET!,
}

const affiliates = createAffiliatesLayer(affiliatesConfig)

// 1. Economy event handlers: order completed, subscription renewed, order refunded.
//    Without this call no referral is ever attributed and no commission is created. Idempotent.
affiliates.registerHandlers()

export default buildConfig({
  // db, secret, editor, admin: as in any Payload config
  collections: [
    ...createEconomyLayer({ payouts: true }), // 2. the payouts extension is required
    ...affiliates.collections,
    // ...your other collections
  ],
  endpoints: [
    ...affiliates.endpoints, // POST /api/affiliates/click
    ...affiliates.jobs.map((job) => job.endpoint), // 3. the four CRON_SECRET-guarded job routes
  ],
  onInit: async (payload) => {
    // 4. Fail at boot, not at the first sale, if economy's payouts collections are missing.
    assertEconomyPayoutsCollections(payload, resolveAffiliatesConfig(affiliatesConfig))
    // The handlers receive no Payload instance of their own. Either pass `getPayload`
    // in the layer config, or hand them this one:
    setAffiliatesPayloadResolver(() => payload)
  },
})

Scheduling the jobs. Each entry of affiliates.jobs is { slug, schedule, handler, endpoint }. The endpoint is a Payload endpoint guarded by the CRON_SECRET environment variable: set CRON_SECRET, then have your scheduler POST to it with Authorization: Bearer <CRON_SECRET>. It answers 500 when CRON_SECRET is unset and 401 on a wrong token. schedule is the suggested cron expression; nothing registers it for you. On Payload's own jobs queue, wrap handler with asPayloadTask from @wabbit/tome-core/jobs instead of mounting the endpoint.

| Job (slug) | Route (under /api) | Suggested schedule | What it does | |---|---|---|---| | approveMaturedAffiliateCommissions | /affiliates/jobs/approve-matured-commissions | 15 3 * * * | Approves pending commissions whose hold has passed; reverses those whose order was fully refunded. | | runAffiliatePayouts | /affiliates/jobs/run-payouts | 0 15 1 * * | Batches approved commissions into payouts through the registered payout rails. | | expireAffiliateReferrals | /affiliates/jobs/expire-referrals | 30 3 * * * | Expires open referrals past the attribution window. | | purgeAffiliateClicks | /affiliates/jobs/purge-clicks | 45 3 * * * | Purges old click rows. |

The referral cookie. captureReferral sets a signed cookie named program.cookieName (default tome_ref) with a lifetime of program.attributionWindowDays days. Rotating cookieSecret invalidates every cookie already issued.

API surface

Root entry (@wabbit/tome-affiliates, . in exports) — config-graph safe, no Node-only imports:

| Export | What it is | |---|---| | createAffiliatesLayer(config) | Canonical layer entry. Returns { collections, endpoints, jobs, registerHandlers } and registers the layer. | | resolveAffiliatesConfig(config) | Applies every default (AFFILIATES_DEFAULT_SLUGS, economy slug defaults, program defaults) — the shape every collection/handler/job factory is built against. | | AFFILIATES_DEFAULT_SLUGS | affiliates / affiliate-referrals / affiliate-clicks / affiliate-commissions. | | AFFILIATES_LAYER_VERSION (also standalone at ./version) | Pinned to package.json by a test. | | AffiliatesConfigError | Thrown by resolveAffiliatesConfig on any missing or invalid required field. | | AFFILIATES_ADMIN_CAPABILITY | The capability string ('affiliates:admin') checked by every admin-only access rule. | | Types | AffiliatesLayerConfig, ResolvedAffiliatesConfig, AffiliateProgramConfig, AffiliatesSlugs, AffiliateStatus, AffiliateApplicationAnswer, ReferralChannel, ReferralStatus, ReferralRejectionReason, CommissionStatus, CommissionSource, CommissionReversalReason, CommissionBasisContext, ReferralCookiePayload, AffiliateDashboard, AffiliateDashboardCommission, AffiliateJobConfig, JobHandler, AffiliatesLayer. The /server subpath also exports the argument types of its operations (ApproveAffiliateArgs, RejectAffiliateArgs, ReleaseFirstPayoutArgs, ReverseCommissionArgs, SyncAffiliatePromotionCodeArgs, RotateAffiliatePromotionCodeArgs, ApplyForAffiliateArgs, AcceptAffiliateTermsArgs, StartPayoutOnboardingArgs). |

/server subpath (@wabbit/tome-affiliates/server) — Node-only helper surface:

| Export | What it is | |---|---| | isAffiliatesAdmin(req) | Admin-capability check (sessionHasCapabilityOrLegacyAdmin). | | assertEconomyPayoutsCollections(payload, cfg) | Throws AffiliatesConfigError from your onInit/job if economy's Orders or payouts-extension collections are missing. | | signReferralCookie / verifyReferralCookie | Signed referral cookie codec. | | hashVisitor | Daily-rotating HMAC visitor hash for affiliate-clicks. | | captureReferral / bindReferralToMember / resolveCheckoutAttribution | Middleware and checkout attribution helpers. | | createAffiliateClickEndpoint(cfg) | The POST /affiliates/click endpoint factory. | | createSignedClickBody(input, secret, now?) | Builds the signed JSON body a consumer's recordClick POSTs to the click endpoint. | | registerAffiliateHandlers(cfg) | Registers the order/subscription/refund economy handlers. They reach Payload through AffiliatesLayerConfig.getPayload. | | setAffiliatesPayloadResolver(resolver) | Fallback for consumers that cannot pass getPayload: call it from onInit(payload). | | createAffiliateJobs(cfg) | The four scheduled jobs (approve, payout, expire, purge) — the same array as createAffiliatesLayer(...).jobs. Their handlers return ApproveMaturedCommissionsSummary, ExpireReferralsSummary and PurgeClicksSummary. | | registerAffiliatePayoutAdapters(adapters) / getAffiliatePayoutAdapters() / runAffiliatePayouts(args) | Register economy payout rails at boot; run the monthly payout batch over approved commissions. | | getAffiliateDashboard | Affiliate-facing aggregate query — never leaks buyer identity. Returns AffiliateDashboard (code, share URL, click/referral/conversion counts, pending/approved/paid/reversed cents, payout-account status) with recentCommissions: AffiliateDashboardCommission[] (createdAt, source, amountCents, status only). | | approveAffiliate / rejectAffiliate / releaseFirstPayout / reverseCommission / syncAffiliatePromotionCode | Admin operations. | | rotateAffiliatePromotionCode(args) | Replaces a leaked promotion code: deactivates the current code in Stripe first, then mints one with different text (newCodeText, 3-40 letters, digits or dashes) and links it to the affiliate. Returns { deactivated, id, code }. If the mint fails, run syncAffiliatePromotionCode again. Past orders keep their commissions. | | applyForAffiliate / acceptAffiliateTerms / startPayoutOnboarding | Plain async consumer-facing server actions — not Next.js server-action wrappers; wrap them yourself. acceptAffiliateTerms requires a signer (see Signing the agreement); startPayoutOnboarding's country is optional and read from the affiliate row. |

Applications, rejection, and re-application

Under enrollment: 'application', applyForAffiliate({ payload, cfg, memberId, code, application }) is how a member becomes an applied affiliate. application is an optional AffiliateApplicationAnswer[] ({ key, label, value }) — whatever question set your consumer asks; it is stored verbatim on the affiliate row's application array field (admin-visible, applicant-invisible after submission) and the row's applicationSubmittedAt is stamped server-side, never taken from the caller. An admin decides the outcome: approveAffiliate moves invited/applied to active; rejectAffiliate({ payload, cfg, affiliateId, actor, reason? }) moves invited/applied to rejected, stamping rejectedAt/rejectedBy and recording rejectionReason when a reason is given — it throws naming the current status when the row is not invited/applied (active, paused, terminated, or already rejected).

A rejected row is not a dead end. applyForAffiliate is also the re-application path: called again for the same member, it checks rejectedAt + cfg.program.reapplyAfterDays (default 30 days; 0 means the applicant may re-apply immediately). Inside the window it throws, naming the date re-application opens. Once the window has passed it UPDATES the same affiliate row rather than creating a second one — status back to applied, a fresh appliedAt/application/applicationSubmittedAt, and rejectedAt/rejectedBy/rejectionReason all cleared — so one member never accumulates more than one affiliate row. The requested code is only changed if it is free; a code already held by another affiliate row throws, exactly as it would on a first-time application. rejected is also exempt from the termsVersion-required rule (affiliates collection): a rejected applicant never accepted terms, so a rejected row saves without one.

Signing the agreement

acceptAffiliateTerms records a signature, not a checkbox. From 0.4.0 it requires a signer:

await acceptAffiliateTerms({
  payload, cfg, memberId,
  termsVersion: cfg.currentTermsVersion,
  termsHash: MY_AGREEMENT_SHA256,   // optional; see below
  ip: firstForwardedHop(headers),
  signer: {
    type: 'business',               // or 'individual'
    firstName: 'Grace',             // the authorised signer — a person, always
    lastName: 'Hopper',
    businessLegalName: 'Hopper Systems LLC',  // required when type is 'business'
    country: 'US',                  // ISO 3166-1 alpha-2
  },
})

Those five values are frozen onto the affiliate row and never resolved back through member. A member can edit their own profile name; an agreement that looked its signatory up at read time would record nothing durable. That matters more on this agreement than on a confidentiality one, because money moves — US tax reporting is against a payee whose legal name has to match their tax records.

The ip is stored as acceptedFromIp (before 0.4.0 it was logged and discarded).

Content hash. Set currentTermsHash in config and pass termsHash at acceptance, and a mismatch is refused. That closes the stale-tab case: a page left open across a revision would otherwise record consent to wording that no longer exists. A version string alone can be re-pointed at edited text; a hash cannot. Consumers that do not hash their agreement leave both unset and nothing changes.

Not captured: taxpayer identification numbers. TINs belong to the payout rail, which already collects tax identity and issues the forms. Holding one here would bring encryption-at-rest duties, breach-notification exposure and a retention policy for data this layer has no need to hold. Economy's payout accounts model tax-form kind and status, which is the right shape — you can know a W-9 is on file without holding it.

Payout rails are opt-in, not fallbacks

startPayoutOnboarding reads the affiliate's declared country from their row, so the jurisdiction on the agreement and the one the payout is routed for cannot diverge. Pass country explicitly only as an admin override; a mismatch is logged.

If no registered adapter supports that country, onboarding throws. It does not quietly fall back to the manual rail, as it did before 0.4.0 — that is how an affiliate ends up costing someone real time every month because an adapter happened to be missing. To allow the hand-run rail, an admin sets manualPayoutApprovedBy / manualPayoutApprovedAt on the affiliate row; until then an affiliate in an unsupported country can hold an accepted agreement and an approved status and still not start onboarding. That is the intended state.

Server / client posture

This package ships no React surface — no .tsx, no 'use client'. Everything under the root entry is Payload configuration (plain objects and factory functions), safe anywhere a payload.config.ts is evaluated. Everything under /server is Node-only: it talks to Payload directly and to a payment provider only through the stripe-shaped structural argument callers pass in. Keep /server out of any module a browser bundle walks.

Status

All v1 surfaces ship: collections and access; attribution (signed cookie, click capture, referral binding, checkout attribution, click endpoint); and the money flow (order, renewal and refund handlers, carry-forward reversals, the four jobs, payouts over the economy payouts extension, the affiliate dashboard, admin operations, and consumer actions). Known deferral: a refunded subscription invoice does not reverse its renewal commission, because economy dispatches no invoice-refund event.

Testing

pnpm --filter @wabbit/tome-affiliates test

The suite runs against resolveAffiliatesConfig, createAffiliatesLayer, and assertEconomyPayoutsCollections directly — no live Payload instance, no network.

Exports

  • @wabbit/tome-affiliates
  • @wabbit/tome-affiliates/server
  • @wabbit/tome-affiliates/version

Changelog

v0.6.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.6.1patch

579cf8d: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.

  • 579cf8d: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.
  • 286ad72: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.6.0minor

aaf7c34: **BREAKING:** the admin operations `approveAffiliate`, `rejectAffiliate`, `releaseFirstPayout`, `reverseCommission`, `syncAffiliatePromotionCode` and `rotateAffiliatePromotionCode` now authorize in-function. Each args object takes the admin's `req` (checked by the new `assertAffiliatesAdmin(req)` — `affiliates:admin` capability, super-admin, or a legacy admin role) or an explicit `system: true` for trusted server code with no session; without either, or for a non-admin session, the call throws the new `AffiliatesAuthorizationError` (code `affiliates-not-authorized`) before any write. Previously the contract was "callers are responsible for checking `isAffiliatesAdmin`", and every write runs with `overrideAccess: true`, so a consumer that wrapped one of these as a server action without its own gate shipped an open money-moving endpoint. Migration: pass `req` (or `system: true`) in each call.

  • aaf7c34: **BREAKING:** the admin operations `approveAffiliate`, `rejectAffiliate`, `releaseFirstPayout`, `reverseCommission`, `syncAffiliatePromotionCode` and `rotateAffiliatePromotionCode` now authorize in-function. Each args object takes the admin's `req` (checked by the new `assertAffiliatesAdmin(req)` — `affiliates:admin` capability, super-admin, or a legacy admin role) or an explicit `system: true` for trusted server code with no session; without either, or for a non-admin session, the call throws the new `AffiliatesAuthorizationError` (code `affiliates-not-authorized`) before any write. Previously the contract was "callers are responsible for checking `isAffiliatesAdmin`", and every write runs with `overrideAccess: true`, so a consumer that wrapped one of these as a server action without its own gate shipped an open money-moving endpoint. Migration: pass `req` (or `system: true`) in each call.
  • 56686d6: Logging goes through `payload.logger`, not stdout. Terms acceptance logs at info WITHOUT the accepting IP (the IP is still stored on the row as consent evidence, never logged); admin payout release / commission reversal notices use `payload.logger.info`; a failed affiliate lookup during checkout attribution now logs a warning instead of silently dropping the order's commission attribution.
  • cbcc20e: Jobs and handlers no longer silently truncate at 1000 (or 5000) rows. `approveMaturedAffiliateCommissions`, `expireAffiliateReferrals`, `purgeAffiliateClicks`, the payout run's approved-commission scan, the velocity review, the refund handler's order-commission scan and the carry-forward sum now page with core's `findPaged`; the renewal period index uses `payload.count`. Independent per-row writes (approve/expire/purge, velocity flag, mark-paid) run through core's bounded-concurrency `batchWrite`; in the three jobs a failing row is logged and counted instead of aborting the run. Job summaries gain optional `failed` and `truncated` fields (a truncated scan converges on the next run). Refund reversals stay sequential on purpose (money movement).
  • 44b39f3: The package-local `relationId` in `server/commissions` (same name as core's, but returning `''` instead of `null`) is removed. Every call site now uses core's `relationId` with an explicit `?? ''`, so behavior is unchanged. The two attribution helpers' private `relationshipId` copies also use core's reader.
v0.5.0minor

e87fdb7: Promotion codes now follow the affiliate's status, and a leaked code can be rotated. - New optional `setPromotionCodeActive({ promotionCodeRef, active })` config callback. The affiliates collection calls it whenever an affiliate crosses into or out of `active`. Pausing or terminating an affiliate deactivates their code in the payment provider, and reinstating them reactivates it. Before this change, a stopped affiliate's code kept giving buyers the discount while crediting nobody. The hook never throws: a provider failure is logged loudly and the status change stands. A missing callback produces a warning. - New `rotateAffiliatePromotionCode` (server entry). It deactivates the current code first, then mints a replacement with different text on the same coupon and links it to the affiliate. Reusing the old text is refused, because it would put the leaked code back into circulation. - Compile-time contract extended: a real Stripe client must fit rotation's structural `promotionCodes.update` / `.create` shape.

  • e87fdb7: Promotion codes now follow the affiliate's status, and a leaked code can be rotated. - New optional `setPromotionCodeActive({ promotionCodeRef, active })` config callback. The affiliates collection calls it whenever an affiliate crosses into or out of `active`. Pausing or terminating an affiliate deactivates their code in the payment provider, and reinstating them reactivates it. Before this change, a stopped affiliate's code kept giving buyers the discount while crediting nobody. The hook never throws: a provider failure is logged loudly and the status change stands. A missing callback produces a warning. - New `rotateAffiliatePromotionCode` (server entry). It deactivates the current code first, then mints a replacement with different text on the same coupon and links it to the affiliate. Reusing the old text is refused, because it would put the leaked code back into circulation. - Compile-time contract extended: a real Stripe client must fit rotation's structural `promotionCodes.update` / `.create` shape.
v0.4.2patch

a7ea106: Fix `syncAffiliatePromotionCode` for current Stripe API versions, and make it idempotent. Stripe API `2025-09-30.clover` removed the top-level `coupon` parameter from promotion-code creation in favour of `promotion: { type: 'coupon', coupon }`. The `stripe` Node SDK v22 pins `2026-04-22.dahlia`. This function still sent the removed parameter, so it would have failed the first time a consumer called it — it had never been called, and its only test used a mock that accepted the old shape. The structural `stripe` argument type changes to match. It is now also idempotent: an affiliate that already has a `promotionCodeRef` is returned without calling Stripe (Stripe rejects a second active code with the same text), and the result carries `created: boolean`. A compile-time contract (`src/server/stripe-promotion-code.contract.ts`, excluded from the build) asserts a real `Stripe` client satisfies the structural shape, so the next SDK change of this kind fails `typecheck` here instead of an approval in production. `stripe` is added as a devDependency for that file only.

  • a7ea106: Fix `syncAffiliatePromotionCode` for current Stripe API versions, and make it idempotent. Stripe API `2025-09-30.clover` removed the top-level `coupon` parameter from promotion-code creation in favour of `promotion: { type: 'coupon', coupon }`. The `stripe` Node SDK v22 pins `2026-04-22.dahlia`. This function still sent the removed parameter, so it would have failed the first time a consumer called it — it had never been called, and its only test used a mock that accepted the old shape. The structural `stripe` argument type changes to match. It is now also idempotent: an affiliate that already has a `promotionCodeRef` is returned without calling Stripe (Stripe rejects a second active code with the same text), and the result carries `created: boolean`. A compile-time contract (`src/server/stripe-promotion-code.contract.ts`, excluded from the build) asserts a real `Stripe` client satisfies the structural shape, so the next SDK change of this kind fails `typecheck` here instead of an approval in production. `stripe` is added as a devDependency for that file only.
v0.4.1patch

Fix `AFFILIATES_LAYER_VERSION` reporting `'0.3.0'` in the 0.4.0 release. 0.4.0 was versioned with a bare `changeset version` instead of the repo's `pnpm version-packages`, which also runs `sync-layer-versions`. So the published package's `package.json` said 0.4.0 while the exported layer-version constant — the value `createAffiliatesLayer` registers the layer under — still said 0.3.0. All 0.4.0 behaviour (signer identity, stored IP, terms hash, opt-in manual rail) shipped intact; only the self-reported version was wrong. This release re-syncs the constant. Consumers should take 0.4.1 rather than 0.4.0.

  • Fix `AFFILIATES_LAYER_VERSION` reporting `'0.3.0'` in the 0.4.0 release. 0.4.0 was versioned with a bare `changeset version` instead of the repo's `pnpm version-packages`, which also runs `sync-layer-versions`. So the published package's `package.json` said 0.4.0 while the exported layer-version constant — the value `createAffiliatesLayer` registers the layer under — still said 0.3.0. All 0.4.0 behaviour (signer identity, stored IP, terms hash, opt-in manual rail) shipped intact; only the self-reported version was wrong. This release re-syncs the constant. Consumers should take 0.4.1 rather than 0.4.0.
v0.4.0minor

832b8d1: Signer identity, a stored acceptance IP, a terms content hash, and an opt-in manual payout rail. **Why:** the receipt for an agreement that authorises payouts was weaker than the one a consumer keeps for a confidentiality agreement. `acceptAffiliateTerms` wrote three fields — version and two timestamps — and took an `ip` it logged and threw away. There was no record of _who_ signed (a member relationship, whose profile name the member can edit), _from where_, or _what exactly_ they agreed to (a version string can be re-pointed at edited wording). **`acceptAffiliateTerms` now requires a `signer`** — `{ type: 'individual' | 'business', firstName, lastName, businessLegalName?, country }` — and freezes it onto the affiliate row as `signerType` / `signedFirstName` / `signedLastName` / `businessLegalName` / `country`. Copied, never joined: a record resolving its signatory through a mutable profile field records nothing durable, and it matters more here than on an NDA because US tax reporting is against a payee whose legal name has to match their tax records. `businessLegalName` is required for a business signer and discarded for an individual; `country` must be ISO 3166-1 alpha-2 and is uppercased. **The IP is stored** as `acceptedFromIp` instead of only logged. **`termsHash` is optional on both sides.** Pass `currentTermsHash` in config and `termsHash` at acceptance and a mismatch is refused — the stale-tab case, where a page left open across a revision would otherwise record consent to text that no longer exists. Consumers that do not hash their agreement are unaffected. **Deliberately not captured: any taxpayer identification number.** TINs belong to the payout rail, which already collects tax identity and issues the forms. Holding one here would bring encryption-at-rest duties, breach-notification exposure and a retention policy for data this layer has no need to hold. **BEHAVIOUR REMOVED — `startPayoutOnboarding` no longer falls back to the manual rail silently.** It previously resolved `connectAdapter ?? manualAdapter`, so an affiliate in a country no Connect adapter supported landed on a hand-run rail because an adapter happened to be missing. The manual rail is now opt-in: it requires `manualPayoutApprovedBy` / `manualPayoutApprovedAt` on the affiliate row, set by an admin. Without that approval an unsupported country throws, naming the country. An affiliate in that state holds an accepted agreement and an approved status but cannot start onboarding — that is the intended state, not a bug. `startPayoutOnboarding`'s `country` argument is now **optional** and sources from the affiliate row's declared country, so the jurisdiction on the agreement and the jurisdiction the payout is routed for cannot diverge. Passing it explicitly still works as an admin override and logs a warning on mismatch. **Migration:** a single production consumer exists today. Callers of `acceptAffiliateTerms` must add `signer`; callers of `startPayoutOnboarding` may drop `country`. Rows signed before this release keep working — `termsHash` and the signer fields are simply absent on them.

  • 832b8d1: Signer identity, a stored acceptance IP, a terms content hash, and an opt-in manual payout rail. **Why:** the receipt for an agreement that authorises payouts was weaker than the one a consumer keeps for a confidentiality agreement. `acceptAffiliateTerms` wrote three fields — version and two timestamps — and took an `ip` it logged and threw away. There was no record of _who_ signed (a member relationship, whose profile name the member can edit), _from where_, or _what exactly_ they agreed to (a version string can be re-pointed at edited wording). **`acceptAffiliateTerms` now requires a `signer`** — `{ type: 'individual' | 'business', firstName, lastName, businessLegalName?, country }` — and freezes it onto the affiliate row as `signerType` / `signedFirstName` / `signedLastName` / `businessLegalName` / `country`. Copied, never joined: a record resolving its signatory through a mutable profile field records nothing durable, and it matters more here than on an NDA because US tax reporting is against a payee whose legal name has to match their tax records. `businessLegalName` is required for a business signer and discarded for an individual; `country` must be ISO 3166-1 alpha-2 and is uppercased. **The IP is stored** as `acceptedFromIp` instead of only logged. **`termsHash` is optional on both sides.** Pass `currentTermsHash` in config and `termsHash` at acceptance and a mismatch is refused — the stale-tab case, where a page left open across a revision would otherwise record consent to text that no longer exists. Consumers that do not hash their agreement are unaffected. **Deliberately not captured: any taxpayer identification number.** TINs belong to the payout rail, which already collects tax identity and issues the forms. Holding one here would bring encryption-at-rest duties, breach-notification exposure and a retention policy for data this layer has no need to hold. **BEHAVIOUR REMOVED — `startPayoutOnboarding` no longer falls back to the manual rail silently.** It previously resolved `connectAdapter ?? manualAdapter`, so an affiliate in a country no Connect adapter supported landed on a hand-run rail because an adapter happened to be missing. The manual rail is now opt-in: it requires `manualPayoutApprovedBy` / `manualPayoutApprovedAt` on the affiliate row, set by an admin. Without that approval an unsupported country throws, naming the country. An affiliate in that state holds an accepted agreement and an approved status but cannot start onboarding — that is the intended state, not a bug. `startPayoutOnboarding`'s `country` argument is now **optional** and sources from the affiliate row's declared country, so the jurisdiction on the agreement and the jurisdiction the payout is routed for cannot diverge. Passing it explicitly still works as an admin override and logs a warning on mismatch. **Migration:** a single production consumer exists today. Callers of `acceptAffiliateTerms` must add `signer`; callers of `startPayoutOnboarding` may drop `country`. Rows signed before this release keep working — `termsHash` and the signer fields are simply absent on them.
v0.3.0minor

d806a2a: `applyForAffiliate` stores an applicant's own answers and `rejectAffiliate` gives admins a way to turn one down; a rejected applicant can try again after a configurable window. The `affiliates` collection gains five fields: `application` (an admin-visible, read-only array of `{ key, label, value }` answers), `applicationSubmittedAt`, `rejectedAt`, `rejectedBy`, and `rejectionReason` (admin-only, like `notes`). `status` gains `rejected`, and the `termsVersion`-required rule now exempts `rejected` alongside `invited`/`applied` — a rejected applicant never accepted terms, so the row was wrongly unsavable before this change. `applyForAffiliate` takes an optional `application?: AffiliateApplicationAnswer[]` argument and stamps `applicationSubmittedAt` server-side. Its re-application rule replaces the old blanket "member already has an affiliate row" throw for the one case that used to dead-end permanently: a `rejected` row. Once `rejectedAt + program.reapplyAfterDays` has passed (new `AffiliateProgramConfig.reapplyAfterDays`, default 30; `0` means immediately), calling `applyForAffiliate` again for that member UPDATES the same row instead of creating a second one — `applied`, a fresh `appliedAt`/answers, and the rejection fields cleared — with the requested code changed only if it is free. Inside the window it throws, naming the date re-application opens. Every other existing-row status still throws as before. `rejectAffiliate({ payload, cfg, affiliateId, actor, reason? })` mirrors `approveAffiliate`: only from `invited`/`applied` (throws otherwise, naming the status), sets `status: 'rejected'`, stamps `rejectedAt`/`rejectedBy`, and records `rejectionReason` when given. No breaking change to any existing export's signature — `application` and `reason` are both optional, and `reapplyAfterDays` defaults. Consumers on `enrollment: 'invite-only'` are unaffected.

  • d806a2a: `applyForAffiliate` stores an applicant's own answers and `rejectAffiliate` gives admins a way to turn one down; a rejected applicant can try again after a configurable window. The `affiliates` collection gains five fields: `application` (an admin-visible, read-only array of `{ key, label, value }` answers), `applicationSubmittedAt`, `rejectedAt`, `rejectedBy`, and `rejectionReason` (admin-only, like `notes`). `status` gains `rejected`, and the `termsVersion`-required rule now exempts `rejected` alongside `invited`/`applied` — a rejected applicant never accepted terms, so the row was wrongly unsavable before this change. `applyForAffiliate` takes an optional `application?: AffiliateApplicationAnswer[]` argument and stamps `applicationSubmittedAt` server-side. Its re-application rule replaces the old blanket "member already has an affiliate row" throw for the one case that used to dead-end permanently: a `rejected` row. Once `rejectedAt + program.reapplyAfterDays` has passed (new `AffiliateProgramConfig.reapplyAfterDays`, default 30; `0` means immediately), calling `applyForAffiliate` again for that member UPDATES the same row instead of creating a second one — `applied`, a fresh `appliedAt`/answers, and the rejection fields cleared — with the requested code changed only if it is free. Inside the window it throws, naming the date re-application opens. Every other existing-row status still throws as before. `rejectAffiliate({ payload, cfg, affiliateId, actor, reason? })` mirrors `approveAffiliate`: only from `invited`/`applied` (throws otherwise, naming the status), sets `status: 'rejected'`, stamps `rejectedAt`/`rejectedBy`, and records `rejectionReason` when given. No breaking change to any existing export's signature — `application` and `reason` are both optional, and `reapplyAfterDays` defaults. Consumers on `enrollment: 'invite-only'` are unaffected.
v0.2.0minor

d2375ad: `registerAffiliateHandlers` uses economy's keyed registration instead of its own globalThis bookkeeping. The hand-rolled registry entry (a `Symbol.for('@wabbit/tome-affiliates/handlers')` slot holding a bundled unsubscribe, plus an early return on the second call) is replaced by passing a stable `key` to each of the three economy registrations. Same guarantee — a repeat call never leaves a second copy of any handler registered — with the bookkeeping now living in the registry that owns the problem. One semantic shift is deliberate: a repeat call now RE-registers with the config it was handed and returns a fresh unsubscribe, where before it ignored the new config and returned the first call's unsubscribe. Last config wins, which is what you want under HMR and when a later caller passes updated settings. An unsubscribe returned by a superseded call is inert rather than destructive, because economy releases a key only if the handler holding it is still the live one. RELEASE COUPLING — read before publishing. This now calls a two-argument registration API that only exists in the `@wabbit/tome-economy` release carrying keyed registration. The peer floor is currently `>=0.11.0 <1.0.0` and MUST be raised to that release's version. JavaScript does not throw when a second argument is passed to a one-argument function, so an affiliates build resolved against an older economy would ignore `{ key }` and silently fall back to append-every-time — losing the dedupe with no error, which is the exact failure class this pair of changes exists to remove. The floor is intentionally left unbumped here because version numbers belong to the release train, not to this changeset.

  • d2375ad: `registerAffiliateHandlers` uses economy's keyed registration instead of its own globalThis bookkeeping. The hand-rolled registry entry (a `Symbol.for('@wabbit/tome-affiliates/handlers')` slot holding a bundled unsubscribe, plus an early return on the second call) is replaced by passing a stable `key` to each of the three economy registrations. Same guarantee — a repeat call never leaves a second copy of any handler registered — with the bookkeeping now living in the registry that owns the problem. One semantic shift is deliberate: a repeat call now RE-registers with the config it was handed and returns a fresh unsubscribe, where before it ignored the new config and returned the first call's unsubscribe. Last config wins, which is what you want under HMR and when a later caller passes updated settings. An unsubscribe returned by a superseded call is inert rather than destructive, because economy releases a key only if the handler holding it is still the live one. RELEASE COUPLING — read before publishing. This now calls a two-argument registration API that only exists in the `@wabbit/tome-economy` release carrying keyed registration. The peer floor is currently `>=0.11.0 <1.0.0` and MUST be raised to that release's version. JavaScript does not throw when a second argument is passed to a one-argument function, so an affiliates build resolved against an older economy would ignore `{ key }` and silently fall back to append-every-time — losing the dedupe with no error, which is the exact failure class this pair of changes exists to remove. The floor is intentionally left unbumped here because version numbers belong to the release train, not to this changeset.
v0.1.0minor

8473b77: Initial release. A referral attribution and commissions layer built on `@wabbit/tome-economy` 0.11.0's seams and payouts extension (layer spec 2026-09-14). **Collections.** `affiliates`, `affiliate-referrals`, `affiliate-clicks`, and `affiliate-commissions`, all slug-overridable. Admin access uses the `affiliates:admin` capability; an affiliate reads only their own row. GDPR registration: affiliates soft-anonymize, referrals null the referred member, commissions are retained. Terminating an affiliate voids their pending commissions and expires their open referrals. **Attribution.** A signed referral cookie (Web Crypto HMAC, safe on the Edge runtime) that is only set with marketing consent when `requireMarketingConsent` is on, a daily-rotating visitor hash, `captureReferral` for middleware, `bindReferralToMember` at signup (last click wins, self-referral rejected), `resolveCheckoutAttribution` for checkout metadata, and a signed `POST /affiliates/click` endpoint. Promotion codes attribute without a cookie and win over links by default. **Money flow.** Order, subscription-renewal, and order-refund handlers on economy's dispatchers. Commissions snapshot the rate and pay on the amount actually paid before tax, optionally narrowed by `commissionBasis`. Partial refunds reverse proportionally; a refund after payout becomes a negative carry-forward row netted against the next payout. A velocity control flags bursts of conversions from one visitor for review. Four jobs: approve matured commissions, run payouts, expire referrals, purge clicks. Payouts run through economy's `runPayoutBatch`, and each affiliate's first payout waits for admin review. **Wiring.** `createAffiliatesLayer(config)` returns collections, endpoints, jobs, and `registerHandlers`. Handlers reach Payload through `config.getPayload`. Consumers call `assertEconomyPayoutsCollections` and `registerAffiliatePayoutAdapters` from `onInit`. Requires `@wabbit/tome-core` >=1.16.0 and `@wabbit/tome-economy` >=0.11.0. Known deferral: a refunded subscription invoice does not reverse its renewal commission, because economy dispatches no invoice-refund event yet.

  • 8473b77: Initial release. A referral attribution and commissions layer built on `@wabbit/tome-economy` 0.11.0's seams and payouts extension (layer spec 2026-09-14). **Collections.** `affiliates`, `affiliate-referrals`, `affiliate-clicks`, and `affiliate-commissions`, all slug-overridable. Admin access uses the `affiliates:admin` capability; an affiliate reads only their own row. GDPR registration: affiliates soft-anonymize, referrals null the referred member, commissions are retained. Terminating an affiliate voids their pending commissions and expires their open referrals. **Attribution.** A signed referral cookie (Web Crypto HMAC, safe on the Edge runtime) that is only set with marketing consent when `requireMarketingConsent` is on, a daily-rotating visitor hash, `captureReferral` for middleware, `bindReferralToMember` at signup (last click wins, self-referral rejected), `resolveCheckoutAttribution` for checkout metadata, and a signed `POST /affiliates/click` endpoint. Promotion codes attribute without a cookie and win over links by default. **Money flow.** Order, subscription-renewal, and order-refund handlers on economy's dispatchers. Commissions snapshot the rate and pay on the amount actually paid before tax, optionally narrowed by `commissionBasis`. Partial refunds reverse proportionally; a refund after payout becomes a negative carry-forward row netted against the next payout. A velocity control flags bursts of conversions from one visitor for review. Four jobs: approve matured commissions, run payouts, expire referrals, purge clicks. Payouts run through economy's `runPayoutBatch`, and each affiliate's first payout waits for admin review. **Wiring.** `createAffiliatesLayer(config)` returns collections, endpoints, jobs, and `registerHandlers`. Handlers reach Payload through `config.getPayload`. Consumers call `assertEconomyPayoutsCollections` and `registerAffiliatePayoutAdapters` from `onInit`. Requires `@wabbit/tome-core` >=1.16.0 and `@wabbit/tome-economy` >=0.11.0. Known deferral: a refunded subscription invoice does not reverse its renewal commission, because economy dispatches no invoice-refund event yet.