Economy
CapabilitiesTome 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.
npm install @wabbit/tome-economyOverview
@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 (2026-09-06, prerequisite PR (b) — see Server / client posture for why the prior "genuinely optional" posture no longer held).
Install
pnpm add @wabbit/tome-economyPeer 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 |
60-second quickstart
Like @wabbit/tome-catalog, economy now has a top-level layer entry, createEconomyLayer(config?) — canonical per R4 ruling #3 (2026-07). 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 (R4 ruling #3, 2026-07); 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) | | 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 | | Subscription actions | createSubscriptionCheckoutAction (+CreateSubscriptionCheckoutActionConfig, SubscriptionCheckoutInput), createSubscriptionPortalAction (+CreateSubscriptionPortalActionConfig, SubscriptionPortalInput), cancelSubscriptionAction (+CancelSubscriptionActionConfig, CancelSubscriptionInput), SubscriptionCheckoutUnsupportedError | | Types | EconomyConfig, Order, OrderItem, OrderStatus, Payment, PaymentStatus, PaymentProvider, Price, Subscription, SubscriptionStatus, CheckoutSessionResult, OrderCompletedEvent, OrderCompleteHandler, ECONOMY_DEFAULT_SLUGS |
Subscriptions (prerequisite PR (a))
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.
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 now a REQUIRED peer (2026-09-06, prerequisite PR (b)) — 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 (prerequisite PR (b))
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'sproviderid writes cleanly with zero schema change here.PaymentProvideris the matching open type ('stripe' | 'free' | 'manual' | 'authorizenet' | (string & {})). - `adapter.webhookSignatureHeader?: string` —
createStripeWebhookHandlerreadsadapter.webhookSignatureHeader ?? 'stripe-signature'instead of hardcoding Stripe's header name, so a processor with its own signature header (Authorize.net'sX-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).StripeAdapterimplements both. - Every Payment/Order write in this package now stamps
adapter.provider— the webhook handler'scheckout.completedandsubscription.renewedbranches no longer hardcode'stripe'.
Links
- Design spec:
docs/superpowers/specs/2026-04-07-tome-economy-layer-design.md - v0 plan:
docs/superpowers/specs/2026-04-26-tome-economy-v0-plan.md - Subscription extension:
docs/superpowers/specs/2026-06-05-tome-economy-subscription-extension-design.md - Directory layer design (prerequisite PRs (a)/(b)):
docs/superpowers/specs/2026-09-06-tome-directory-layer-design.md§14 - CHANGELOG
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
e5c4acb: **Two webhook defects found by the first live purchase-and-refund on 6DoF Academy (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 (6dof-academy, wabbit-site-core) 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 6DoF Academy (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 (6dof-academy, wabbit-site-core) 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.
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 (directory spec §7.3 — 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 (directory spec §7.3 — 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`.
7d0949f4: **Three additive checkout/webhook seams, surfaced by the first non-LMS consumer (6DoF Academy, 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)`; 6DoF 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 (6DoF Academy, 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)`; 6DoF can drop its consumer-side `items[].productType` re-stamp and pass `allowPromotionCodes: true` for the founder/early-bird codes.
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.
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.
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).
6bc419c: R4 rulings #2 + #3 (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: R4 rulings #2 + #3 (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.
Admin label polish + formatted commerce money columns (PR #208): 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 (PR #208): 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).
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.
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.
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.
61af0ea: Add subscription / recurring-billing support to the Stripe provider and webhook handler (B2 subscription-extension spec §4–§9). 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 (B2 subscription-extension spec §4–§9). 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.
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.
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.
Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
- Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
Updated dependencies [36dc023]
- Updated dependencies [36dc023]
- Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
Updated dependencies - @wabbit/tome-core@0.2.0
- Updated dependencies - @wabbit/tome-core@0.2.0