Economy

Commerce engineStable
@wabbit/tome-economyv0.13.2

Tome economy layer — orders, payments, prices, subscriptions, payment provider adapters (Stripe + Free), Stripe webhook handler, checkout + subscription-checkout actions, order-complete and subscription-lifecycle event buses.

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-economy

Overview

@wabbit/tome-economy

Tome economy layer — orders, payments, prices, subscriptions, payment provider adapters (Stripe + Free), Stripe webhook handler, checkout + subscription-checkout session actions, order-complete/subscription-lifecycle event buses.

Layer: domain (per ARCHITECTURE.md). @wabbit/tome-core is a required peer as of 0.10.0 (see Server / client posture for why the prior "genuinely optional" posture no longer held).

Install

pnpm add @wabbit/tome-economy

Peer ranges, copied from package.json:

| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | @payloadcms/richtext-lexical | >=3.67.0 | no | | @wabbit/tome-core | >=1.14.0 <2.0.0 | no | | stripe | >=14.0.0 | yes | | lucide-react | >=0.460.0 | yes |

lucide-react is genuinely optional: the layer's ShoppingCart sidebar icon is loaded lazily, and without the package the admin sidebar shows the nav domain's default icon instead.

60-second quickstart

Like @wabbit/tome-catalog, economy now has a top-level layer entry, createEconomyLayer(config?), the canonical entry. It returns a bare CollectionConfig[]: Orders, Payments, Prices, and Subscriptions (default ON — see Subscriptions) built from one shared EconomyConfig, plus an opt-in fifth (VendorEarnings, via vendorEarnings: true | VendorEarningsCollectionConfig — deferred to multi-vendor v1+, off by default) — and it delegates sidebar registration to initEconomy() internally. As of 0.10.0 it also applies the shared access/hooks/extraFields/fieldOverrides/omitFields/fieldOrder vocabulary from @wabbit/tome-core/utilities/layerFactoryConfig to every collection it returns — see Server / client posture:

import { buildConfig } from 'payload'
import { createEconomyLayer } from '@wabbit/tome-economy'

export default buildConfig({
  collections: [
    ...createEconomyLayer(),
    // ...your other collections
  ],
})

The pre-existing per-collection-factory path (plus the optional initEconomy() that only registers the admin-sidebar manifest, returning void) still works and is unaffected:

import { buildConfig } from 'payload'
import {
  initEconomy,
  createOrdersCollection,
  createPaymentsCollection,
  createPricesCollection,
} from '@wabbit/tome-economy'

initEconomy() // optional — sidebar grouping only ('Commerce', order: 10, after catalog's order: 0); superseded by createEconomyLayer for most consumers

export default buildConfig({
  collections: [
    createOrdersCollection(),
    createPaymentsCollection(),
    createPricesCollection(),
  ],
})

initEconomy() itself did not change shape — it still returns void and only registers the admin-sidebar manifest; createEconomyLayer calls it internally rather than the other way around, so there remains exactly one place that owns the registerLayer call.

API surface

Single export subpath (. only) — notably, createStripeWebhookHandler and createCheckoutSessionAction (below) ship from the main barrel, not a ./server subpath, unlike crm/deals/marketing's server-only split.

