Documentation menu

Documentation / Commerce engine / economy

Economy

Package
@wabbit/tome-economy
Version
v0.19.2
npm install @wabbit/tome-economy
First install from our registry?
  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
Commerce engineStable

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.

Overview

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 the repo's ARCHITECTURE.md § The Layer Model). @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).

Peer dependencies

Peer ranges, copied from package.json:

PeerRangeOptional?
payload>=3.67.0no
@payloadcms/richtext-lexical>=3.67.0no
@wabbit/tome-core>=1.14.0 <2.0.0no
stripe>=14.0.0yes
lucide-react>=0.460.0yes
@payloadcms/ui>=3.67.0yes — the admin view only
react>=19.0.0yes — the admin view only

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

The main barrel (.) carries everything below; @wabbit/tome-economy/giving (side-effect-free money rules, see Custom amounts and guest checkout), @wabbit/tome-economy/admin and @wabbit/tome-economy/scripts are the other subpaths. Notably, createStripeWebhookHandler and createCheckoutSessionAction (below) ship from the main barrel, not a @wabbit/tome-economy/server subpath, unlike crm/deals/marketing's server-only split.

GroupExports
Layer entrycreateEconomyLayer(config?), EconomyLayerConfig — canonical; returns CollectionConfig[], matching the createOrgLayer/createAccountsLayer bare-array shape
Collection factoriescreateOrdersCollection, 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 registrationinitEconomy(config?), InitEconomyConfig — void-returning, registration-only; unchanged contract, now called internally by createEconomyLayer too
Provider adaptersStripeAdapter, createStripeAdapter (+StripeAdapterConfig), FreeAdapter, createFreeAdapter, PaymentProviderAdapter interface (+ CheckoutLineItem, CreateCheckoutSessionArgs, WebhookEvent, CreateSubscriptionSessionArgs, SubscriptionSessionResult; saved-card types CreateSetupSessionArgs, SetupSessionResult, ChargeSavedPaymentMethodArgs, ChargeSavedPaymentMethodResult, ChargeFailureCode, ReleaseSavedPaymentMethodResult — see Saved payment methods; cart types CheckoutShippingOption, ProviderCheckoutSession — see Cart checkout)
Event busonOrderComplete, registerOrderCompleteHandler, onOrderCompleteDispatch; subscription-lifecycle equivalents ×4 (register/onSubscription{Complete,Renewal,Cancel,PaymentFailed}Handler + matching *Dispatch functions and event types)
Route/action helperscreateStripeWebhookHandler (+StripeWebhookHandlerConfig, SetupCompletedWebhookEvent), completeCheckoutOrder (+CompleteCheckoutOrderArgs, CompleteCheckoutOrderResult, CheckoutCompletedWebhookEvent), createCheckoutSessionAction (+CreateCheckoutSessionActionConfig, CheckoutSessionInput, CheckoutCallerIdentity, CheckoutIdentityFailureReason), CheckoutIdentityMismatchError, ReservedCheckoutMetadataKeyError (+RESERVED_CHECKOUT_METADATA_KEYS), VariantPriceRequiredError
Cart checkoutcreateCartCheckoutAction (+CreateCartCheckoutActionConfig, CartCheckoutLine, CartCheckoutBuyer, CartCheckoutInput, CartCheckoutInventory, CartCheckoutResult), CART_RESERVED_CHECKOUT_METADATA_KEYS, CART_CHECKOUT_MAX_LINES, errors CartCheckoutInputError, CartCheckoutLineError, CartCheckoutGuestDisabledError, CartCheckoutUnsupportedProviderError (+CartCheckoutInputFailureReason, CartCheckoutLineFailureReason) — see Cart checkout
Custom amounts and guestsErrors GiftCheckoutInputError (+GiftCheckoutInputFailureReason), CheckoutGuestDisabledError, GuestProductTypeError, CustomAmountNotAllowedError, GiftAmountOutOfRangeError, GiftTotalChangedError, GuestClaimRefusedError; GIFT_RESERVED_CHECKOUT_METADATA_KEYS; ensureGuestMember (+EnsureGuestMemberOptions, ECONOMY_GUEST_MEMBER), claimGuestOrders (+ClaimGuestOrdersArgs, ClaimGuestOrdersResult, GuestClaimFailureReason); computeFeeCoverCents, validateGiftAmount, isGuestUserId, GUEST_USER_ID_PREFIX, PROVIDER_MAX_AMOUNT_CENTS, FeeCoverRuleError (+FeeCoverRule, GiftAmountBounds, GiftAmountValidation, GiftAmountFailureReason); InlineRecurringPrice — see Custom amounts and guest checkout
Order-cancel event busregisterOrderCancelHandler, onOrderCancelDispatch (+OrderCanceledEvent, OrderCancelHandler)
Pending-order sweeperexpireStalePendingOrders, createExpireStalePendingOrdersTask (+ExpireStalePendingOrdersArgs, ExpireStalePendingOrdersSummary, ExpireStalePendingOrdersTaskOptions) — opt-in
Subscription actionscreateSubscriptionCheckoutAction (+CreateSubscriptionCheckoutActionConfig, SubscriptionCheckoutInput), createSubscriptionPortalAction (+CreateSubscriptionPortalActionConfig, SubscriptionPortalInput), cancelSubscriptionAction (+CancelSubscriptionActionConfig, CancelSubscriptionInput), SubscriptionCheckoutUnsupportedError
Order-refund event busregisterOrderRefundHandler, onOrderRefundDispatch (+OrderRefundedEvent, OrderRefundHandler, OrderRefundTransition types) — mirrors the order-complete bus above; see Order-refund dispatcher
Payouts extensioncreatePayoutAccountsCollection, createPayoutsCollection, PAYOUTS_DEFAULT_SLUGS, ManualPayoutAdapter/createManualPayoutAdapter, StripeConnectPayoutAdapter/createStripeConnectPayoutAdapter (+STRIPE_CONNECT_DEFAULT_SUPPORTED_COUNTRIES), createPayoutWebhookHandler, runPayoutBatch, getPayeeAnnualTotals, registerPayoutsGdpr (+PayoutProviderAdapter, PayoutRail, PayoutsConfig and related types)
TypesEconomyConfig, 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.