| Group | Exports | |---|---| | Layer entry | createEconomyLayer(config?), EconomyLayerConfig — canonical; returns CollectionConfig[], matching the createOrgLayer/createAccountsLayer bare-array shape | | Collection factories | createOrdersCollection, createPaymentsCollection, createPricesCollection, createVendorEarningsCollection, createSubscriptionsCollection (+ each factory's *CollectionConfig type). Note: createVendorEarningsCollection is a documented stub per the barrel's own comment — "multi-vendor deferred to v1+" | | Sidebar-only registration | initEconomy(config?), InitEconomyConfig — void-returning, registration-only; unchanged contract, now called internally by createEconomyLayer too | | Provider adapters | StripeAdapter, createStripeAdapter (+StripeAdapterConfig), FreeAdapter, createFreeAdapter, PaymentProviderAdapter interface (+ CheckoutLineItem, CreateCheckoutSessionArgs, WebhookEvent, CreateSubscriptionSessionArgs, SubscriptionSessionResult; saved-card types CreateSetupSessionArgs, SetupSessionResult, ChargeSavedPaymentMethodArgs, ChargeSavedPaymentMethodResult, ChargeFailureCode, ReleaseSavedPaymentMethodResult — see Saved payment methods) | | Event bus | onOrderComplete, registerOrderCompleteHandler, onOrderCompleteDispatch; subscription-lifecycle equivalents ×4 (register/onSubscription{Complete,Renewal,Cancel,PaymentFailed}Handler + matching *Dispatch functions and event types) | | Route/action helpers | createStripeWebhookHandler (+StripeWebhookHandlerConfig, SetupCompletedWebhookEvent), createCheckoutSessionAction (+CreateCheckoutSessionActionConfig, CheckoutSessionInput, CheckoutCallerIdentity, CheckoutIdentityFailureReason), CheckoutIdentityMismatchError, ReservedCheckoutMetadataKeyError (+RESERVED_CHECKOUT_METADATA_KEYS) | | Subscription actions | createSubscriptionCheckoutAction (+CreateSubscriptionCheckoutActionConfig, SubscriptionCheckoutInput), createSubscriptionPortalAction (+CreateSubscriptionPortalActionConfig, SubscriptionPortalInput), cancelSubscriptionAction (+CancelSubscriptionActionConfig, CancelSubscriptionInput), SubscriptionCheckoutUnsupportedError | | Order-refund event bus | registerOrderRefundHandler, onOrderRefundDispatch (+OrderRefundedEvent, OrderRefundHandler types) — mirrors the order-complete bus above | | Payouts extension | createPayoutAccountsCollection, createPayoutsCollection, PAYOUTS_DEFAULT_SLUGS, ManualPayoutAdapter/createManualPayoutAdapter, StripeConnectPayoutAdapter/createStripeConnectPayoutAdapter (+STRIPE_CONNECT_DEFAULT_SUPPORTED_COUNTRIES), createPayoutWebhookHandler, runPayoutBatch, getPayeeAnnualTotals, registerPayoutsGdpr (+PayoutProviderAdapter, PayoutRail, PayoutsConfig and related types) | | Types | EconomyConfig, Order, OrderItem, OrderStatus, Payment, PaymentStatus, PaymentProvider, Price, Subscription, SubscriptionStatus, CheckoutSessionResult, OrderCompletedEvent, OrderCompleteHandler, ECONOMY_DEFAULT_SLUGS |

Subscriptions

Added 2026-09-06 as a prerequisite for @wabbit/tome-directory (a monthly/annual per-listing tier ladder needs recurring billing this package did not yet have). createEconomyLayer includes the Subscriptions collection by default — pass subscriptions: false to omit it, or a SubscriptionsCollectionConfig to override its slugs/relationship targets.

createSubscriptionCheckoutAction is the recurring-billing counterpart of createCheckoutSessionAction, same identity contract (auth is a required second argument; a mismatch throws CheckoutIdentityMismatchError). It resolves the Price, requires interval: 'month' | 'year' (throws SubscriptionCheckoutUnsupportedError for a one-time Price), feature-detects adapter.createSubscriptionSession (throws the same error type when the adapter has none — FreeAdapter has no recurring-billing support), then creates the Checkout session and a Subscriptions row. providerRef on that row starts as the Checkout Session id and is finalized to the real provider subscription id by createStripeWebhookHandler's subscription.renewed handling on first activation — mirroring how createCheckoutSessionAction sets Order.externalId before the webhook completes it.

createSubscriptionPortalAction/cancelSubscriptionAction feature-detect adapter.createPortalSession/adapter.cancelSubscription the same way.

On subscription.renewed, the webhook handler now (in addition to its existing entitlement dispatch, unchanged) writes a renewal Payment row — previously that branch dispatched events but created no ledger row, unlike checkout.completed. Because a renewal has no Order, Payments.order is now optional and a new Payments.subscription relationship carries the link instead.

Checkout metadata passthrough

Added 2026-09-14 as a prerequisite for @wabbit/tome-affiliates. CheckoutSessionInput.metadata?: Record<string, string> lets a caller attach arbitrary extra keys (e.g. an affiliate program's affiliateCode/referralId) to a one-time checkout. The action merges that metadata with the four RESERVED keys it computes itself — orderId, productId, memberId, productType (exported as RESERVED_CHECKOUT_METADATA_KEYS) — and a caller-supplied key that collides with one of them throws ReservedCheckoutMetadataKeyError before any Payload or adapter call. The merged object reaches both the adapter's session metadata (and so payment_intent_data.metadata, per the existing refund-lookup stamp) and the pending Order's own metadata field, written at creation (the Order row omits orderId from its own metadata — self-referential, the row already knows its id). createSubscriptionCheckoutAction's metadata was already required and already passed arbitrary extra keys through untouched, so it needed no change for the same use case.

Paid-amount truth

Added 2026-09-14. Before this, Orders.totalCents/discountCents were frozen at LIST price from createCheckoutSessionAction's pending-Order write (discountCents hardcoded 0) and never corrected once Stripe applied a promo — the Payment row alone carried the real charged amount. createStripeWebhookHandler's checkout.completed branch now overwrites Orders.totalCents with the session's real amount_total on completion, plus two new Orders fields written when the session resolves them: taxCents (from total_details.amount_tax) and promotionCodeRef (the applied Stripe promotion code id, from session.discounts[0].promotion_code; undefined for a coupon-only discount or no discount at all). discountCents is likewise overwritten from total_details.amount_discount when resolvable. This is also the fix for the refund branch's isPartial comparison (refundAmountCents < order.totalCents) — it reads the same field, so once totalCents carries the real charged amount instead of list price, that comparison is correct for a discounted order too, with no separate code change. WebhookEvent's checkout.completed member gained subtotalCents/discountCents/taxCents/promotionCodeRef, all optional (undefined on a hand-built event that doesn't carry them — every real Stripe session does).

Order-refund dispatcher

Added 2026-09-14. Mirrors the order-complete event bus exactly (same globalThis-keyed registry pattern, same Promise.allSettled best-effort delivery): registerOrderRefundHandler(handler) subscribes, onOrderRefundDispatch(order) fires, and the event shape is OrderRefundedEvent { orderId, userId, status: 'refunded' | 'partially-refunded', refundedCents, totalCents, refundedAt }. Before this, no layer had a refund signal from economy — consumers reinvented their own afterChange watch on Orders. A new Orders.refundedCents field (default 0) carries the provider's cumulative refund amount, written by createStripeWebhookHandler's refund branch alongside the existing status write. The Orders collection's afterChange hook dispatches when status is refunded/partially-refunded AND EITHER the status just transitioned OR refundedCents increased — the second arm is what makes a partial refund followed later by a full refund dispatch twice, and the first arm alone is what makes a webhook redelivery reporting the same cumulative amount a no-op.

Renewal amount on the renewal event

Added 2026-09-14. SubscriptionRenewedEvent (the event registerSubscriptionRenewalHandler subscribes to) gains amountCents?, currency?, and paymentId?. The webhook handler now writes the renewal Payment row BEFORE dispatching (previously: dispatch first, write after) so the dispatch can carry that row's id; amountCents/currency come straight from the source WebhookEvent and so are present whenever it resolved them, independent of whether the ledger write itself succeeded — only paymentId depends on that write, and is omitted (not the whole dispatch skipped) if it fails. The entitlement dispatch is still never gated on the ledger write succeeding, preserving the existing "a ledger-write hiccup cannot turn a successful entitlement grant into a Stripe retry" guarantee.

Payouts extension (opt-in)

Added 2026-09-14 as a prerequisite for @wabbit/tome-affiliates, and built to be the one payout seam for the platform (vendor earnings join it when the marketplace money side is built). Opt in with createEconomyLayer({ payouts: true }), which adds two collections and registers their GDPR handling: payout-accounts (one row per payee per rail, with onboarding status, payoutsEnabled, and tax-form status) and payouts (one batch per payee per run, with polymorphic items of { sourceCollection, sourceId, amountCents } so negative items can carry a reversal forward). Both are server-written; REST create and delete are closed, admins read everything through economy's admin gate, and a payee reads only their own rows.

Money moves through a PayoutProviderAdapter, separate from the payment adapter. ManualPayoutAdapter records a payout for an admin to pay outside Stripe and mark paid. StripeConnectPayoutAdapter onboards a payee with a v1 Connect account (Express dashboard, transfers capability only, Stripe-hosted onboarding) and pays with a separate transfer from the platform balance; a US platform can reach payees in the US, UK, EEA, Canada, and Switzerland only, which supportedCountries enforces. Mount createPayoutWebhookHandler on its own route for Connect account.updated and transfer.reversed events.

runPayoutBatch sends released payouts first, then batches newly owed items per payee: it enforces a minimum, never batches a payee who already has a payout waiting for review, checks the platform balance before recording anything, creates a first payout as awaiting-review when requiresReview says so, and uses payout:<payeeId>:<runId> as the idempotency key so re-running a run never sends twice. getPayeeAnnualTotals returns paid totals per payee for a calendar year, for 1099 preparation.

Server / client posture

Server-side, but with a genuinely different posture than crm/deals/marketing: economy has no `server-only` dependency and no `./server` subpath — createStripeWebhookHandler (a Next.js route-handler factory), createCheckoutSessionAction, and createSubscriptionCheckoutAction (server-action helpers) are exported straight from the main barrel with no build-time guard against client-component import. Site code must still only invoke them from server contexts; nothing at the package level enforces it.

What IS enforced, as of the 2026-09-01 sale-readiness audit, is caller identity. The function createCheckoutSessionAction returns takes a required second argument, auth: pass { user } from your own session lookup (a Payload req.user-like object). It resolves the session user's member row through @wabbit/tome-core/identity's resolveMemberFromSession (the members collection is a separate collection from users, so a user id never equals a member id), asserts that row IS the memberId in the input, and asserts memberEmail is either the session's or that member's address; anything else throws CheckoutIdentityMismatchError before the Order is written or the adapter is called. The Order writes keep overrideAccess: true, which is safe precisely because the caller is now proven rather than assumed. A genuinely anonymous flow opts out with { allowUnauthenticatedCaller: true }; that is documented as dangerous and does not relax the assertions when a user IS present. createSubscriptionCheckoutAction runs the identical gate.

`@wabbit/tome-core` is a REQUIRED peer since 0.10.0 — the "genuinely optional" posture documented here through 0.9.0 no longer held even before this change: createCheckoutSessionAction.ts already carried a static, module-scope import { resolveMemberFromSession } from '@wabbit/tome-core/identity', so any consumer using that action already required core at runtime regardless of what peerDependenciesMeta claimed. initEconomy now imports registerLayer statically too (the try/catch around it exists only for the "already registered" HMR/repeat-call case, matching catalog/org/accounts/etc.'s own initXLayer), and createEconomyLayer applies @wabbit/tome-core/utilities/layerFactoryConfig's shared access/hooks/extraFields/fieldOverrides/omitFields/fieldOrder vocabulary to every collection it returns — the seam every other multi-collection layer already offers.

Provider seam

The PaymentProviderAdapter interface, and the Orders/Payments/Subscriptions provider field, were built stripe-first and are now open to any processor:

  • `provider` fields are `text` with an open `validate`, not a closed `select`. A known value (stripe, free, manual, authorizenet) is documented for the admin UI; any other non-empty string is also accepted, so a new adapter's provider id writes cleanly with zero schema change here. PaymentProvider is the matching open type ('stripe' | 'free' | 'manual' | 'authorizenet' | (string & {})).
  • `adapter.webhookSignatureHeader?: string` — createStripeWebhookHandler reads adapter.webhookSignatureHeader ?? 'stripe-signature' instead of hardcoding Stripe's header name, so a processor with its own signature header (Authorize.net's X-ANET-Signature, the first real case — see @wabbit/tome-economy-authnet) plugs in without a fork.
  • `adapter.cancelSubscription?(args)` / `adapter.refund?(args)` — both optional, both feature-detected by callers (cancelSubscriptionAction, and any consumer issuing a refund). StripeAdapter implements both.
  • Every Payment/Order write in this package now stamps adapter.provider — the webhook handler's checkout.completed and subscription.renewed branches no longer hardcode 'stripe'.

Saved payment methods and off-session charges

For flows that save a card now and charge it later (a crowdfunding pledge settled at the deadline, a deferred invoice), PaymentProviderAdapter has three optional methods. Feature-detect each before calling (if (adapter.chargeSavedPaymentMethod)). StripeAdapter implements all three; FreeAdapter implements them synthetically for tests and demos.

  • `createSetupSession(args: CreateSetupSessionArgs): Promise<SetupSessionResult>` — hosted Checkout in mode: 'setup': saves a card and charges nothing. CreateSetupSessionArgs is { customerEmail, successUrl, cancelUrl, metadata, stripeCustomerId? }; pass an existing stripeCustomerId so a repeat customer is not duplicated. Returns { sessionId, url }. When the session completes, the webhook handler emits a setup.completed event and calls your onSetupCompleted(event) option on createStripeWebhookHandler (typed SetupCompletedWebhookEvent, carrying pledgeId from metadata.pledgeId, the session id and the customer id). The handler writes no Order or Payment for this event; your hook owns the write.
  • `chargeSavedPaymentMethod(args: ChargeSavedPaymentMethodArgs): Promise<ChargeSavedPaymentMethodResult>` — charges the saved card off-session. Args: { customerId, paymentMethodId, amountCents, currency, metadata, idempotencyKey }. idempotencyKey is passed through to the provider, so re-running a settlement job returns the original charge instead of charging twice; use a stable key per charge (the pledge id). A decline is a result, not an exception: the result is { ok: true, paymentIntentId, status } or { ok: false, failureCode, failureMessage, paymentIntentId?, status? }, where ChargeFailureCode is 'card_declined' (retry within your dunning window), 'authentication_required' (the customer must come back on-session; an off-session retry will fail the same way) or 'provider_error' (network, bad request, outage).
  • `releaseSavedPaymentMethod({ paymentMethodId }): Promise<ReleaseSavedPaymentMethodResult>` — detaches the saved card when it will not be charged. Never throws; returns { released, failureMessage? }.

Links

Extending this package

Provider adapters implement the PaymentProviderAdapter interface — StripeAdapter/FreeAdapter are the two shipped implementations; a third provider is a matter of implementing the same interface (including the now-open provider string and the optional webhookSignatureHeader/cancelSubscription/refund members) and passing it wherever a site currently passes createStripeAdapter(...). The order-complete and subscription event buses (registerOrderCompleteHandler et al.) are the seam for cross-layer reactions (e.g. LMS enrollment activation on order completion) without a direct dependency.

Exports

  • @wabbit/tome-economy

Changelog

v0.13.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.13.1patch

c71de23: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `ShoppingCart` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.

  • c71de23: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `ShoppingCart` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.
v0.13.0minor

b2470a2: **BREAKING:** economy's collection admin gate now delegates to core's `sessionHasCapabilityOrLegacyAdmin(req, 'economy:admin')` (core has been a required peer since 2026-09-06). The default access of Orders, Payments, Prices, Subscriptions, VendorEarnings, PayoutAccounts and Payouts therefore also admits `super-admin` (populated roles, `role: string[]`, or the legacy `'superadmin'`/`'super-admin'` strings) and any role granted the `economy:admin` capability — previously only a legacy `roles: string[]` containing `'admin'` passed. Consumers who override `access` are unaffected. Internally the gate is async and takes the request. All 51 inline `as CollectionSlug` casts are replaced with core's `typedSlug()` (no runtime change).

  • b2470a2: **BREAKING:** economy's collection admin gate now delegates to core's `sessionHasCapabilityOrLegacyAdmin(req, 'economy:admin')` (core has been a required peer since 2026-09-06). The default access of Orders, Payments, Prices, Subscriptions, VendorEarnings, PayoutAccounts and Payouts therefore also admits `super-admin` (populated roles, `role: string[]`, or the legacy `'superadmin'`/`'super-admin'` strings) and any role granted the `economy:admin` capability — previously only a legacy `roles: string[]` containing `'admin'` passed. Consumers who override `access` are unaffected. Internally the gate is async and takes the request. All 51 inline `as CollectionSlug` casts are replaced with core's `typedSlug()` (no runtime change).
  • 56686d6: Stripe webhook handler: the "setup.completed with no hook registered" and "subscription.payment_failed (dunning)" notices log through `payload.logger.info` instead of `console.info`.
  • 44b39f3: `runPayoutBatch` reads relationship ids with core's `relationId` instead of a local copy, which also handles numeric (Postgres) ids.
v0.12.0minor

40a0f22: Handler registration accepts an optional `key`, so a module evaluated in more than one bundle registers exactly one handler. Every economy dispatcher keeps its handler list on `globalThis` under a `Symbol.for(...)` key, because bundlers — notably Next.js — load the module in more than one server bundle. That is what stops registration writing into one copy while dispatch reads an empty other copy. The unaddressed half is that registration then also runs once per bundle, and a bare `push()` stacked a second copy of the same handler each time. The dispatchers fan out through `Promise.allSettled`, so the copies run concurrently, and any consumer handler doing a check-then-act (find, then create if absent) had every copy pass the check and write. The failure surfaces as a silent duplicate rather than an error. `registerOrderCompleteHandler`, `registerOrderRefundHandler`, and the four subscription lifecycle registrations now take an optional second argument. Passing `{ key: 'my-app/grants' }` replaces whatever was previously registered under that key instead of appending beside it, which also does the right thing under HMR — the newest closure wins. Omitting the key preserves the previous append-every-time behaviour exactly, so no existing caller changes semantics. Two implementation details are deliberate. The handler list keeps holding bare handler functions, with the key map stored under its own symbol, because `Symbol.for` keys are process-global and an older copy of this package loaded as a nested dependency must still be able to dispatch the same list. And an unsubscribe returned by a superseded registration is a no-op rather than tearing down its replacement, which matters for callers like `@wabbit/tome-affiliates` that bundle several unsubscribes together. Found from a live double-grant: a consumer site granted two entitlement rows for one purchase on 2026-09-20 because its consumer-side guard against re-registering was a module-local `let` while the registry it guarded was global. `@wabbit/tome-affiliates` avoids the same trap only by hand-rolling its own globalThis-keyed entry around the register calls; the `key` option is that pattern made available to every consumer.

  • 40a0f22: Handler registration accepts an optional `key`, so a module evaluated in more than one bundle registers exactly one handler. Every economy dispatcher keeps its handler list on `globalThis` under a `Symbol.for(...)` key, because bundlers — notably Next.js — load the module in more than one server bundle. That is what stops registration writing into one copy while dispatch reads an empty other copy. The unaddressed half is that registration then also runs once per bundle, and a bare `push()` stacked a second copy of the same handler each time. The dispatchers fan out through `Promise.allSettled`, so the copies run concurrently, and any consumer handler doing a check-then-act (find, then create if absent) had every copy pass the check and write. The failure surfaces as a silent duplicate rather than an error. `registerOrderCompleteHandler`, `registerOrderRefundHandler`, and the four subscription lifecycle registrations now take an optional second argument. Passing `{ key: 'my-app/grants' }` replaces whatever was previously registered under that key instead of appending beside it, which also does the right thing under HMR — the newest closure wins. Omitting the key preserves the previous append-every-time behaviour exactly, so no existing caller changes semantics. Two implementation details are deliberate. The handler list keeps holding bare handler functions, with the key map stored under its own symbol, because `Symbol.for` keys are process-global and an older copy of this package loaded as a nested dependency must still be able to dispatch the same list. And an unsubscribe returned by a superseded registration is a no-op rather than tearing down its replacement, which matters for callers like `@wabbit/tome-affiliates` that bundle several unsubscribes together. Found from a live double-grant: a consumer site granted two entitlement rows for one purchase on 2026-09-20 because its consumer-side guard against re-registering was a module-local `let` while the registry it guarded was global. `@wabbit/tome-affiliates` avoids the same trap only by hand-rolling its own globalThis-keyed entry around the register calls; the `key` option is that pattern made available to every consumer.
v0.11.0minor

1553fde: **Four additive economy seams — the prerequisite PRs the `@wabbit/tome-affiliates` layer spec (2026-09-14, §9 a-d) needs to build on.** 1. **Checkout metadata passthrough.** `CheckoutSessionInput` gains `metadata?: Record<string, string>`. Reserved keys `orderId`, `productId`, `memberId`, `productType` (exported as `RESERVED_CHECKOUT_METADATA_KEYS`) are computed by the action itself; a caller-supplied key that collides with one now throws the new `ReservedCheckoutMetadataKeyError` before any Payload or adapter call, instead of silently colliding. The merged metadata reaches the adapter's session metadata (and so `payment_intent_data.metadata`) AND the pending Order's own `metadata` field, written at Order-creation time. `createSubscriptionCheckoutAction`'s `metadata` was already required and already passed arbitrary extra keys through, so it needed no change for the same affiliate-attribution use case. 2. **Paid-amount truth on Orders.** `Orders.totalCents`/`discountCents` were frozen at list price from `createCheckoutSessionAction`'s pending-Order write (`discountCents` hardcoded `0`) and never corrected once Stripe applied a promo. `createStripeWebhookHandler`'s `checkout.completed` branch now overwrites `totalCents` with the session's real `amount_total` on completion, plus two NEW Orders fields written when the session resolves them: `taxCents` (from `total_details.amount_tax`) and `promotionCodeRef` (the applied Stripe promotion code id, from `session.discounts[0].promotion_code`). `discountCents` is likewise overwritten from `total_details.amount_discount` when resolvable. This is also the fix for the refund branch's `isPartial` comparison, which reads the same `order.totalCents` field — no separate code change was needed there once it carries the real charged amount instead of list price. `WebhookEvent`'s `checkout.completed` member gains `subtotalCents`/`discountCents`/`taxCents`/`promotionCodeRef`, all optional (undefined on a hand-built event that doesn't carry them; every real Stripe session does). 3. **Order refund dispatcher.** NEW event bus mirroring the order-complete one exactly: `registerOrderRefundHandler(handler)`, `onOrderRefundDispatch(order)`, event shape `OrderRefundedEvent { orderId, userId, status: 'refunded' | 'partially-refunded', refundedCents, totalCents, refundedAt }`. No layer previously had a refund signal from economy; consumers reinvented their own `afterChange` watch. NEW Orders field `refundedCents` (default `0`) carries the provider's cumulative refund amount, written by the webhook handler's refund branch. The Orders `afterChange` hook dispatches when status is `refunded`/`partially-refunded` AND (the status just transitioned OR `refundedCents` increased) — the second arm makes a partial-then-full refund dispatch twice; the first arm alone makes a webhook redelivery reporting the same cumulative amount a no-op. 4. **Renewal amount on the renewal event.** `SubscriptionRenewedEvent` gains `amountCents?`, `currency?`, `paymentId?`. The webhook handler now writes the renewal Payment row BEFORE dispatching (previously: dispatch first, write after) so the dispatch can carry that row's id. `amountCents`/`currency` come straight from the source `WebhookEvent` and so are present whenever it resolved them, independent of the ledger write's own success; only `paymentId` is omitted if that write fails — the entitlement dispatch itself is never gated on it, preserving the existing "a ledger-write hiccup cannot turn a successful grant into a Stripe retry" guarantee. Consumer follow-up: none required — every change here is additive, and `OrderCompletedEvent` is unchanged (frozen contract, shared-contracts-v3 §4).

  • 1553fde: **Four additive economy seams — the prerequisite PRs the `@wabbit/tome-affiliates` layer spec (2026-09-14, §9 a-d) needs to build on.** 1. **Checkout metadata passthrough.** `CheckoutSessionInput` gains `metadata?: Record<string, string>`. Reserved keys `orderId`, `productId`, `memberId`, `productType` (exported as `RESERVED_CHECKOUT_METADATA_KEYS`) are computed by the action itself; a caller-supplied key that collides with one now throws the new `ReservedCheckoutMetadataKeyError` before any Payload or adapter call, instead of silently colliding. The merged metadata reaches the adapter's session metadata (and so `payment_intent_data.metadata`) AND the pending Order's own `metadata` field, written at Order-creation time. `createSubscriptionCheckoutAction`'s `metadata` was already required and already passed arbitrary extra keys through, so it needed no change for the same affiliate-attribution use case. 2. **Paid-amount truth on Orders.** `Orders.totalCents`/`discountCents` were frozen at list price from `createCheckoutSessionAction`'s pending-Order write (`discountCents` hardcoded `0`) and never corrected once Stripe applied a promo. `createStripeWebhookHandler`'s `checkout.completed` branch now overwrites `totalCents` with the session's real `amount_total` on completion, plus two NEW Orders fields written when the session resolves them: `taxCents` (from `total_details.amount_tax`) and `promotionCodeRef` (the applied Stripe promotion code id, from `session.discounts[0].promotion_code`). `discountCents` is likewise overwritten from `total_details.amount_discount` when resolvable. This is also the fix for the refund branch's `isPartial` comparison, which reads the same `order.totalCents` field — no separate code change was needed there once it carries the real charged amount instead of list price. `WebhookEvent`'s `checkout.completed` member gains `subtotalCents`/`discountCents`/`taxCents`/`promotionCodeRef`, all optional (undefined on a hand-built event that doesn't carry them; every real Stripe session does). 3. **Order refund dispatcher.** NEW event bus mirroring the order-complete one exactly: `registerOrderRefundHandler(handler)`, `onOrderRefundDispatch(order)`, event shape `OrderRefundedEvent { orderId, userId, status: 'refunded' | 'partially-refunded', refundedCents, totalCents, refundedAt }`. No layer previously had a refund signal from economy; consumers reinvented their own `afterChange` watch. NEW Orders field `refundedCents` (default `0`) carries the provider's cumulative refund amount, written by the webhook handler's refund branch. The Orders `afterChange` hook dispatches when status is `refunded`/`partially-refunded` AND (the status just transitioned OR `refundedCents` increased) — the second arm makes a partial-then-full refund dispatch twice; the first arm alone makes a webhook redelivery reporting the same cumulative amount a no-op. 4. **Renewal amount on the renewal event.** `SubscriptionRenewedEvent` gains `amountCents?`, `currency?`, `paymentId?`. The webhook handler now writes the renewal Payment row BEFORE dispatching (previously: dispatch first, write after) so the dispatch can carry that row's id. `amountCents`/`currency` come straight from the source `WebhookEvent` and so are present whenever it resolved them, independent of the ledger write's own success; only `paymentId` is omitted if that write fails — the entitlement dispatch itself is never gated on it, preserving the existing "a ledger-write hiccup cannot turn a successful grant into a Stripe retry" guarantee. Consumer follow-up: none required — every change here is additive, and `OrderCompletedEvent` is unchanged (frozen contract, shared-contracts-v3 §4).
  • 0c715fc: **Payouts extension — the provider-neutral payout seam the `@wabbit/tome-affiliates` layer spec (2026-09-14, §9 e) needs, generalized from the April vendor Payouts design.** Opt in with `createEconomyLayer({ payouts: true })` (default off; existing consumers are unaffected). Adds the `payout-accounts` and `payouts` collections with GDPR registration (`redact` and `retain`), a `PayoutProviderAdapter` interface with `ManualPayoutAdapter` and `StripeConnectPayoutAdapter` (v1 Connect account with Express dashboard and transfers capability, account-link onboarding, separate transfers from the platform balance with a request idempotency key, balance lookup, `account.updated` and `transfer.reversed` webhook mapping), `createPayoutWebhookHandler`, `runPayoutBatch` (released payouts first, per-payee minimum, waiting-payout guard, balance check before any write, first-payout review, failed attempts free their key for a later retry), and `getPayeeAnnualTotals`.
v0.10.1patch

e5c4acb: **Two webhook defects found by the first live purchase-and-refund on a consumer site (2026-09-13). Both affect every consumer of `createStripeWebhookHandler`.** 1. **Refunds never resolved their Order.** `StripeAdapter.handleWebhook` mapped `charge.refunded` to `orderId = charge.metadata.orderId`, but one-time Checkout stamps metadata on the Session only and Stripe never copies it to the PaymentIntent or Charge. Every refund therefore arrived with `orderId: ''`, the handler skipped the Payment and Order writes, returned 200, and the consumer's refund-revocation hooks (keyed on `status: 'refunded'`) never fired — a refunded buyer kept the entitlement. Fix, in the adapter: `createCheckoutSession` now stamps `payment_intent_data.metadata` (the payment-mode twin of the `subscription_data.metadata` mirror the subscription path always had), and the refund mapping resolves the order from the charge's own metadata, then the PaymentIntent's metadata, then the Checkout Session listed by `payment_intent` — so charges from sessions created before this release still resolve. An unresolvable charge still yields `''` and the same logged 200 as before, never a retry storm. 2. **Every paid order granted twice.** The handler's `payload.update({ status: 'completed' })` fires the Orders collection's afterChange hook, which calls `onOrderCompleteDispatch`; the handler then invoked the consumer's `onOrderComplete` extra hook — and both shipped consumers wire that hook to the same dispatcher, so every order-complete handler ran twice and each purchase produced two entitlement rows. The handler now recognises `onOrderComplete === onOrderCompleteDispatch`, skips it, and logs a warning naming the consumer fix (drop the line). `StripeWebhookHandlerConfig.onOrderComplete` is documented as site-level side effects only. Consumer follow-up: remove `onOrderComplete: onOrderCompleteDispatch` from the webhook route (the warning says so at runtime); no behaviour change beyond the two fixes. Tests: four refund-resolution cases on the adapter, a single-dispatch case on the handler, and a `payment_intent_data` stamp assertion on session creation.

  • e5c4acb: **Two webhook defects found by the first live purchase-and-refund on a consumer site (2026-09-13). Both affect every consumer of `createStripeWebhookHandler`.** 1. **Refunds never resolved their Order.** `StripeAdapter.handleWebhook` mapped `charge.refunded` to `orderId = charge.metadata.orderId`, but one-time Checkout stamps metadata on the Session only and Stripe never copies it to the PaymentIntent or Charge. Every refund therefore arrived with `orderId: ''`, the handler skipped the Payment and Order writes, returned 200, and the consumer's refund-revocation hooks (keyed on `status: 'refunded'`) never fired — a refunded buyer kept the entitlement. Fix, in the adapter: `createCheckoutSession` now stamps `payment_intent_data.metadata` (the payment-mode twin of the `subscription_data.metadata` mirror the subscription path always had), and the refund mapping resolves the order from the charge's own metadata, then the PaymentIntent's metadata, then the Checkout Session listed by `payment_intent` — so charges from sessions created before this release still resolve. An unresolvable charge still yields `''` and the same logged 200 as before, never a retry storm. 2. **Every paid order granted twice.** The handler's `payload.update({ status: 'completed' })` fires the Orders collection's afterChange hook, which calls `onOrderCompleteDispatch`; the handler then invoked the consumer's `onOrderComplete` extra hook — and both shipped consumers wire that hook to the same dispatcher, so every order-complete handler ran twice and each purchase produced two entitlement rows. The handler now recognises `onOrderComplete === onOrderCompleteDispatch`, skips it, and logs a warning naming the consumer fix (drop the line). `StripeWebhookHandlerConfig.onOrderComplete` is documented as site-level side effects only. Consumer follow-up: remove `onOrderComplete: onOrderCompleteDispatch` from the webhook route (the warning says so at runtime); no behaviour change beyond the two fixes. Tests: four refund-resolution cases on the adapter, a single-dispatch case on the handler, and a `payment_intent_data` stamp assertion on session creation.
v0.10.0minor

48048dd: **Subscriptions collection + subscription checkout, and an open payment-provider seam — the two prerequisite PRs the `@wabbit/tome-directory` layer spec (§14 a/b) needs to build on.** 1. **New `Subscriptions` collection + `createSubscriptionCheckoutAction`.** `createEconomyLayer` now includes a Subscriptions collection by default (`subscriptions: false` to opt out, or a `SubscriptionsCollectionConfig` to override slugs/relationship targets) with fields `provider`, `providerRef`, `customerRef`, `account`/`member` (both optional relationships), `price`, `status` (`active|trialing|past_due|canceled|expired`), `currentPeriodStart`/`currentPeriodEnd`, `cancelAtPeriodEnd`, `metadata`. `createSubscriptionCheckoutAction` is the recurring-billing counterpart of `createCheckoutSessionAction` — same required `auth` identity gate, same `CheckoutIdentityMismatchError` — routes a Price with `interval: 'month' | 'year'` through `adapter.createSubscriptionSession` (throwing the new `SubscriptionCheckoutUnsupportedError` for a one-time Price or an adapter without recurring-billing support), creates the Subscriptions row eagerly (mirroring the pending-Order-before-redirect pattern), and calls an optional `persistCustomerRef` callback so a consumer can save the provider customer id onto its own record. `createSubscriptionPortalAction` and `cancelSubscriptionAction` round out the surface, both feature-detecting the adapter's optional `createPortalSession`/`cancelSubscription`. 2. **`createStripeWebhookHandler`'s `subscription.renewed` branch now writes a renewal Payment row and upserts the Subscriptions row.** Previously that branch dispatched the entitlement events but created no ledger row at all, unlike `checkout.completed` — a monthly Price yielded a working entitlement grant but an invisible billing history. The upsert resolves the row by `providerRef` (a direct hit on every renewal after the first) and falls back to a `metadata.userId`/`metadata.productId` match (the first renewal, before `providerRef` is finalized from the checkout-session id to the real provider subscription id); a subscription provisioned outside this package's checkout action gets a best-effort row rather than a silently dropped event. `subscription.cancelled`/`subscription.payment_failed` now also update the row's `status`. Because a renewal has no Order, `Payments.order` is now optional and a new `Payments.subscription` relationship carries the link instead — existing one-time-checkout Payment rows are unaffected (that write path still always sets `order`). 3. **Provider seam opened up (Stripe's own policy disqualifies it for the launching vertical, so a second real adapter, `@wabbit/tome-economy-authnet`, was always coming).** `PaymentProvider` widens from the closed `'stripe' | 'free' | 'manual'` to `'stripe' | 'free' | 'manual' | 'authorizenet' | (string & {})`; the Orders/Payments/Subscriptions `provider` fields convert from a closed Payload `select` to `text` + an open `validate` (any non-empty string; known values are documented for the admin UI only — see `collections/commerce/providerField.ts`). `createStripeWebhookHandler` reads the signature header via `adapter.webhookSignatureHeader ?? 'stripe-signature'` (new optional `PaymentProviderAdapter` member) instead of hardcoding Stripe's header name, and every Payment write now stamps `adapter.provider` instead of a hardcoded `'stripe'`. `PaymentProviderAdapter` also gains optional `cancelSubscription(args)` and `refund(args)`, both implemented on `StripeAdapter` (the pre-existing `StripeAdapter.cancelSubscription(subscriptionId, atPeriodEnd)` positional signature is now the interface's `{ providerRef, atPeriodEnd? }` object shape — a breaking change to that one method's own signature, safe because nothing in this repo called it yet). 4. **`@wabbit/tome-core` is now a REQUIRED peer.** The "genuinely optional" posture this package documented through 0.9.0 no longer held even before this change — `createCheckoutSessionAction.ts` already carried a static, module-scope `import { resolveMemberFromSession } from '@wabbit/tome-core/identity'`, so any consumer using that action already required core at runtime regardless of what `peerDependenciesMeta` claimed. `initEconomy` now imports `registerLayer` statically (its `try/catch` exists only for the "already registered" HMR/repeat-call case, matching every sibling `initXLayer`), and `createEconomyLayer` now actually applies the shared `access`/`hooks`/`extraFields`/`fieldOverrides`/`omitFields`/`fieldOrder` vocabulary from `@wabbit/tome-core/utilities/layerFactoryConfig` to every collection it returns — previously accepted on `EconomyLayerConfig` but explicitly documented as inert pending this exact trigger. `access/adminGate.ts`'s own promotion trigger ("the day core becomes a required peer") has therefore fired, but delegating that gate's implementation to core's `sessionHasCapabilityOrLegacyAdmin` primitive is a separate, larger change this PR deliberately does not bundle — recorded in that file's header rather than half-done silently. Consumer follow-ups: a site wiring `@wabbit/tome-accounts`'s forthcoming `billing` group (prerequisite PR (c)) as `persistCustomerRef` gets provider-agnostic customer-ref persistence on first subscription checkout for free. `@wabbit/tome-economy-authnet` (prerequisite PR (b)'s stated reason for the provider seam) can now implement `PaymentProviderAdapter` in full, including `webhookSignatureHeader: 'X-ANET-Signature'` and `cancelSubscription`/`refund`.

  • 48048dd: **Subscriptions collection + subscription checkout, and an open payment-provider seam — the two prerequisite PRs the `@wabbit/tome-directory` layer spec (§14 a/b) needs to build on.** 1. **New `Subscriptions` collection + `createSubscriptionCheckoutAction`.** `createEconomyLayer` now includes a Subscriptions collection by default (`subscriptions: false` to opt out, or a `SubscriptionsCollectionConfig` to override slugs/relationship targets) with fields `provider`, `providerRef`, `customerRef`, `account`/`member` (both optional relationships), `price`, `status` (`active|trialing|past_due|canceled|expired`), `currentPeriodStart`/`currentPeriodEnd`, `cancelAtPeriodEnd`, `metadata`. `createSubscriptionCheckoutAction` is the recurring-billing counterpart of `createCheckoutSessionAction` — same required `auth` identity gate, same `CheckoutIdentityMismatchError` — routes a Price with `interval: 'month' | 'year'` through `adapter.createSubscriptionSession` (throwing the new `SubscriptionCheckoutUnsupportedError` for a one-time Price or an adapter without recurring-billing support), creates the Subscriptions row eagerly (mirroring the pending-Order-before-redirect pattern), and calls an optional `persistCustomerRef` callback so a consumer can save the provider customer id onto its own record. `createSubscriptionPortalAction` and `cancelSubscriptionAction` round out the surface, both feature-detecting the adapter's optional `createPortalSession`/`cancelSubscription`. 2. **`createStripeWebhookHandler`'s `subscription.renewed` branch now writes a renewal Payment row and upserts the Subscriptions row.** Previously that branch dispatched the entitlement events but created no ledger row at all, unlike `checkout.completed` — a monthly Price yielded a working entitlement grant but an invisible billing history. The upsert resolves the row by `providerRef` (a direct hit on every renewal after the first) and falls back to a `metadata.userId`/`metadata.productId` match (the first renewal, before `providerRef` is finalized from the checkout-session id to the real provider subscription id); a subscription provisioned outside this package's checkout action gets a best-effort row rather than a silently dropped event. `subscription.cancelled`/`subscription.payment_failed` now also update the row's `status`. Because a renewal has no Order, `Payments.order` is now optional and a new `Payments.subscription` relationship carries the link instead — existing one-time-checkout Payment rows are unaffected (that write path still always sets `order`). 3. **Provider seam opened up (Stripe's own policy disqualifies it for the launching vertical, so a second real adapter, `@wabbit/tome-economy-authnet`, was always coming).** `PaymentProvider` widens from the closed `'stripe' | 'free' | 'manual'` to `'stripe' | 'free' | 'manual' | 'authorizenet' | (string & {})`; the Orders/Payments/Subscriptions `provider` fields convert from a closed Payload `select` to `text` + an open `validate` (any non-empty string; known values are documented for the admin UI only — see `collections/commerce/providerField.ts`). `createStripeWebhookHandler` reads the signature header via `adapter.webhookSignatureHeader ?? 'stripe-signature'` (new optional `PaymentProviderAdapter` member) instead of hardcoding Stripe's header name, and every Payment write now stamps `adapter.provider` instead of a hardcoded `'stripe'`. `PaymentProviderAdapter` also gains optional `cancelSubscription(args)` and `refund(args)`, both implemented on `StripeAdapter` (the pre-existing `StripeAdapter.cancelSubscription(subscriptionId, atPeriodEnd)` positional signature is now the interface's `{ providerRef, atPeriodEnd? }` object shape — a breaking change to that one method's own signature, safe because nothing in this repo called it yet). 4. **`@wabbit/tome-core` is now a REQUIRED peer.** The "genuinely optional" posture this package documented through 0.9.0 no longer held even before this change — `createCheckoutSessionAction.ts` already carried a static, module-scope `import { resolveMemberFromSession } from '@wabbit/tome-core/identity'`, so any consumer using that action already required core at runtime regardless of what `peerDependenciesMeta` claimed. `initEconomy` now imports `registerLayer` statically (its `try/catch` exists only for the "already registered" HMR/repeat-call case, matching every sibling `initXLayer`), and `createEconomyLayer` now actually applies the shared `access`/`hooks`/`extraFields`/`fieldOverrides`/`omitFields`/`fieldOrder` vocabulary from `@wabbit/tome-core/utilities/layerFactoryConfig` to every collection it returns — previously accepted on `EconomyLayerConfig` but explicitly documented as inert pending this exact trigger. `access/adminGate.ts`'s own promotion trigger ("the day core becomes a required peer") has therefore fired, but delegating that gate's implementation to core's `sessionHasCapabilityOrLegacyAdmin` primitive is a separate, larger change this PR deliberately does not bundle — recorded in that file's header rather than half-done silently. Consumer follow-ups: a site wiring `@wabbit/tome-accounts`'s forthcoming `billing` group (prerequisite PR (c)) as `persistCustomerRef` gets provider-agnostic customer-ref persistence on first subscription checkout for free. `@wabbit/tome-economy-authnet` (prerequisite PR (b)'s stated reason for the provider seam) can now implement `PaymentProviderAdapter` in full, including `webhookSignatureHeader: 'X-ANET-Signature'` and `cancelSubscription`/`refund`.
v0.9.0minor

7d0949f4: **Three additive checkout/webhook seams, surfaced by the first non-LMS consumer (2026-09-04).** 1. **`items[].productType` is now stamped at checkout.** Orders has declared the field since 0.3.0 and `onOrderCompleteDispatch` reads it, but `createCheckoutSessionAction` never wrote it, so every non-course product was dispatched to order-complete handlers as `'course'` and consumers had to re-stamp the line item themselves. The action now denormalises the catalog product's `type` onto the line item and into the provider session metadata (`metadata.productType`). Products with no `type` are unchanged (the key is omitted, never written empty). 2. **Promotion codes and site-applied discounts pass through to Stripe Checkout.** `CheckoutSessionInput` and `CreateCheckoutSessionArgs` gain optional `allowPromotionCodes?: boolean` (Stripe `allow_promotion_codes`) and `discounts?: Array<{ coupon?: string; promotionCode?: string }>` (Stripe `discounts`). Both are absent from the adapter call and the Stripe request on the default path, so existing sessions are byte-identical. Stripe forbids the two keys together: when both are given, `discounts` is applied and the code box is suppressed for that session. `FreeAdapter` ignores both. 3. **`createStripeWebhookHandler` gains `onSetupCompleted`.** The adapter has emitted `setup.completed` (a `mode: 'setup'` Checkout Session: card saved, nothing charged) since 0.6.0, but the handler dropped it, and wabbit-site-core worked around that by classifying a cloned request ahead of the handler. The handler now calls `onSetupCompleted(event)` when configured. It writes nothing itself (there is no Order or Payment for a setup session; the consumer owns the pledge row), a throwing hook is logged and the route still answers 200, and with no hook registered the event is acknowledged and ignored exactly as before. The event type is exported as `SetupCompletedWebhookEvent`. Consumer follow-ups: wabbit-site-core can retire the request-clone peek in `src/app/api/webhooks/stripe/route.ts` by passing `onSetupCompleted: (e) => handleSetupCompleted(payload, e)`; the other consumer can drop its consumer-side `items[].productType` re-stamp and pass `allowPromotionCodes: true` for the founder/early-bird codes.

  • 7d0949f4: **Three additive checkout/webhook seams, surfaced by the first non-LMS consumer (2026-09-04).** 1. **`items[].productType` is now stamped at checkout.** Orders has declared the field since 0.3.0 and `onOrderCompleteDispatch` reads it, but `createCheckoutSessionAction` never wrote it, so every non-course product was dispatched to order-complete handlers as `'course'` and consumers had to re-stamp the line item themselves. The action now denormalises the catalog product's `type` onto the line item and into the provider session metadata (`metadata.productType`). Products with no `type` are unchanged (the key is omitted, never written empty). 2. **Promotion codes and site-applied discounts pass through to Stripe Checkout.** `CheckoutSessionInput` and `CreateCheckoutSessionArgs` gain optional `allowPromotionCodes?: boolean` (Stripe `allow_promotion_codes`) and `discounts?: Array<{ coupon?: string; promotionCode?: string }>` (Stripe `discounts`). Both are absent from the adapter call and the Stripe request on the default path, so existing sessions are byte-identical. Stripe forbids the two keys together: when both are given, `discounts` is applied and the code box is suppressed for that session. `FreeAdapter` ignores both. 3. **`createStripeWebhookHandler` gains `onSetupCompleted`.** The adapter has emitted `setup.completed` (a `mode: 'setup'` Checkout Session: card saved, nothing charged) since 0.6.0, but the handler dropped it, and wabbit-site-core worked around that by classifying a cloned request ahead of the handler. The handler now calls `onSetupCompleted(event)` when configured. It writes nothing itself (there is no Order or Payment for a setup session; the consumer owns the pledge row), a throwing hook is logged and the route still answers 200, and with no hook registered the event is acknowledged and ignored exactly as before. The event type is exported as `SetupCompletedWebhookEvent`. Consumer follow-ups: wabbit-site-core can retire the request-clone peek in `src/app/api/webhooks/stripe/route.ts` by passing `onSetupCompleted: (e) => handleSetupCompleted(payload, e)`; the other consumer can drop its consumer-side `items[].productType` re-stamp and pass `allowPromotionCodes: true` for the founder/early-bird codes.
v0.8.0minor

670d2a1: **Breaking (0.x) — `createCheckoutSessionAction`'s returned action now takes a required second argument.** The action creates Orders with `overrideAccess: true` and previously trusted the `memberId`/`memberEmail` it was handed. The auth contract lived only in a JSDoc usage example, so a consumer that forgot to resolve the session — or resolved it and then passed a client-supplied id — shipped an IDOR: any signed-in customer could mint a pending Order against another member, with that member's id carried into the payment provider's metadata. A comment cannot fail a build, so the check is now at runtime. The returned function is `action(input, auth)`. `auth` is `{ user }` — pass a Payload `req.user`-like object verbatim. Before the Order is written or the adapter called, the action resolves the session user's **member row** (via `@wabbit/tome-core/identity`'s `resolveMemberFromSession`, one `find` on the members collection) and asserts that row is `input.memberId`. A user id is never compared to a member id: they are different collections, and every shipping consumer — tome-starter, wabbit-site-core — passes a members-row id. `input.memberEmail` must match the session's email or the resolved member's, case-insensitively. A failure throws the new exported `CheckoutIdentityMismatchError` (`.reason` is `'missing-auth' | 'no-member' | 'id-mismatch' | 'email-mismatch'`, `.code` is `'checkout-identity-mismatch'`); map it to a 403. Writes keep `overrideAccess: true` — that bypass is now safe precisely because the caller's identity is proven rather than assumed. Consumer edit — in your `'use server'` wrapper, resolve the session server-side and stop taking the member from the client: ```diff - export async function enrollAction(productId: string, memberId: string, memberEmail: string) { + export async function enrollAction(productId: string) { const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: await headers() }) + if (!user) throw new Error('Not signed in') + // orders.customer -> members. Resolve the buyer's member row from the + // session; the action re-derives it independently and must agree. + const member = (await payload.find({ + collection: 'members', where: { user: { equals: user.id } }, limit: 1, depth: 0, overrideAccess: true, + })).docs[0] + if (!member) throw new Error('No member profile for this account') const action = createCheckoutSessionAction({ adapter, payload, baseUrl }) - return action({ productId, memberId, memberEmail }) + return action( + { productId, memberId: String(member.id), memberEmail: user.email }, + { user }, + ) } ``` A genuinely anonymous flow (guest checkout, or a server-to-server job that authorised the purchase upstream) opts out with `{ allowUnauthenticatedCaller: true }`, which is documented as dangerous and does NOT relax the id/email assertions when a `user` is present. New exports: `CheckoutIdentityMismatchError`, `CheckoutCallerIdentity`, `CheckoutIdentityFailureReason`. Also: the `TODO(post-v0): add pending-order expiry mechanism (Risk R4)` is now a stated accepted risk with explicit build triggers (pending Orders accumulating in production, a second write path creating pending Orders, or checkout exposed to unauthenticated callers) instead of an open-ended TODO. No implementation change. Tests: 15 assertions covering a user whose member row is the memberId (ids differ across collections), a user with no member row, id mismatch (throws before any read, write or adapter call), missing `auth`, absent session, the explicit unauthenticated opt-in, and the opt-in NOT overriding a present-but-wrong user.

  • 670d2a1: **Breaking (0.x) — `createCheckoutSessionAction`'s returned action now takes a required second argument.** The action creates Orders with `overrideAccess: true` and previously trusted the `memberId`/`memberEmail` it was handed. The auth contract lived only in a JSDoc usage example, so a consumer that forgot to resolve the session — or resolved it and then passed a client-supplied id — shipped an IDOR: any signed-in customer could mint a pending Order against another member, with that member's id carried into the payment provider's metadata. A comment cannot fail a build, so the check is now at runtime. The returned function is `action(input, auth)`. `auth` is `{ user }` — pass a Payload `req.user`-like object verbatim. Before the Order is written or the adapter called, the action resolves the session user's **member row** (via `@wabbit/tome-core/identity`'s `resolveMemberFromSession`, one `find` on the members collection) and asserts that row is `input.memberId`. A user id is never compared to a member id: they are different collections, and every shipping consumer — tome-starter, wabbit-site-core — passes a members-row id. `input.memberEmail` must match the session's email or the resolved member's, case-insensitively. A failure throws the new exported `CheckoutIdentityMismatchError` (`.reason` is `'missing-auth' | 'no-member' | 'id-mismatch' | 'email-mismatch'`, `.code` is `'checkout-identity-mismatch'`); map it to a 403. Writes keep `overrideAccess: true` — that bypass is now safe precisely because the caller's identity is proven rather than assumed. Consumer edit — in your `'use server'` wrapper, resolve the session server-side and stop taking the member from the client: ```diff - export async function enrollAction(productId: string, memberId: string, memberEmail: string) { + export async function enrollAction(productId: string) { const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: await headers() }) + if (!user) throw new Error('Not signed in') + // orders.customer -> members. Resolve the buyer's member row from the + // session; the action re-derives it independently and must agree. + const member = (await payload.find({ + collection: 'members', where: { user: { equals: user.id } }, limit: 1, depth: 0, overrideAccess: true, + })).docs[0] + if (!member) throw new Error('No member profile for this account') const action = createCheckoutSessionAction({ adapter, payload, baseUrl }) - return action({ productId, memberId, memberEmail }) + return action( + { productId, memberId: String(member.id), memberEmail: user.email }, + { user }, + ) } ``` A genuinely anonymous flow (guest checkout, or a server-to-server job that authorised the purchase upstream) opts out with `{ allowUnauthenticatedCaller: true }`, which is documented as dangerous and does NOT relax the id/email assertions when a `user` is present. New exports: `CheckoutIdentityMismatchError`, `CheckoutCallerIdentity`, `CheckoutIdentityFailureReason`. Also: the `TODO(post-v0): add pending-order expiry mechanism (Risk R4)` is now a stated accepted risk with explicit build triggers (pending Orders accumulating in production, a second write path creating pending Orders, or checkout exposed to unauthenticated callers) instead of an open-ended TODO. No implementation change. Tests: 15 assertions covering a user whose member row is the memberId (ids differ across collections), a user with no member row, id mismatch (throws before any read, write or adapter call), missing `auth`, absent session, the explicit unauthenticated opt-in, and the opt-in NOT overriding a present-but-wrong user.
  • 0836ef5: Admin gate → core primitive. "Is this user an admin?" was answered five incompatible ways across the platform (2026-09-01 sale-readiness audit §5.2); these four packages carried a deliberate clone of the same pre-`can()` role-string check, crowdfund's and fulfillment's headers both saying "matching economy verbatim". No behaviour change is intended for the legacy path, and tests pin it rather than prose asserting it. **crowdfund, fulfillment, rpg** now call `sessionHasCapabilityOrLegacyAdmin()` from `@wabbit/tome-core/auth/repScoping`, and compose the owner-scoped WHERE through `ownershipOrBypass()` from `@wabbit/tome-core/access`. All three declare `@wabbit/tome-core` as a required, explicitly non-optional peer, so these are plain static imports. The legacy path is unchanged: a `roles` array containing 'admin' is an admin, an unauthenticated request is not, and a non-admin session still resolves to `{ [ownerField]: { equals: user.id } }`. Deliberately widened: capability grants (`crowdfund:admin` / `fulfillment:admin` / `rpg:admin`) and core's `superadmin` / `super-admin` legacy aliases now pass too — the point of adopting the shared primitive. The Access functions are async now; Payload's `Access` type has always allowed a `Promise`, and the capability path needs an await. `hasAdminRole` stays exported from crowdfund and fulfillment as a `@deprecated` back-compat shim with byte-identical semantics; `CROWDFUND_ADMIN_CAPABILITY` and `FULFILLMENT_ADMIN_CAPABILITY` are new named exports. **economy** deliberately does NOT adopt the core primitive, and the reason is a constraint rather than an oversight: `@wabbit/tome-core` is a declared OPTIONAL peer here, the README states in two places that core is genuinely optional, and the only core reference in `src/` is a guarded lazy `require()` in `initEconomy`. A static import of a core access primitive from a collection factory would silently convert that optional peer into a required one. Instead the ten inline checks across `Orders` (×3), `Payments` (×2), `Prices` (×4) and `VendorEarnings` (×1) collapse into one internal `isEconomyAdmin()` in `src/access/adminGate.ts`, implementation moved not rewritten, with a written promotion trigger: the day core becomes a required peer of this package, delete the body and delegate. Orders' unique extra `req.user.collection === 'users'` condition is preserved exactly and pinned by a test. New suites: `crowdfund/tests/access.test.ts` (14), `fulfillment/tests/access.test.ts` (16), `economy/tests/admin-gate.test.ts` (11). Existing `collections.test.ts` assertions in crowdfund and fulfillment were updated to await the now-async access results — asserted VALUES unchanged. rpg has no test harness, so its change is covered by typecheck only. The forcing function ships with the consolidation: `eslint.config.mjs` gains a `no-restricted-syntax` warn-ratchet banning hand-rolled `.roles.includes(...)` admin checks in `access/**`, `collections/**` and `*access*.ts`, pointing at `sessionHasCapabilityOrLegacyAdmin` / `can`. The repo-wide count is 0 (down from 13), with three written `eslint-disable` exemptions: the two deprecated back-compat exports and economy's single gate.
  • 4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
v0.7.0minor

Track C I3: opt-in catalog-product-variants collection (default OFF — bare createCatalogLayer() is unchanged; enable via variants: true|{slug}, the entitlements-style knob) with SKU-axis options, semantic-duplicate hook, weight/dimensions. Economy Prices gain an optional variantSku TEXT field (advisory by design, non-unique); the checkout price query is untouched and now test-pinned.

  • Track C I3: opt-in catalog-product-variants collection (default OFF — bare createCatalogLayer() is unchanged; enable via variants: true|{slug}, the entitlements-style knob) with SKU-axis options, semantic-duplicate hook, weight/dimensions. Economy Prices gain an optional variantSku TEXT field (advisory by design, non-unique); the checkout price query is untouched and now test-pinned.
v0.6.0minor

Optional adapter methods for crowdfund settlement (B2 precedent — additive interface members): createSetupSession (hosted Checkout mode:'setup'), chargeSavedPaymentMethod (off-session PaymentIntent with Stripe idempotency-key request option; declined/SCA failures returned as typed results), releaseSavedPaymentMethod. WebhookEvent union gains setup.completed, discriminated on session mode — mode:'payment'/'subscription' mappings pinned field-for-field unchanged.

  • Optional adapter methods for crowdfund settlement (B2 precedent — additive interface members): createSetupSession (hosted Checkout mode:'setup'), chargeSavedPaymentMethod (off-session PaymentIntent with Stripe idempotency-key request option; declined/SCA failures returned as typed results), releaseSavedPaymentMethod. WebhookEvent union gains setup.completed, discriminated on session mode — mode:'payment'/'subscription' mappings pinned field-for-field unchanged.
  • 1df8cf0: Track C I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict wired into build; assert-node-loadable preflight added — both dists now raw-Node loadable, PASS 2/2). Stale registerLayer versions corrected (catalog said 1.1.1 at 1.4.0; economy said 0.2.3 at 0.5.0) and test-pinned to package.json so future bumps can't silently drift. Economy gains its vitest harness (first tests in the package — the settlement logic landing in I1 requires it).
v0.5.0minor

6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.

  • 6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
  • 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
v0.4.2patch

Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).

  • Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).
v0.4.1patch

bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.

  • bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
v0.4.0minor

Add an opt-in `subscription.payment_failed` dispatch seam for dunning. New exports: `registerSubscriptionPaymentFailedHandler`, `onSubscriptionPaymentFailedDispatch`, and the `SubscriptionPaymentFailedEvent` / `SubscriptionPaymentFailedHandler` types. The Stripe webhook handler now dispatches failed renewal charges to registered consumer handlers (in addition to the existing observability log) instead of dropping them, so a consumer can flag the billing record past_due, alert the team, and email the customer. No-ops when no handler is registered, so existing consumers are unaffected.

  • Add an opt-in `subscription.payment_failed` dispatch seam for dunning. New exports: `registerSubscriptionPaymentFailedHandler`, `onSubscriptionPaymentFailedDispatch`, and the `SubscriptionPaymentFailedEvent` / `SubscriptionPaymentFailedHandler` types. The Stripe webhook handler now dispatches failed renewal charges to registered consumer handlers (in addition to the existing observability log) instead of dropping them, so a consumer can flag the billing record past_due, alert the team, and email the customer. No-ops when no handler is registered, so existing consumers are unaffected.
v0.3.1patch

StripeAdapter: resolve the subscription id on `invoice.paid` / `invoice.payment_failed` across Stripe API shapes. The handler previously read only the top-level `invoice.subscription` field, which Stripe removed in API `2025-03-31.basil` (the SDK is pinned to `2026-04-22.dahlia`). On a Basil+ webhook payload the id resolved to `''`, the metadata fallback never ran, and every subscription lifecycle event silently skipped (no entitlement grant, no billing record). Now reads `invoice.parent.subscription_details.{subscription,metadata}` and the per-line `parent.subscription_item_details.subscription`, falling back to the legacy field — correct regardless of the webhook endpoint's pinned API version.

  • StripeAdapter: resolve the subscription id on `invoice.paid` / `invoice.payment_failed` across Stripe API shapes. The handler previously read only the top-level `invoice.subscription` field, which Stripe removed in API `2025-03-31.basil` (the SDK is pinned to `2026-04-22.dahlia`). On a Basil+ webhook payload the id resolved to `''`, the metadata fallback never ran, and every subscription lifecycle event silently skipped (no entitlement grant, no billing record). Now reads `invoice.parent.subscription_details.{subscription,metadata}` and the per-line `parent.subscription_item_details.subscription`, falling back to the legacy field — correct regardless of the webhook endpoint's pinned API version.
v0.3.0minor

61af0ea: Add subscription / recurring-billing support to the Stripe provider and webhook handler. New optional adapter methods `createSubscriptionSession` and `createPortalSession`, plus subscription/customer helpers; the webhook handler now maps the subscription lifecycle (`invoice.paid` -> renewed, `customer.subscription.updated(cancel_at_period_end)` + `.deleted` -> cancelled, `invoice.payment_failed` -> payment_failed) and dispatches via a new `onSubscriptionComplete` event bus. Adds `Prices.interval` (one-time / month / year) and `Orders.items[].productType`. Fully additive — one-time Checkout and the existing order/webhook path are unchanged.

  • 61af0ea: Add subscription / recurring-billing support to the Stripe provider and webhook handler. New optional adapter methods `createSubscriptionSession` and `createPortalSession`, plus subscription/customer helpers; the webhook handler now maps the subscription lifecycle (`invoice.paid` -> renewed, `customer.subscription.updated(cancel_at_period_end)` + `.deleted` -> cancelled, `invoice.payment_failed` -> payment_failed) and dispatches via a new `onSubscriptionComplete` event bus. Adds `Prices.interval` (one-time / month / year) and `Orders.items[].productType`. Fully additive — one-time Checkout and the existing order/webhook path are unchanged.
v0.2.7patch

935ce94: Fix: the order-complete handler registry is now a `globalThis`-keyed singleton so handler registration (typically in Payload `onInit`) and `onOrderCompleteDispatch` always share one list. Previously the registry was a module-local array; bundlers (notably Next.js) can load the module in more than one server bundle — e.g. the `onInit`/server-action context vs. an API route-handler bundle — so a webhook route would dispatch against an empty registry and silently drop every order completion (no enrollment or proposal-payment handler ran, even though the order/payment rows were written). `onOrderCompleteDispatch` now also logs a warning instead of returning silently when it dispatches with zero handlers, so this class of misconfiguration can never fail silently again.

  • 935ce94: Fix: the order-complete handler registry is now a `globalThis`-keyed singleton so handler registration (typically in Payload `onInit`) and `onOrderCompleteDispatch` always share one list. Previously the registry was a module-local array; bundlers (notably Next.js) can load the module in more than one server bundle — e.g. the `onInit`/server-action context vs. an API route-handler bundle — so a webhook route would dispatch against an empty registry and silently drop every order completion (no enrollment or proposal-payment handler ran, even though the order/payment rows were written). `onOrderCompleteDispatch` now also logs a warning instead of returning silently when it dispatches with zero handlers, so this class of misconfiguration can never fail silently again.
v0.2.6patch

4b2f368: Platform-wide peer-range sweep: every `workspace:*`/`workspace:^` entry in `peerDependencies` replaced with an explicit semver range (`@wabbit/tome-core >=1.0.0 <2.0.0`, `tome-ui >=0.9.0 <1.0.0`, `tome-motion >=0.2.0 <1.0.0`, `tome-catalog >=1.1.0 <2.0.0`, `tome-admin >=0.5.0 <1.0.0`; `tome-crm` ranges standardized to `>=0.2.0 <1.0.0`). The workspace protocol publishes as an **exact-version pin**, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring `tome-crm@0.2.0` exactly. devDependencies keep `workspace:*` for the local link. (`@wabbit/tome-admin-pro` got the same source fix but is rc-versioned; it carries the change on its next intentional release.) tome-crm additionally gains a once-per-process **production warning when the capability-registry fallback grants access** — the bootstrap heuristic (any authenticated user passes `crm:read`) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item). Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (`@wabbit/tome-print >=0.1.0 <1.0.0`); tome-intake declares its lazy catalog routing strategy (`@wabbit/tome-catalog >=1.1.0 <2.0.0`, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.

  • 4b2f368: Platform-wide peer-range sweep: every `workspace:*`/`workspace:^` entry in `peerDependencies` replaced with an explicit semver range (`@wabbit/tome-core >=1.0.0 <2.0.0`, `tome-ui >=0.9.0 <1.0.0`, `tome-motion >=0.2.0 <1.0.0`, `tome-catalog >=1.1.0 <2.0.0`, `tome-admin >=0.5.0 <1.0.0`; `tome-crm` ranges standardized to `>=0.2.0 <1.0.0`). The workspace protocol publishes as an **exact-version pin**, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring `tome-crm@0.2.0` exactly. devDependencies keep `workspace:*` for the local link. (`@wabbit/tome-admin-pro` got the same source fix but is rc-versioned; it carries the change on its next intentional release.) tome-crm additionally gains a once-per-process **production warning when the capability-registry fallback grants access** — the bootstrap heuristic (any authenticated user passes `crm:read`) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item). Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (`@wabbit/tome-print >=0.1.0 <1.0.0`); tome-intake declares its lazy catalog routing strategy (`@wabbit/tome-catalog >=1.1.0 <2.0.0`, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.
v0.2.5patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
v0.2.4patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
v1.0.0patch

Updated dependencies - @wabbit/tome-core@0.2.0

  • Updated dependencies - @wabbit/tome-core@0.2.0