The renewal Payment row is keyed by the paid invoice when the adapter names it. The subscription.renewed event has optional invoiceId, paymentIntentRef and chargeRef (StripeAdapter fills them from invoice.paid, listing the invoice's payments when the payload does not carry them). With invoiceId, the row's externalId is the invoice id, paymentIntentRef and chargeRef are stored, and a redelivery of the same invoice reuses the existing row and its id as paymentId instead of writing a second one; this also holds on a site whose subscriptions slug is its own collection. Without invoiceId, the row is keyed by the subscription id and written per delivery, as before.

On subscription.canceled, the handler dispatches the cancel event, then updates the Subscriptions row by when access ends. A soft cancel (accessEndsAt more than a minute in the future; Stripe: customer.subscription.updated with cancel_at_period_end) sets cancelAtPeriodEnd: true and leaves status as it is, so the subscriber stays active through the paid period and a cancel handler's own status write is not overwritten. A cancel whose access has ended, or that gives no future date (Stripe: customer.subscription.deleted), sets status: 'canceled'.

StripeAdapter never puts a made-up date on these events. A soft cancel's accessEndsAt is cancel_at, then the subscription items' current_period_end (the latest, when items differ; Stripe API 2025-03-31.basil and later, including 2026-04-22.dahlia, keep the period there instead of on the subscription), then the older top-level current_period_end, then one subscriptions.retrieve. When none resolves, the update is not mapped (it arrives as unknown, with a warning) rather than sent with a past date that would end a paid subscription; Stripe's later customer.subscription.deleted completes the cancel. A customer.subscription.deleted maps accessEndsAt from ended_at (when service stopped), then canceled_at (on a cancel at period end, when the cancel was requested), then the event's created time, then the current time. On invoice.paid, periodEnd and periodStart come from the invoice's subscription line (not a one-off invoice item, and a regular line before a proration; the earliest period end when there are several), with line 0 as the fallback when no line identifies itself, as before. An invoice with no period on any line is read from the subscription; if that lookup fails for a reason that can pass (network, rate limit, 5xx), the adapter throws, the handler answers 400 and Stripe redelivers the event. When Stripe answers with no period, or says the subscription does not exist, the invoice is not mapped and is logged for review.

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.

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 what keeps the refund branch's full-or-partial comparison right (see Refund accounting): it compares the refunded total with the same field, so once totalCents carries the real charged amount instead of list price, a discounted order is judged against what was actually paid. 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; payload extended in 0.14.0. Mirrors the order-complete event bus (same globalThis-keyed registry, same Promise.allSettled best-effort delivery, same optional { key } so a module evaluated in more than one bundle registers one handler). It is the seam to use for anything a refund should undo — revoking access, reversing a commission, telling the team — instead of watching the Orders collection yourself:

import { registerOrderRefundHandler } from '@wabbit/tome-economy'

registerOrderRefundHandler(
  async (event) => {
    if (event.full) {
      // revoke what the order granted
    }
    // notify on every refund, full or partial
  },
  { key: 'my-site/order-refunds' },
)

The handler receives an OrderRefundedEvent:

FieldMeaning
orderId, userIdThe Order and its purchaser (Order.customer; '' when it has none)
status / newStatusThe Order's status after this refund: refunded or partially-refunded (the two fields are equal; status is the original name)
previousStatusThe status before this refund (completed, partially-refunded, …); null on a create
refundedCentsTotal refunded on the Order so far, in cents
previousRefundedCentsTotal refunded before this refund; refundedCents - previousRefundedCents is this refund's amount
totalCentsThe Order total, the real paid amount
fullTrue when the Order is now fully refunded (newStatus === 'refunded')
sourceEventIdThe provider webhook event that caused it (Stripe evt_…, Authorize.net notificationId); null for an admin edit or an adapter that supplies no id
orderThe refunded Order document as saved
refundedAtISO 8601 time of the dispatch

It fires once per real refund state change: the Order entering refunded or partially-refunded, or its refunded total growing. A partial refund followed by the rest dispatches twice (the second with full: true); two partials that do not reach the total dispatch twice with full: false. A replayed webhook dispatches nothing, because the webhook handler writes nothing for it (see Refund accounting) and the Orders afterChange hook only dispatches when the status changed or refundedCents increased. The dispatch comes from that hook, so an admin who sets an Order to refunded by hand dispatches too, with sourceEventId: null. A handler that throws or rejects is logged with the order id and never fails the Order write or the webhook, which still answers 200.

Refund accounting

Rewritten in 0.14.0. The webhook handler's refund branch decides full or partial by amount, never by whether a Payment is still succeeded (the earlier rule, under which a replayed partial refund, or a second partial, marked the whole Order refunded):

  • The provider's refunded amount is the source of truth. Stripe's charge.refunded carries the charge's cumulative amount_refunded, so the Order's refundedCents becomes the larger of what it holds and what the event reports, never a running sum a replay could double. The Order is refunded when that total reaches or passes totalCents (an over-refund included) and partially-refunded when it is above 0 and below.
  • Replays change nothing. The handler records each applied event id on the Order (refundEventIds) and ignores an event whose id is already there; an event reporting an amount no larger than the recorded one is ignored too, so a stale partial delivered after the full refund cannot lower anything.
  • Status only moves forward. An Order already refunded stays refunded.
  • The Payment row is kept in step: its new refundedCents field holds the refunded amount, and its status becomes partially-refunded (new value) or refunded.
  • Providers that report one refund at a time mark the refund event refundAmountBasis: 'delta'; the amount is then added to the Order's total, guarded by the event id and the provider's refund id (refundId). @wabbit/tome-economy-authnet does this. A custom adapter that omits refundAmountBasis is read as cumulative.
  • An event with no order, or with no refund amount, writes nothing and is logged for review.

A checkout.completed event for an Order already completed, refunded or partially-refunded writes nothing: the Order keeps its status, no second Payment is created, and neither the order-complete handlers nor the onOrderComplete option run again. For an Order still pending, a Payment already recorded for the same checkout session (or Order) is reused rather than duplicated, so an earlier delivery that failed between the two writes completes cleanly. Two deliveries of the same event arriving at the same instant can still both pass these checks; nothing here holds a database lock.

Schema changes for Postgres consumers. 0.14.0 adds three columns or values, all additive: orders.refund_event_ids (json), payments.refunded_cents (number, default 0), and a partially-refunded value on the Payments status enum. Generate and run a migration (payload migrate:create, then payload migrate) before deploying. MongoDB consumers need no migration. Existing Payment rows read as nothing refunded until their Order's next refund event.

Cart checkout

Added in 0.15.0. createCartCheckoutAction is a second, parallel checkout path for multi-line carts of physical goods: N lines become N provider line items, one pending Order and one hosted session with a fixed shipping option. createCheckoutSessionAction is unchanged for every existing caller; the one addition to it is the variant guard below.

import { createCartCheckoutAction, StripeAdapter } from '@wabbit/tome-economy'

const checkout = createCartCheckoutAction({
  adapter: new StripeAdapter({ secretKey, webhookSecret }),
  payload,
  baseUrl: 'https://shop.example',
  guestCustomerId, // the placeholder member row guest orders point at
  inventory,       // optional { reserve, release } stock guard
})

const { url, orderId } = await checkout(
  {
    lines: [{ productId, variantSku: 'MUG-BLUE', quantity: 2 }],
    buyer: { kind: 'guest', email: 'buyer@example.com' },
    shipping: { country: 'US', amountCents: 500, label: 'Standard shipping' },
    kind: 'my-cart',
    expiresAt: new Date(Date.now() + 30 * 60_000),
  },
  { user }, // the session user, or {} for a guest
)
  • Prices are resolved server-side, every time. A line carries a product id, an optional variantSku, a quantity and display hints (displayName, imageUrl). The action charges the newest active one-time Price for (product, variantSku); a line with no variantSku for a product that has variant Prices is refused (CartCheckoutLineError, reason variant-required), and lines in different currencies are refused. Nothing a caller sends can change an amount.
  • Two buyers. member runs the same identity gate as the single-item action. guest writes the configured guestCustomerId as the Order's customer (never a caller-supplied id) and records the buyer as contactEmail; without guestCustomerId it throws CartCheckoutGuestDisabledError. On a site whose members collection requires a user, point guestCustomerId at a member linked to a system user you control.
  • Shipping. The quote passes through as the session's only shipping option, and the hosted address form is restricted to shipping.country. Hosted checkout fixes shipping options when the session is created, so the destination country is chosen before the session.
  • The adapter must declare `supportsCartShipping`. StripeAdapter does; FreeAdapter does not, and the action throws CartCheckoutUnsupportedProviderError for it.
  • Metadata. Caller metadata is merged onto the Order and the session; the keys in CART_RESERVED_CHECKOUT_METADATA_KEYS (orderId, memberId, productType, kind, cartId, checkoutPath, buyer) throw ReservedCheckoutMetadataKeyError. The calling layer sets kind and cartId through their own input fields, and the session also carries checkoutPath: 'cart' plus memberId or buyer: 'guest'.
  • Stock. With an inventory guard the action reserves once per SKU (the line's variantSku, else the product's sku) after writing the Order. A failed reservation, a failed session or a failed externalId write cancels the Order immediately and releases the reservation; the cancel also fires the order-cancel bus.
  • Promotion codes pass through (allowPromotionCodes, discounts), as on the single-item path. The webhook records the paid amounts.

Variant guard on the single-item action. createCheckoutSessionAction now throws VariantPriceRequiredError before writing anything when the newest active Price it resolves carries a variantSku. Without it, a product sold in variants would be charged whichever variant's Price was created last. A Price with no variantSku resolves exactly as before.

Additive fields. Orders gain shippingCents (number, default 0), contactEmail (email, indexed) and items[].variantSku (text); Prices gain compareAtCents (display-only "was" price, never charged). All are optional and server-written. Postgres consumers generate and run a migration for the four columns; MongoDB consumers need none.

Provider additions. CheckoutLineItem gains displayName?/imageUrl?; CreateCheckoutSessionArgs gains shippingAddressCollection?, shippingOptions? and expiresAt?; PaymentProviderAdapter gains supportsCartShipping? and getCheckoutSession?(externalId), which returns a provider-neutral ProviderCheckoutSession (status, paid amounts, the collected shipping address). StripeAdapter sends each new Stripe parameter only when its argument is present.

Webhooks. checkout.completed gains shippingCents?, and the handler writes it onto the Order when it is non-zero or the Order already holds a non-zero quote (a session with no shipping writes exactly what it wrote before). A new checkout.expired event (Stripe checkout.session.expired) is acknowledged and ignored by default; set cancelOnExpiry: true, or a list of metadata.kind values such as ['my-cart'], to cancel the still-pending Order it names. Only pending Orders are ever canceled. completeCheckoutOrder(payload, { order, event, adapterProvider }) is the handler's completion step as a function (replay guard, one Payment, paid-amount write), for settling an Order whose webhook never arrived.

Order-cancel bus. registerOrderCancelHandler(handler, { key }) mirrors the refund bus. The Orders afterChange hook dispatches once when status enters canceled or the older cancelled (both are accepted; writers store canceled), with { orderId, previousStatus, metadata, items, canceledAt, order }. A dispatch with no handlers is silent.

Pending-order sweeper (opt-in). expireStalePendingOrders({ payload, adapter, kinds, olderThanMinutes, limit?, now? }) handles pending Orders of the named metadata.kind values older than the threshold: with adapter.getCheckoutSession it settles a paid session through completeCheckoutOrder, leaves an open or still-settling one alone, and cancels an expired or unknown one; without it, it cancels only after 24 hours plus the threshold. It refuses an empty kinds list, so it never sweeps every pending Order. Economy registers nothing; wrap it with createExpireStalePendingOrdersTask({ adapter: () => adapter, kinds, olderThanMinutes }) for Payload's job queue, or call it from your own scheduler.

Custom amounts and guest checkout

Added in 0.17.0. The two single-product actions, createCheckoutSessionAction (one-time) and createSubscriptionCheckoutAction (monthly or yearly), accept a buyer-chosen amount and a buyer without an account. Every new input is optional: a call that passes none of them behaves exactly as in 0.16.0, field for field and Stripe parameter for Stripe parameter.

A Price opts in. Set allowCustomAmount with minAmountCents and maxAmountCents on a Price. A validation hook requires both bounds, min ≤ amountCents ≤ max, a minimum at least the provider's minimum charge (Stripe USD: 50) and a maximum at most 99,999,999. amountCents stays the amount charged when a caller sends none and is the suggested amount a form shows. externalProductId (a Stripe prod_…) groups every inline charge under one Stripe product. A custom-amount Price needs no Stripe Price at all: the amount is charged inline (price_data, with recurring for monthly and yearly gifts).

import { createCheckoutSessionAction, createSubscriptionCheckoutAction, StripeAdapter } from '@wabbit/tome-economy'

const config = {
  adapter: new StripeAdapter({ secretKey, webhookSecret }),
  payload,
  baseUrl: 'https://example.org',
  guestCustomerId,               // from ensureGuestMember(payload, { userId })
  guestProductTypes: ['gift'],   // what a guest may buy
  feeCover: { percentBps: 300 }, // optional; fee cover is off unless the caller asks
}

// One-time gift from a guest. Stripe's page asks for the email unless guest.email is sent.
const { url } = await createCheckoutSessionAction(config)(
  {
    productId, priceId, amountCents: 2500, coverFees: true, expectedTotalCents: 2575,
    guest: {}, kind: 'gift', displayName: 'Gift to Harrow Lane Neighbors',
    expiresAt: new Date(Date.now() + 60 * 60_000),
  },
  {},
)

// Monthly gift from a guest: the Price has interval 'month'.
const monthly = await createSubscriptionCheckoutAction(config)(
  {
    priceId: monthlyPriceId, amountCents: 1500, guest: {}, kind: 'gift',
    metadata: { productId, productType: 'gift' },
  },
  {},
)
  • The server decides the money. The action re-reads the Price and checks the amount (a whole number of cents within the bounds, else GiftAmountOutOfRangeError carrying the bounds; a Price without allowCustomAmount throws CustomAmountNotAllowedError), always charges in the Price's currency, computes the fee cover, and refuses (GiftTotalChangedError, before anything is written) when expectedTotalCents differs from its own total. A form without script may omit expectedTotalCents; the hosted page shows the total before payment. An amount forces quantity 1, and a promotion code or discount with an amount is refused.
  • Fee cover is a plain percentage plus an optional fixed part: computeFeeCoverCents(gift, rule) is ceil(gift × percentBps / 10000) + fixedCents. The Order's line and total carry the total; metadata.giftCents and metadata.feeCoverCents carry the split, and the hosted line name states the fee part. coverFees: true without a configured rule throws.
  • Guests. guest replaces memberId/memberEmail (sending both throws). A guest may buy only product types in guestProductTypes (GuestProductTypeError otherwise), and needs guestCustomerId for one-time orders (CheckoutGuestDisabledError otherwise). A one-time guest Order points at the placeholder member, records the donor in contactEmail, carries metadata.buyer: 'guest' and must carry a kind. A recurring guest subscription has no member and a unique userId of guest:<uuid> (with metadata.checkoutRef), so two donors on one gift product never share a row; userId and checkoutRef in caller metadata are refused there. A signed-in session is ignored on the guest path, and the allowUnauthenticatedCaller hatch is never used. Rate-limit the server action that calls these by IP and session.
  • `kind` is stamped as metadata.kind. 'gift' makes the hosted button read "Donate" (submit_type: 'donate', on one-time and subscription sessions alike). On the gift and guest paths kind, buyer, giftCents and feeCoverCents are reserved in caller metadata; the member path without new inputs keeps passing any metadata.kind a caller already sends.
  • Failures. A guest session failure cancels its pending Order at once. Abandoned sessions are cleaned by kind: the webhook's cancelOnExpiry: ['gift'] and expireStalePendingOrders({ kinds: ['gift'], … }).
  • Webhooks. checkout.completed gains mode?, customerEmail? and customerRef?. A guest Order with no contactEmail gets the email the donor paid with in the completing update (member orders are never touched). Every completion looks for its Order first, exactly as before, whatever the session mode: a subscription-mode session that has an Order (a consumer's own subscription checkout) completes it on the unchanged path. Only when no Order matches does a subscription-mode completion record contactEmail and customerRef on its Subscriptions row (each only when empty) instead of logging a missing Order; an Order that did complete also enriches a matching row. That record is best effort: it is skipped when the configured subscriptions collection has no providerRef field (a site's own collection under the same slug), and any error in it is logged without affecting the webhook. The order-complete event gains buyerKind: 'guest' for guest orders.
  • Subscription handlers must ignore guest ids. Lifecycle events for a guest gift carry userId: 'guest:…'. A handler that grants access on registerSubscriptionCompleteHandler/RenewalHandler should skip ids for which isGuestUserId(id) is true.
  • Claiming later. claimGuestOrders(payload, { memberId, email, emailVerified, guestCustomerId, kinds }) moves a donor's guest Orders (placeholder customer, same contactEmail, metadata.kind in kinds) and memberless guest Subscriptions rows to their new member, stamping metadata.claimedAt. It refuses unless emailVerified === true from your auth layer, and is idempotent. Provider subscription metadata is not rewritten.
  • The placeholder member. ensureGuestMember(payload, { userId?, resolveUserId?, memberSlug? }) finds or creates "Guest checkout (system)" (slug guest-checkout, and it also matches storefront's guest slugs). On a members collection that requires a user, pass the system user.
  • `@wabbit/tome-economy/giving` exports computeFeeCoverCents, validateGiftAmount, isGuestUserId and the provider bounds with no server dependencies, so a form can show the same numbers the action will check.
  • Adapter additions. CreateCheckoutSessionArgs.customerEmail is now optional and gains submitType? (CreateSubscriptionSessionArgs gains submitType?: 'auto' | 'donate'; Stripe rejects 'pay' in subscription mode); CheckoutLineItem gains providerProductId?; CreateSubscriptionSessionArgs.stripePriceId and customerEmail are optional, with a new inlinePrice?; PaymentProviderAdapter.supportsInlineRecurringPrice? (StripeAdapter sets it; without it a custom recurring amount throws SubscriptionCheckoutUnsupportedError).

Schema changes. Prices gain allowCustomAmount (checkbox, default false), minAmountCents, maxAmountCents and externalProductId; Subscriptions gain contactEmail. All optional. Postgres consumers generate and run a migration; MongoDB consumers need none.

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.

Order Overview (@wabbit/tome-economy/admin) and refund backfill (@wabbit/tome-economy/scripts)

  • @wabbit/tome-economy/admin exports OrderOverviewView and orderOverviewViewConfig(). createOrdersCollection makes the Order Overview the default edit view (stock form on an Edit tab) unless admin: { overview: false }; the create route and drawers fall through to DefaultEditView. The collection passes its payments slug and refund access policy to the view through admin.custom.tomeOrderOverview.
  • Refunds are recorded as rows (refunds[]: source, reason, amount, actor, access result). refundAccess (default: revoke what a fully refunded order granted; false opts out) runs through each product type's revokeAccess hook.
  • @wabbit/tome-economy/scripts exports backfillOrderRefunds (dry run by default; --apply writes) to build refund rows for orders refunded before this release.

Server / client posture

Server-side, but with a genuinely different posture than crm/deals/marketing: economy has no `server-only` dependency and no `@wabbit/tome-economy/server` subpath — createStripeWebhookHandler (a Next.js route-handler factory), createCheckoutSessionAction, createCartCheckoutAction, 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? }.
  • CHANGELOG.md

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

Import path
@wabbit/tome-economy
@wabbit/tome-economy/admin
@wabbit/tome-economy/giving
@wabbit/tome-economy/scripts

Changelog

0.19.xMinor

  • Update your code first: the commerce writers now store the American values.

    New rows hold canceled and canceling, so a site that compares to 'cancelled' or 'cancelling' must accept both spellings before it takes this release. This is the second of three steps toward American spelling in stored values.

    Canceled orders (cart and checkout failures, Stripe session expiry and the stale order sweep) store canceled. Entitlements canceling at period end store canceling, and the order overview's access status reports it.

    Restock requests store canceled, and a canceled shipment prefers canceled when the shipments collection offers it.

    Reads still accept both spellings, and rows that hold the old values are unchanged until a site runs its backfill. The old options stay until the third step.

    On a SQL adapter the enum already gained the new value in the accept-both release, so make sure that migration has run.

5 fixes (0.19.1 – 0.19.2)
  • 0.19.2
    StripeAdapter now records when a subscription's service actually ended.

    For a subscription canceled at period end, Stripe's customer.subscription.deleted carries canceled_at as the time the cancel was requested (often weeks earlier) and ended_at as the time the subscription ended, and the adapter read canceled_at first, so subscription.canceled reported the day notice was given as the day access ended. accessEndsAt now comes from ended_at, then canceled_at, then the event's own time, then now.

    Nothing changes for an immediate cancel, where both are the same instant, and the webhook handler still marks the subscription canceled, since ended_at is never in the future.

  • 0.19.2
    StripeAdapter no longer sends a 1970 date on a subscription event.

    A missing Stripe timestamp became the Unix epoch, and on Stripe API 2025-03-31.basil and later (including 2026-04-22.dahlia) a subscription has no top-level current_period_end, so a soft cancel without cancel_at arrived as subscription.canceled with access ending in 1970: a site's cancel handler ended a paying subscriber's service mid-period, and the webhook handler wrote canceled instead of cancelAtPeriodEnd. The soft cancel now reads cancel_at, then the subscription items' current_period_end, then the older top-level field, then retrieves the subscription once.

    If none gives a date, the update is not mapped and a warning is logged, and Stripe's customer.subscription.deleted completes the cancel when access ends. A customer.subscription.deleted without canceled_at or ended_at uses the event's own time, then now.

    The renewal period now comes from the invoice's subscription line. invoice.paid read periodEnd and periodStart off the first invoice line, and when that line was a one-off invoice item (a fee or a credit) the renewal carried the item's creation time as its period end, which consumers use to extend access and as an idempotency key.

    The adapter now takes the regular subscription line for the invoice's subscription on both current and older API shapes, before a proration line, and the earliest period end when there are several. An invoice whose lines do not identify themselves maps from line 0 exactly as before.

    An invoice with no period on any line reads it from the subscription. If that lookup fails for a reason that can pass (network, rate limit, a Stripe 5xx), the webhook now fails with 400 so Stripe redelivers it.

    If Stripe answers with no period, or says the subscription does not exist, the invoice is not mapped and is logged for review.

  • 0.19.1
    The Stripe webhook handler no longer fails the renewal ledger write on a site that registers its own collection on the subscriptions slug.

    That collection has no providerRef, metadata or member field, so the Subscriptions lookup threw a query error on every subscription.renewed, no Payments row was written, and the renewal event went out without a paymentId. The handler now skips the Subscriptions find, update and create for a collection that is not economy's, still writes the Payments row (with no subscription link) and passes its id as paymentId.

    The canceled and payment-failed branches skip the same lookup instead of logging an error each time. Nothing changes for a site that uses economy's own Subscriptions collection.

  • 0.19.1
    The renewal Payments row is now keyed by the invoice it records.

    Before, every subscription.renewed delivery created a row with the subscription id as externalId, so a redelivered invoice.paid wrote a second row with a new paymentId, and no row carried an invoice, PaymentIntent or charge id a refund could be matched by. The subscription.renewed event gains optional invoiceId, paymentIntentRef and chargeRef, and StripeAdapter fills them from invoice.paid (listing the invoice's payments when the payload does not carry them; a failed lookup leaves them out and the renewal still goes through).

    With invoiceId, the row uses it as externalId, stores the PaymentIntent and charge, and a redelivery of the same invoice reuses the row and its id as paymentId; this also applies on a site whose subscriptions slug is its own collection. An adapter that sends no invoiceId sees no change.

  • 0.19.1
    The Stripe webhook handler no longer marks a subscription canceled the moment a cancel at period end is requested.

    A soft cancel arrives as subscription.canceled with an accessEndsAt in the future (Stripe: customer.subscription.updated with cancel_at_period_end), and the handler wrote status: 'canceled' on the Subscriptions row anyway, after the cancel handlers had run, so it overwrote any status a handler wrote and showed a paid-up subscriber as canceled. The handler now sets cancelAtPeriodEnd: true and leaves status alone while accessEndsAt is more than a minute ahead, and sets status: 'canceled' when access has ended or no future date is given (Stripe: customer.subscription.deleted).

    The cancel event is dispatched exactly as before. Nothing changes for an immediate cancel, an Authorize.net cancel, or a site whose subscriptions slug is its own collection.

0.18.xMinor

  • BREAKING: the American spellings replace the British ones in the order-cancel and subscription-cancel APIs.
    • OrderCancelledEvent is now OrderCanceledEvent, and SubscriptionCancelledEvent is now SubscriptionCanceledEvent.
    • The order-cancel handler payload field cancelledAt is now canceledAt.
    • The stale-order sweeper summary fields cancelled and cancelledOrderIds are now canceled and canceledOrderIds.
    • The subscription webhook event type 'subscription.cancelled' is now 'subscription.canceled'. A custom adapter must emit the new value.
    • Stored values are unchanged: an order's status is still 'cancelled'.
  • Admin labels, field descriptions, demo content and messages now use American spellings.

1 fix (0.18.1)
  • 0.18.1
    Canceled statuses now accept both spellings.

    This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet.

    Entitlement access canceling, order, payout and shipment canceled, and restock request canceled join the existing cancelling and cancelled options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view.

    Documents that hold the old value keep validating and behave as before.

    The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)".

    Writers still store the old values; a later release switches them and a backfill moves the stored data.

    If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.

0.17.xMinor

  • Single-product checkout now accepts a buyer-chosen amount (one-time, monthly or yearly) and a buyer without an account; no existing call changes.
    • Custom amounts. A Price opts in with allowCustomAmount, minAmountCents and maxAmountCents (validated on save), plus an optional externalProductId. createCheckoutSessionAction and createSubscriptionCheckoutAction take priceId, amountCents, coverFees and expectedTotalCents, and re-check every number on the server against the Price: the amount and its bounds, the currency, the fee cover and the total the buyer was shown. A custom amount is charged through an inline price, with recurring for monthly and yearly gifts, so no Stripe Price is needed.
    • Guest checkout. A guest buyer replaces memberId/memberEmail. One-time guest orders point at the site's placeholder member (guestCustomerId) and record the buyer as contactEmail; recurring guest subscriptions have no member and a unique guest:<uuid> userId. Only product types listed in guestProductTypes can be bought as a guest. A failed guest session cancels its pending Order at once.
    • Fee cover is a plain percentage plus an optional fixed part (feeCover: { percentBps, fixedCents? }), off unless the caller passes coverFees: true.
    • New helpers. ensureGuestMember finds or creates the placeholder member. claimGuestOrders moves a verified buyer's past guest orders and subscriptions to their new member. @wabbit/tome-economy/giving exports the side-effect-free rules (computeFeeCoverCents, validateGiftAmount, isGuestUserId) for forms.
    • Webhooks. checkout.completed gains mode, customerEmail and customerRef. A guest order with no email gets the one the buyer paid with. Every completion still looks for its Order first, in every session mode, so a subscription-mode session that has an Order completes exactly as before; only when there is none does a subscription-mode completion record the email and customer id on its Subscriptions row instead of logging a missing Order. That record is skipped when the configured subscriptions collection has no providerRef field, and its errors never affect the webhook. The order-complete event gains buyerKind: 'guest' for guest orders.
    • Adapters. StripeAdapter sends submit_type: 'donate' for one-time and subscription gifts, price_data.product when a provider product is given, and inline recurring prices; it declares the new supportsInlineRecurringPrice. For custom adapter authors: CreateCheckoutSessionArgs.customerEmail and CreateSubscriptionSessionArgs.customerEmail/stripePriceId are now optional at the type level (still always sent on every pre-existing path).
    • Schema. Prices gain four optional fields and Subscriptions gain contactEmail. Postgres consumers generate and run a migration; MongoDB consumers need none.

    Subscription lifecycle handlers that grant access should skip userIds for which isGuestUserId is true.

2 fixes (0.17.1 – 0.17.2)
  • 0.17.2

    README: subpath and file mentions are written package-qualified (for example @wabbit/tome-x/render) or as code text, so crawlers no longer resolve them as site URLs.

  • 0.17.1
    A subscription checkout now records the subscriber's email and customer id whichever order Stripe delivers its two webhooks in.

    When Stripe sent the first invoice.paid before checkout.session.completed, the invoice had already moved the Subscriptions row's providerRef from the checkout session id to the subscription id, so the completion could not find the row and a guest monthly gift was left with no contactEmail or customerRef. The completion now looks the row up by the session id (as before, one query in the usual order), then by the session's subscription id, then by metadata.checkoutSessionId for a site that stamps it.

    The checkout.completed webhook event gains an optional subscriptionId (Stripe: session.subscription, subscription-mode sessions only). Unchanged: the Order lookup still runs first, a subscriptions collection without providerRef is still skipped without being queried, an email or customer id already on the row is never overwritten, and a failure here still never fails the webhook.

0.16.xMinor

  • BREAKING: A full refund now revokes the access the order granted by default, and the Orders default edit view becomes the Order Overview.

    Migration: Pass refundAccess: false to createEconomyLayer / createOrdersCollection to keep refunds from touching access, and admin: { overview: false } to keep the previous Orders edit view. SQL adapters generate a migration (on Postgres: the orders_refunds array table and new payments columns); MongoDB needs none.

    Run backfillOrderRefunds from @wabbit/tome-economy/scripts once per consumer (dry run by default).

    • Orders gain a server-written refunds[] array, one row per provider refund or admin refund edit: providerRefundId, amountCents, currency, refundedAt, source, refundedBy, reason, note, sourceEventId, accessResult.
    • The webhook upserts rows by refund id in the same update that records the replay keys; an admin raising refundedCents appends an admin row with the acting user; admins can edit only reason and note.
    • StripeAdapter reads refunds.list({ charge }) on charge.refunded (Stripe's expired_uncaptured_charge maps to other; a list failure leaves the cumulative path running).
    • On checkout completion StripeAdapter retrieves the PaymentIntent once to fill the new Payments fields paymentIntentRef, chargeRef, card (brand, last4, expiry, wallet; never the number or fingerprint) and livemode.
    • Refund access policy, configured by refundAccess: a full refund revokes the entitlements the order granted (writing the catalog revocation audit fields by slug) and calls each product type's revokeAccess hook, unless another order still justifies the access. Partial refunds change nothing. exceptProductTypes and isStillJustified tune it.
    • The policy runs before the order-refunded dispatch, which now carries actorId and refunds.
    • admin.overview (default true) installs the Order Overview as the Orders default edit view; @wabbit/tome-economy/admin ships orderOverviewViewConfig and OrderOverviewView.
    • react and @payloadcms/ui are new optional peer dependencies.

0.15.xMinor

  • Adds createCartCheckoutAction, a parallel multi-line checkout for physical goods; createCheckoutSessionAction behaves as before for every existing caller.

    All additive and optional:

    • Cart checkout. createCartCheckoutAction turns N cart lines into N provider line items, one pending Order and one hosted session with a fixed shipping option. Every line's price is resolved server-side from the newest active one-time Price for (product, variantSku); the caller sends identities and quantities, never amounts. A member buyer passes the same identity gate as the single-item action; a guest buyer is recorded against a configured placeholder member (guestCustomerId) with contactEmail. An optional inventory guard reserves stock after the Order is written, and a failed reservation or session cancels the Order at once. Promotion codes pass through.
    • Variant guard. createCheckoutSessionAction throws the new VariantPriceRequiredError, before writing anything, when the Price it resolves names a variantSku. Products whose Prices carry no variantSku resolve exactly as before. Not marked BREAKING: a Price only carries a variantSku to sell a variant through the cart, which did not exist before this release, so no existing single-item checkout can reach the guard.
    • Fields. Orders gain optional shippingCents (default 0), contactEmail and items[].variantSku; Prices gain an optional display-only compareAtCents. Postgres consumers generate and run a migration for the new columns; MongoDB consumers need none.
    • Provider seam. CheckoutLineItem gains displayName?/imageUrl?; CreateCheckoutSessionArgs gains shippingAddressCollection?, shippingOptions? and expiresAt?; PaymentProviderAdapter gains supportsCartShipping? and getCheckoutSession?. StripeAdapter implements both and sends each new Stripe parameter only when its argument is present.
    • Webhooks. checkout.completed carries shippingCents?, written onto the Order only when it is non-zero or the Order already holds a quote. A new checkout.expired event is acknowledged and ignored unless cancelOnExpiry is set (true, or a list of metadata.kind values); only pending Orders are ever canceled. The completion step is exported as completeCheckoutOrder.
    • Order-cancel bus. registerOrderCancelHandler / onOrderCancelDispatch fire once when an Order enters cancelled; with no handler registered the dispatch is silent.
    • Pending-order sweeper. expireStalePendingOrders (and createExpireStalePendingOrdersTask) settles or cancels stale pending Orders of the metadata.kind values a consumer names. Economy registers nothing; Orders of unnamed kinds are never touched.
1 fix (0.15.1)
  • 0.15.1
    Failed payment-provider calls now log only the error type, HTTP status, request id and Stripe error codes, never the provider's message, headers or raw error.
    • Stripe's code and decline_code (for example card_declined, insufficient_funds) are kept, as code and declineCode, so a failed payment can still be diagnosed from the log.
    • Covers the checkout and cart-checkout actions, the subscription checkout action, the Stripe and payout webhook handlers, the refund ledger, the order and subscription event buses and the pending-order sweeper.
    • A Stripe authentication error used to put a masked key fragment (sk_test_****…) into the log line through its message. That text is no longer logged.
    • Internal failures, such as a database write or a throwing event handler, still log their message and stack. An internal error that wraps a provider error, or whose text contains a key-shaped string, is reduced or redacted the same way.
    • Nothing else changes: the same errors are thrown, the same responses returned and the same records written.

0.14.xMinor

  • Refund accounting is cumulative and replay-safe; checkout completion is idempotent; the order-refunded event carries what a consumer needs to act on it.
    1. A replayed partial refund, or a second partial, no longer marks the Order fully refunded. The refund branch of createStripeWebhookHandler decided full-or-partial by whether a succeeded Payment still existed, and marked that Payment refunded on any refund, so the second refund event for an order (a redelivery of the first, or a genuine second partial) always read as full. It now decides by amount: the Order's refundedCents takes the provider's cumulative refunded amount (Stripe's charge amount_refunded, the larger of what is recorded and what the event reports, never a running sum), and the Order is refunded when that reaches or passes totalCents, partially-refunded when it is above 0 and below. Each applied event id is recorded on the Order, and an event already applied, or one reporting no more than the recorded amount, writes nothing. Status only moves forward (refunded stays refunded). An event with no refund amount now writes nothing and logs a warning; it used to set a refund status with 0 refunded.
    2. A `checkout.completed` replayed after a refund no longer re-grants. For an Order already completed, refunded or partially-refunded the branch writes nothing: no status change, no second Payment, no order-complete dispatch, and the onOrderComplete option does not run. For a pending Order, a Payment already recorded for the same session or Order is reused instead of duplicated.
    3. `OrderRefundedEvent` gains `previousStatus`, `newStatus`, `previousRefundedCents`, `full`, `sourceEventId` and `order` (the six existing fields are unchanged). registerOrderRefundHandler keeps its signature; onOrderRefundDispatch(order, transition?) takes an optional second argument (new OrderRefundTransition type). A refund handler that throws synchronously is now caught and logged like a rejecting one, and a missing Order.customer dispatches userId: '' instead of throwing.
    4. `WebhookEvent`'s `refund` member gains three optional fields: eventId (the provider event id; StripeAdapter now sets it from the Stripe event), refundAmountBasis: 'cumulative' | 'delta' (omitted = cumulative, the previous reading) and refundId. A delta event's amount is added to the Order's total, replay-guarded by eventId and refundId.

    Schema changes — Postgres consumers must generate and run a migration before deploying (payload migrate:create, then payload migrate): orders.refundEventIds (json), payments.refundedCents (number, default 0), and a new partially-refunded value on the Payments status select (an enum change). MongoDB needs nothing.

    PaymentStatus gains 'partially-refunded'; code that switches exhaustively on it needs the new case. A partial refund now leaves its Payment partially-refunded, where it used to become refunded.

    Consumer follow-up: a site that watched the Orders collection in its own afterChange to react to refunds can move that logic to registerOrderRefundHandler and branch on event.full.

    @wabbit/tome-economy-authnet: mapAuthorizeNetWebhookEvent marks net.authorize.payment.refund.created as refundAmountBasis: 'delta' (an Authorize.net refund transaction carries that one refund's amount, not a running total) and adds eventId (the notificationId) and refundId (the refund transId). With this economy release, two partial refunds on an Authorize.net order now sum, and a redelivered notification is ignored.

    Upgrade both packages together: an older economy ignores the new fields and keeps its previous behavior, and this economy reading an older authnet's events treats each refund amount as a running total.

0.13.xMinor

  • 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).

  • 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.

  • runPayoutBatch reads relationship ids with core's relationId instead of a local copy, which also handles numeric (Postgres) ids.

2 fixes (0.13.1 – 0.13.2)
  • 0.13.2
    Published packages now contain compiled JavaScript and type declarations under a one-line license 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 license: /*! @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 behavior change, and nothing to do on upgrade.

    In @wabbit/tome-blocks-gallery, the source snapshots extractGallerySource writes from an installed pack leave out the license 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.

  • 0.13.1
    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.

0.12.xMinor

  • 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 behavior 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.

0.11.xMinor

  • 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).

  • 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.

0.10.xMinor

  • 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.

1 fix (0.10.1)
  • 0.10.1
    **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 recognizes 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 behavior 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.

0.9.xMinor

  • 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).
    1. 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.
    1. `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.

0.8.xMinor

  • 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:

    - 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 authorized 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.

  • 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 behavior 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.

  • 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 honors 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 `createLayer 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.

  • 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.

0.7.xMinor

  • 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.

0.6.xMinor

  • 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.

  • 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).

0.5.xMinor

  • 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.

  • 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.

0.4.xMinor

  • 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.

2 fixes (0.4.1 – 0.4.2)
  • 0.4.2

    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).

  • 0.4.1
    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.

0.3.xMinor

  • 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 -> canceled, 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.

1 fix (0.3.1)
  • 0.3.1
    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.

0.2.x

5 fixes (0.2.4 – 0.2.7)
  • 0.2.7
    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.

  • 0.2.6
    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.

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

    Updated dependencies [36dc023]

  • 0.2.4
    Updated dependencies [2612799]
    • @wabbit/tome-core@1.0.11

1.0.x

1 fix (1.0.0)
  • 1.0.0
    Updated dependencies
    • @wabbit/tome-core@0.2.0

Full changelog across all packages