Documentation menu
Documentation / Commerce engine / economy
Economy
npm install @wabbit/tome-economyFirst install from our registry?
Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKEN
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:
| Peer | Range | Optional? |
|---|---|---|
payload | >=3.67.0 | no |
@payloadcms/richtext-lexical | >=3.67.0 | no |
@wabbit/tome-core | >=1.14.0 <2.0.0 | no |
stripe | >=14.0.0 | yes |
lucide-react | >=0.460.0 | yes |
@payloadcms/ui | >=3.67.0 | yes — the admin view only |
react | >=19.0.0 | yes — 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.
| Group | Exports |
|---|---|
| Layer entry | createEconomyLayer(config?), EconomyLayerConfig — canonical; returns CollectionConfig[], matching the createOrgLayer/createAccountsLayer bare-array shape |
| Collection factories | createOrdersCollection, createPaymentsCollection, createPricesCollection, createVendorEarningsCollection, createSubscriptionsCollection (+ each factory's *CollectionConfig type). Note: createVendorEarningsCollection is a documented stub per the barrel's own comment — "multi-vendor deferred to v1+" |
| Sidebar-only registration | initEconomy(config?), InitEconomyConfig — void-returning, registration-only; unchanged contract, now called internally by createEconomyLayer too |
| Provider adapters | StripeAdapter, createStripeAdapter (+StripeAdapterConfig), FreeAdapter, createFreeAdapter, PaymentProviderAdapter interface (+ CheckoutLineItem, CreateCheckoutSessionArgs, WebhookEvent, CreateSubscriptionSessionArgs, SubscriptionSessionResult; saved-card types CreateSetupSessionArgs, SetupSessionResult, ChargeSavedPaymentMethodArgs, ChargeSavedPaymentMethodResult, ChargeFailureCode, ReleaseSavedPaymentMethodResult — see Saved payment methods; cart types CheckoutShippingOption, ProviderCheckoutSession — see Cart checkout) |
| Event bus | onOrderComplete, registerOrderCompleteHandler, onOrderCompleteDispatch; subscription-lifecycle equivalents ×4 (register/onSubscription{Complete,Renewal,Cancel,PaymentFailed}Handler + matching *Dispatch functions and event types) |
| Route/action helpers | createStripeWebhookHandler (+StripeWebhookHandlerConfig, SetupCompletedWebhookEvent), completeCheckoutOrder (+CompleteCheckoutOrderArgs, CompleteCheckoutOrderResult, CheckoutCompletedWebhookEvent), createCheckoutSessionAction (+CreateCheckoutSessionActionConfig, CheckoutSessionInput, CheckoutCallerIdentity, CheckoutIdentityFailureReason), CheckoutIdentityMismatchError, ReservedCheckoutMetadataKeyError (+RESERVED_CHECKOUT_METADATA_KEYS), VariantPriceRequiredError |
| Cart checkout | createCartCheckoutAction (+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 guests | Errors 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 bus | registerOrderCancelHandler, onOrderCancelDispatch (+OrderCanceledEvent, OrderCancelHandler) |
| Pending-order sweeper | expireStalePendingOrders, createExpireStalePendingOrdersTask (+ExpireStalePendingOrdersArgs, ExpireStalePendingOrdersSummary, ExpireStalePendingOrdersTaskOptions) — opt-in |
| Subscription actions | createSubscriptionCheckoutAction (+CreateSubscriptionCheckoutActionConfig, SubscriptionCheckoutInput), createSubscriptionPortalAction (+CreateSubscriptionPortalActionConfig, SubscriptionPortalInput), cancelSubscriptionAction (+CancelSubscriptionActionConfig, CancelSubscriptionInput), SubscriptionCheckoutUnsupportedError |
| Order-refund event bus | registerOrderRefundHandler, onOrderRefundDispatch (+OrderRefundedEvent, OrderRefundHandler, OrderRefundTransition types) — mirrors the order-complete bus above; see Order-refund dispatcher |
| Payouts extension | createPayoutAccountsCollection, createPayoutsCollection, PAYOUTS_DEFAULT_SLUGS, ManualPayoutAdapter/createManualPayoutAdapter, StripeConnectPayoutAdapter/createStripeConnectPayoutAdapter (+STRIPE_CONNECT_DEFAULT_SUPPORTED_COUNTRIES), createPayoutWebhookHandler, runPayoutBatch, getPayeeAnnualTotals, registerPayoutsGdpr (+PayoutProviderAdapter, PayoutRail, PayoutsConfig and related types) |
| Types | EconomyConfig, Order, OrderItem, OrderStatus, Payment, PaymentStatus, PaymentProvider, Price, Subscription, SubscriptionStatus, CheckoutSessionResult, OrderCompletedEvent, OrderCompleteHandler, ECONOMY_DEFAULT_SLUGS |
Subscriptions
Added 2026-09-06 as a prerequisite for @wabbit/tome-directory (a monthly/annual per-listing tier ladder needs recurring billing this package did not yet have). createEconomyLayer includes the Subscriptions collection by default — pass subscriptions: false to omit it, or a SubscriptionsCollectionConfig to override its slugs/relationship targets.
createSubscriptionCheckoutAction is the recurring-billing counterpart of createCheckoutSessionAction, same identity contract (auth is a required second argument; a mismatch throws CheckoutIdentityMismatchError). It resolves the Price, requires interval: 'month' | 'year' (throws SubscriptionCheckoutUnsupportedError for a one-time Price), feature-detects adapter.createSubscriptionSession (throws the same error type when the adapter has none — FreeAdapter has no recurring-billing support), then creates the Checkout session and a Subscriptions row. providerRef on that row starts as the Checkout Session id and is finalized to the real provider subscription id by createStripeWebhookHandler's subscription.renewed handling on first activation — mirroring how createCheckoutSessionAction sets Order.externalId before the webhook completes it.
createSubscriptionPortalAction/cancelSubscriptionAction feature-detect adapter.createPortalSession/adapter.cancelSubscription the same way.
On subscription.renewed, the webhook handler now (in addition to its existing entitlement dispatch, unchanged) writes a renewal Payment row — previously that branch dispatched events but created no ledger row, unlike checkout.completed. Because a renewal has no Order, Payments.order is now optional and a new Payments.subscription relationship carries the link instead.
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.
Paid-amount truth
Added 2026-09-14. Before this, Orders.totalCents/discountCents were frozen at LIST price from createCheckoutSessionAction's pending-Order write (discountCents hardcoded 0) and never corrected once Stripe applied a promo — the Payment row alone carried the real charged amount. createStripeWebhookHandler's checkout.completed branch now overwrites Orders.totalCents with the session's real amount_total on completion, plus two new Orders fields written when the session resolves them: taxCents (from total_details.amount_tax) and promotionCodeRef (the applied Stripe promotion code id, from session.discounts[0].promotion_code; undefined for a coupon-only discount or no discount at all). discountCents is likewise overwritten from total_details.amount_discount when resolvable. This is also 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:
| Field | Meaning |
|---|---|
orderId, userId | The Order and its purchaser (Order.customer; '' when it has none) |
status / newStatus | The Order's status after this refund: refunded or partially-refunded (the two fields are equal; status is the original name) |
previousStatus | The status before this refund (completed, partially-refunded, …); null on a create |
refundedCents | Total refunded on the Order so far, in cents |
previousRefundedCents | Total refunded before this refund; refundedCents - previousRefundedCents is this refund's amount |
totalCents | The Order total, the real paid amount |
full | True when the Order is now fully refunded (newStatus === 'refunded') |
sourceEventId | The provider webhook event that caused it (Stripe evt_…, Authorize.net notificationId); null for an admin edit or an adapter that supplies no id |
order | The refunded Order document as saved |
refundedAt | ISO 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.refundedcarries the charge's cumulativeamount_refunded, so the Order'srefundedCentsbecomes the larger of what it holds and what the event reports, never a running sum a replay could double. The Order isrefundedwhen that total reaches or passestotalCents(an over-refund included) andpartially-refundedwhen 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
refundedstaysrefunded. - The Payment row is kept in step: its new
refundedCentsfield holds the refunded amount, and its status becomespartially-refunded(new value) orrefunded. - Providers that report one refund at a time mark the
refundeventrefundAmountBasis: '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-authnetdoes this. A custom adapter that omitsrefundAmountBasisis 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 novariantSkufor a product that has variant Prices is refused (CartCheckoutLineError, reasonvariant-required), and lines in different currencies are refused. Nothing a caller sends can change an amount. - Two buyers.
memberruns the same identity gate as the single-item action.guestwrites the configuredguestCustomerIdas the Order'scustomer(never a caller-supplied id) and records the buyer ascontactEmail; withoutguestCustomerIdit throwsCartCheckoutGuestDisabledError. On a site whose members collection requires a user, pointguestCustomerIdat 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`.
StripeAdapterdoes;FreeAdapterdoes not, and the action throwsCartCheckoutUnsupportedProviderErrorfor it. - Metadata. Caller
metadatais merged onto the Order and the session; the keys inCART_RESERVED_CHECKOUT_METADATA_KEYS(orderId,memberId,productType,kind,cartId,checkoutPath,buyer) throwReservedCheckoutMetadataKeyError. The calling layer setskindandcartIdthrough their own input fields, and the session also carriescheckoutPath: 'cart'plusmemberIdorbuyer: 'guest'. - Stock. With an
inventoryguard the action reserves once per SKU (the line'svariantSku, else the product'ssku) after writing the Order. A failed reservation, a failed session or a failedexternalIdwrite 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
GiftAmountOutOfRangeErrorcarrying the bounds; a Price withoutallowCustomAmountthrowsCustomAmountNotAllowedError), always charges in the Price's currency, computes the fee cover, and refuses (GiftTotalChangedError, before anything is written) whenexpectedTotalCentsdiffers from its own total. A form without script may omitexpectedTotalCents; 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)isceil(gift × percentBps / 10000) + fixedCents. The Order's line and total carry the total;metadata.giftCentsandmetadata.feeCoverCentscarry the split, and the hosted line name states the fee part.coverFees: truewithout a configured rule throws. - Guests.
guestreplacesmemberId/memberEmail(sending both throws). A guest may buy only product types inguestProductTypes(GuestProductTypeErrorotherwise), and needsguestCustomerIdfor one-time orders (CheckoutGuestDisabledErrorotherwise). A one-time guest Order points at the placeholder member, records the donor incontactEmail, carriesmetadata.buyer: 'guest'and must carry akind. A recurring guest subscription has no member and a uniqueuserIdofguest:<uuid>(withmetadata.checkoutRef), so two donors on one gift product never share a row;userIdandcheckoutRefin caller metadata are refused there. A signed-in session is ignored on the guest path, and theallowUnauthenticatedCallerhatch 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 pathskind,buyer,giftCentsandfeeCoverCentsare reserved in caller metadata; the member path without new inputs keeps passing anymetadata.kinda 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']andexpireStalePendingOrders({ kinds: ['gift'], … }). - Webhooks.
checkout.completedgainsmode?,customerEmail?andcustomerRef?. A guest Order with nocontactEmailgets 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 recordcontactEmailandcustomerRefon 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 noproviderReffield (a site's own collection under the same slug), and any error in it is logged without affecting the webhook. The order-complete event gainsbuyerKind: 'guest'for guest orders. - Subscription handlers must ignore guest ids. Lifecycle events for a guest gift carry
userId: 'guest:…'. A handler that grants access onregisterSubscriptionCompleteHandler/RenewalHandlershould skip ids for whichisGuestUserId(id)is true. - Claiming later.
claimGuestOrders(payload, { memberId, email, emailVerified, guestCustomerId, kinds })moves a donor's guest Orders (placeholder customer, samecontactEmail,metadata.kindinkinds) and memberless guest Subscriptions rows to their new member, stampingmetadata.claimedAt. It refuses unlessemailVerified === truefrom 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)" (slugguest-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,isGuestUserIdand the provider bounds with no server dependencies, so a form can show the same numbers the action will check. - Adapter additions.
CreateCheckoutSessionArgs.customerEmailis now optional and gainssubmitType?(CreateSubscriptionSessionArgsgainssubmitType?: 'auto' | 'donate'; Stripe rejects'pay'in subscription mode);CheckoutLineItemgainsproviderProductId?;CreateSubscriptionSessionArgs.stripePriceIdandcustomerEmailare optional, with a newinlinePrice?;PaymentProviderAdapter.supportsInlineRecurringPrice?(StripeAdaptersets it; without it a custom recurring amount throwsSubscriptionCheckoutUnsupportedError).
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/adminexportsOrderOverviewViewandorderOverviewViewConfig().createOrdersCollectionmakes the Order Overview the default edit view (stock form on an Edit tab) unlessadmin: { overview: false }; the create route and drawers fall through toDefaultEditView. The collection passes its payments slug and refund access policy to the view throughadmin.custom.tomeOrderOverview.- Refunds are recorded as rows (
refunds[]: source, reason, amount, actor, access result).refundAccess(default: revoke what a fully refunded order granted;falseopts out) runs through each product type'srevokeAccesshook. @wabbit/tome-economy/scriptsexportsbackfillOrderRefunds(dry run by default;--applywrites) 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'sproviderid writes cleanly with zero schema change here.PaymentProvideris the matching open type ('stripe' | 'free' | 'manual' | 'authorizenet' | (string & {})). - `adapter.webhookSignatureHeader?: string` —
createStripeWebhookHandlerreadsadapter.webhookSignatureHeader ?? 'stripe-signature'instead of hardcoding Stripe's header name, so a processor with its own signature header (Authorize.net'sX-ANET-Signature, the first real case — see@wabbit/tome-economy-authnet) plugs in without a fork. - `adapter.cancelSubscription?(args)` / `adapter.refund?(args)` — both optional, both feature-detected by callers (
cancelSubscriptionAction, and any consumer issuing a refund).StripeAdapterimplements both. - Every Payment/Order write in this package now stamps
adapter.provider— the webhook handler'scheckout.completedandsubscription.renewedbranches no longer hardcode'stripe'.
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.CreateSetupSessionArgsis{ customerEmail, successUrl, cancelUrl, metadata, stripeCustomerId? }; pass an existingstripeCustomerIdso a repeat customer is not duplicated. Returns{ sessionId, url }. When the session completes, the webhook handler emits asetup.completedevent and calls youronSetupCompleted(event)option oncreateStripeWebhookHandler(typedSetupCompletedWebhookEvent, carryingpledgeIdfrommetadata.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 }.idempotencyKeyis 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? }, whereChargeFailureCodeis'card_declined'(retry within your dunning window),'authentication_required'(the customer must come back on-session; an off-session retry will fail the same way) or'provider_error'(network, bad request, outage). - `releaseSavedPaymentMethod({ paymentMethodId }): Promise<ReleaseSavedPaymentMethodResult>` — detaches the saved card when it will not be charged. Never throws; returns
{ released, failureMessage? }.
Links
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
canceledandcanceling, 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 storecanceling, and the order overview's access status reports it.Restock requests store
canceled, and a canceled shipment preferscanceledwhen 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
StripeAdapternow records when a subscription's service actually ended.For a subscription canceled at period end, Stripe's
customer.subscription.deletedcarriescanceled_atas the time the cancel was requested (often weeks earlier) andended_atas the time the subscription ended, and the adapter readcanceled_atfirst, sosubscription.canceledreported the day notice was given as the day access ended.accessEndsAtnow comes fromended_at, thencanceled_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, sinceended_atis never in the future. - 0.19.2
StripeAdapterno 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 withoutcancel_atarrived assubscription.canceledwith access ending in 1970: a site's cancel handler ended a paying subscriber's service mid-period, and the webhook handler wrotecanceledinstead ofcancelAtPeriodEnd. The soft cancel now readscancel_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.deletedcompletes the cancel when access ends. Acustomer.subscription.deletedwithoutcanceled_atorended_atuses the event's own time, then now.The renewal period now comes from the invoice's subscription line.
invoice.paidreadperiodEndandperiodStartoff 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
subscriptionsslug.That collection has no
providerRef,metadataormemberfield, so the Subscriptions lookup threw a query error on everysubscription.renewed, no Payments row was written, and the renewal event went out without apaymentId. The handler now skips the Subscriptions find, update and create for a collection that is not economy's, still writes the Payments row (with nosubscriptionlink) and passes its id aspaymentId.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.reneweddelivery created a row with the subscription id asexternalId, so a redeliveredinvoice.paidwrote a second row with a newpaymentId, and no row carried an invoice, PaymentIntent or charge id a refund could be matched by. Thesubscription.renewedevent gains optionalinvoiceId,paymentIntentRefandchargeRef, andStripeAdapterfills them frominvoice.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 asexternalId, stores the PaymentIntent and charge, and a redelivery of the same invoice reuses the row and its id aspaymentId; this also applies on a site whosesubscriptionsslug is its own collection. An adapter that sends noinvoiceIdsees no change. - 0.19.1
The Stripe webhook handler no longer marks a subscription
canceledthe moment a cancel at period end is requested.A soft cancel arrives as
subscription.canceledwith anaccessEndsAtin the future (Stripe:customer.subscription.updatedwithcancel_at_period_end), and the handler wrotestatus: '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 setscancelAtPeriodEnd: trueand leavesstatusalone whileaccessEndsAtis more than a minute ahead, and setsstatus: '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
subscriptionsslug is its own collection.
0.18.xMinor
BREAKING: the American spellings replace the British ones in the order-cancel and subscription-cancel APIs.
OrderCancelledEventis nowOrderCanceledEvent, andSubscriptionCancelledEventis nowSubscriptionCanceledEvent.- The order-cancel handler payload field
cancelledAtis nowcanceledAt. - The stale-order sweeper summary fields
cancelledandcancelledOrderIdsare nowcanceledandcanceledOrderIds. - 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
statusis 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 shipmentcanceled, and restock requestcanceledjoin the existingcancellingandcancelledoptions. 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,minAmountCentsandmaxAmountCents(validated on save), plus an optionalexternalProductId.createCheckoutSessionActionandcreateSubscriptionCheckoutActiontakepriceId,amountCents,coverFeesandexpectedTotalCents, 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, withrecurringfor monthly and yearly gifts, so no Stripe Price is needed. - Guest checkout. A
guestbuyer replacesmemberId/memberEmail. One-time guest orders point at the site's placeholder member (guestCustomerId) and record the buyer ascontactEmail; recurring guest subscriptions have no member and a uniqueguest:<uuid>userId. Only product types listed inguestProductTypescan 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 passescoverFees: true. - New helpers.
ensureGuestMemberfinds or creates the placeholder member.claimGuestOrdersmoves a verified buyer's past guest orders and subscriptions to their new member.@wabbit/tome-economy/givingexports the side-effect-free rules (computeFeeCoverCents,validateGiftAmount,isGuestUserId) for forms. - Webhooks.
checkout.completedgainsmode,customerEmailandcustomerRef. 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 noproviderReffield, and its errors never affect the webhook. The order-complete event gainsbuyerKind: 'guest'for guest orders. - Adapters.
StripeAdaptersendssubmit_type: 'donate'for one-time and subscription gifts,price_data.productwhen a provider product is given, and inline recurring prices; it declares the newsupportsInlineRecurringPrice. For custom adapter authors:CreateCheckoutSessionArgs.customerEmailandCreateSubscriptionSessionArgs.customerEmail/stripePriceIdare 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 whichisGuestUserIdis true.- Custom amounts. A Price opts in with
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.paidbeforecheckout.session.completed, the invoice had already moved the Subscriptions row'sproviderReffrom the checkout session id to the subscription id, so the completion could not find the row and a guest monthly gift was left with nocontactEmailorcustomerRef. 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 bymetadata.checkoutSessionIdfor a site that stamps it.The
checkout.completedwebhook event gains an optionalsubscriptionId(Stripe:session.subscription, subscription-mode sessions only). Unchanged: the Order lookup still runs first, asubscriptionscollection withoutproviderRefis 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: falsetocreateEconomyLayer/createOrdersCollectionto keep refunds from touching access, andadmin: { overview: false }to keep the previous Orders edit view. SQL adapters generate a migration (on Postgres: theorders_refundsarray table and newpaymentscolumns); MongoDB needs none.Run
backfillOrderRefundsfrom@wabbit/tome-economy/scriptsonce 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
refundedCentsappends an admin row with the acting user; admins can edit onlyreasonandnote. StripeAdapterreadsrefunds.list({ charge })oncharge.refunded(Stripe'sexpired_uncaptured_chargemaps toother; a list failure leaves the cumulative path running).- On checkout completion
StripeAdapterretrieves the PaymentIntent once to fill the new Payments fieldspaymentIntentRef,chargeRef,card(brand, last4, expiry, wallet; never the number or fingerprint) andlivemode. - 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'srevokeAccesshook, unless another order still justifies the access. Partial refunds change nothing.exceptProductTypesandisStillJustifiedtune it. - The policy runs before the order-refunded dispatch, which now carries
actorIdandrefunds. admin.overview(default true) installs the Order Overview as the Orders default edit view;@wabbit/tome-economy/adminshipsorderOverviewViewConfigandOrderOverviewView.reactand@payloadcms/uiare new optional peer dependencies.
- Orders gain a server-written
0.15.xMinor
Adds
createCartCheckoutAction, a parallel multi-line checkout for physical goods;createCheckoutSessionActionbehaves as before for every existing caller.All additive and optional:
- Cart checkout.
createCartCheckoutActionturns 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) withcontactEmail. An optionalinventoryguard reserves stock after the Order is written, and a failed reservation or session cancels the Order at once. Promotion codes pass through. - Variant guard.
createCheckoutSessionActionthrows the newVariantPriceRequiredError, before writing anything, when the Price it resolves names avariantSku. Products whose Prices carry novariantSkuresolve exactly as before. Not marked BREAKING: a Price only carries avariantSkuto 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),contactEmailanditems[].variantSku; Prices gain an optional display-onlycompareAtCents. Postgres consumers generate and run a migration for the new columns; MongoDB consumers need none. - Provider seam.
CheckoutLineItemgainsdisplayName?/imageUrl?;CreateCheckoutSessionArgsgainsshippingAddressCollection?,shippingOptions?andexpiresAt?;PaymentProviderAdaptergainssupportsCartShipping?andgetCheckoutSession?.StripeAdapterimplements both and sends each new Stripe parameter only when its argument is present. - Webhooks.
checkout.completedcarriesshippingCents?, written onto the Order only when it is non-zero or the Order already holds a quote. A newcheckout.expiredevent is acknowledged and ignored unlesscancelOnExpiryis set (true, or a list ofmetadata.kindvalues); onlypendingOrders are ever canceled. The completion step is exported ascompleteCheckoutOrder. - Order-cancel bus.
registerOrderCancelHandler/onOrderCancelDispatchfire once when an Order enterscancelled; with no handler registered the dispatch is silent. - Pending-order sweeper.
expireStalePendingOrders(andcreateExpireStalePendingOrdersTask) settles or cancels stale pending Orders of themetadata.kindvalues a consumer names. Economy registers nothing; Orders of unnamed kinds are never touched.
- Cart checkout.
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
codeanddecline_code(for examplecard_declined,insufficient_funds) are kept, ascodeanddeclineCode, 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.
- Stripe's
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.
- A replayed partial refund, or a second partial, no longer marks the Order fully refunded. The refund branch of
createStripeWebhookHandlerdecided full-or-partial by whether asucceededPayment still existed, and marked that Paymentrefundedon 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'srefundedCentstakes the provider's cumulative refunded amount (Stripe's chargeamount_refunded, the larger of what is recorded and what the event reports, never a running sum), and the Order isrefundedwhen that reaches or passestotalCents,partially-refundedwhen 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 (refundedstaysrefunded). An event with no refund amount now writes nothing and logs a warning; it used to set a refund status with 0 refunded. - A `checkout.completed` replayed after a refund no longer re-grants. For an Order already
completed,refundedorpartially-refundedthe branch writes nothing: no status change, no second Payment, no order-complete dispatch, and theonOrderCompleteoption does not run. For apendingOrder, a Payment already recorded for the same session or Order is reused instead of duplicated. - `OrderRefundedEvent` gains `previousStatus`, `newStatus`, `previousRefundedCents`, `full`, `sourceEventId` and `order` (the six existing fields are unchanged).
registerOrderRefundHandlerkeeps its signature;onOrderRefundDispatch(order, transition?)takes an optional second argument (newOrderRefundTransitiontype). A refund handler that throws synchronously is now caught and logged like a rejecting one, and a missingOrder.customerdispatchesuserId: ''instead of throwing. - `WebhookEvent`'s `refund` member gains three optional fields:
eventId(the provider event id;StripeAdapternow sets it from the Stripe event),refundAmountBasis: 'cumulative' | 'delta'(omitted = cumulative, the previous reading) andrefundId. A delta event's amount is added to the Order's total, replay-guarded byeventIdandrefundId.
Schema changes — Postgres consumers must generate and run a migration before deploying (
payload migrate:create, thenpayload migrate):orders.refundEventIds(json),payments.refundedCents(number, default 0), and a newpartially-refundedvalue on the Paymentsstatusselect (an enum change). MongoDB needs nothing.PaymentStatusgains'partially-refunded'; code that switches exhaustively on it needs the new case. A partial refund now leaves its Paymentpartially-refunded, where it used to becomerefunded.Consumer follow-up: a site that watched the Orders collection in its own
afterChangeto react to refunds can move that logic toregisterOrderRefundHandlerand branch onevent.full.@wabbit/tome-economy-authnet:mapAuthorizeNetWebhookEventmarksnet.authorize.payment.refund.createdasrefundAmountBasis: 'delta'(an Authorize.net refund transaction carries that one refund's amount, not a running total) and addseventId(thenotificationId) andrefundId(the refundtransId). 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.
- A replayed partial refund, or a second partial, no longer marks the Order fully refunded. The refund branch of
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 theeconomy:admincapability — previously only a legacyroles: string[]containing'admin'passed. Consumers who overrideaccessare unaffected.Internally the gate is async and takes the request. All 51 inline
as CollectionSlugcasts are replaced with core'stypedSlug()(no runtime change).Stripe webhook handler: the "setup.completed with no hook registered" and "subscription.payment_failed (dunning)" notices log through
payload.logger.infoinstead ofconsole.info.runPayoutBatchreads relationship ids with core'srelationIdinstead 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.ctsdeclarations, 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
.mapfiles and thesourceMappingURLcomments 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 snapshotsextractGallerySourcewrites 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-reactinstalled; the optional peer supplies only the sidebar icon.The
ShoppingCarticon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install withoutlucide-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
globalThisunder aSymbol.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 throughPromise.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.forkeys 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-affiliatesthat 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
letwhile the registry it guarded was global.@wabbit/tome-affiliatesavoids the same trap only by hand-rolling its own globalThis-keyed entry around the register calls; thekeyoption 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.
- Checkout metadata passthrough.
CheckoutSessionInputgainsmetadata?: Record<string, string>. Reserved keysorderId,productId,memberId,productType(exported asRESERVED_CHECKOUT_METADATA_KEYS) are computed by the action itself; a caller-supplied key that collides with one now throws the newReservedCheckoutMetadataKeyErrorbefore any Payload or adapter call, instead of silently colliding. The merged metadata reaches the adapter's session metadata (and sopayment_intent_data.metadata) AND the pending Order's ownmetadatafield, written at Order-creation time.createSubscriptionCheckoutAction'smetadatawas already required and already passed arbitrary extra keys through, so it needed no change for the same affiliate-attribution use case. - Paid-amount truth on Orders.
Orders.totalCents/discountCentswere frozen at list price fromcreateCheckoutSessionAction's pending-Order write (discountCentshardcoded0) and never corrected once Stripe applied a promo.createStripeWebhookHandler'scheckout.completedbranch now overwritestotalCentswith the session's realamount_totalon completion, plus two NEW Orders fields written when the session resolves them:taxCents(fromtotal_details.amount_tax) andpromotionCodeRef(the applied Stripe promotion code id, fromsession.discounts[0].promotion_code).discountCentsis likewise overwritten fromtotal_details.amount_discountwhen resolvable. This is also the fix for the refund branch'sisPartialcomparison, which reads the sameorder.totalCentsfield — no separate code change was needed there once it carries the real charged amount instead of list price.WebhookEvent'scheckout.completedmember gainssubtotalCents/discountCents/taxCents/promotionCodeRef, all optional (undefined on a hand-built event that doesn't carry them; every real Stripe session does). - Order refund dispatcher. NEW event bus mirroring the order-complete one exactly:
registerOrderRefundHandler(handler),onOrderRefundDispatch(order), event shapeOrderRefundedEvent { orderId, userId, status: 'refunded' | 'partially-refunded', refundedCents, totalCents, refundedAt }. No layer previously had a refund signal from economy; consumers reinvented their ownafterChangewatch. NEW Orders fieldrefundedCents(default0) carries the provider's cumulative refund amount, written by the webhook handler's refund branch. The OrdersafterChangehook dispatches when status isrefunded/partially-refundedAND (the status just transitioned ORrefundedCentsincreased) — 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. - Renewal amount on the renewal event.
SubscriptionRenewedEventgainsamountCents?,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/currencycome straight from the sourceWebhookEventand so are present whenever it resolved them, independent of the ledger write's own success; onlypaymentIdis 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
OrderCompletedEventis unchanged (frozen contract, shared-contracts-v3 §4).- Checkout metadata passthrough.
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 thepayout-accountsandpayoutscollections with GDPR registration (redactandretain), aPayoutProviderAdapterinterface withManualPayoutAdapterandStripeConnectPayoutAdapter(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.updatedandtransfer.reversedwebhook 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), andgetPayeeAnnualTotals.
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.
- New `Subscriptions` collection + `createSubscriptionCheckoutAction`.
createEconomyLayernow includes a Subscriptions collection by default (subscriptions: falseto opt out, or aSubscriptionsCollectionConfigto override slugs/relationship targets) with fieldsprovider,providerRef,customerRef,account/member(both optional relationships),price,status(active|trialing|past_due|canceled|expired),currentPeriodStart/currentPeriodEnd,cancelAtPeriodEnd,metadata.createSubscriptionCheckoutActionis the recurring-billing counterpart ofcreateCheckoutSessionAction— same requiredauthidentity gate, sameCheckoutIdentityMismatchError— routes a Price withinterval: 'month' | 'year'throughadapter.createSubscriptionSession(throwing the newSubscriptionCheckoutUnsupportedErrorfor 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 optionalpersistCustomerRefcallback so a consumer can save the provider customer id onto its own record.createSubscriptionPortalActionandcancelSubscriptionActionround out the surface, both feature-detecting the adapter's optionalcreatePortalSession/cancelSubscription. - `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 byproviderRef(a direct hit on every renewal after the first) and falls back to ametadata.userId/metadata.productIdmatch (the first renewal, beforeproviderRefis 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_failednow also update the row'sstatus. Because a renewal has no Order,Payments.orderis now optional and a newPayments.subscriptionrelationship carries the link instead — existing one-time-checkout Payment rows are unaffected (that write path still always setsorder). - 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).
PaymentProviderwidens from the closed'stripe' | 'free' | 'manual'to'stripe' | 'free' | 'manual' | 'authorizenet' | (string & {}); the Orders/Payments/Subscriptionsproviderfields convert from a closed Payloadselecttotext+ an openvalidate(any non-empty string; known values are documented for the admin UI only — seecollections/commerce/providerField.ts).createStripeWebhookHandlerreads the signature header viaadapter.webhookSignatureHeader ?? 'stripe-signature'(new optionalPaymentProviderAdaptermember) instead of hardcoding Stripe's header name, and every Payment write now stampsadapter.providerinstead of a hardcoded'stripe'.PaymentProviderAdapteralso gains optionalcancelSubscription(args)andrefund(args), both implemented onStripeAdapter(the pre-existingStripeAdapter.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). - `@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.tsalready carried a static, module-scopeimport { resolveMemberFromSession } from '@wabbit/tome-core/identity', so any consumer using that action already required core at runtime regardless of whatpeerDependenciesMetaclaimed.initEconomynow importsregisterLayerstatically (itstry/catchexists only for the "already registered" HMR/repeat-call case, matching every siblinginitXLayer), andcreateEconomyLayernow actually applies the sharedaccess/hooks/extraFields/fieldOverrides/omitFields/fieldOrdervocabulary from@wabbit/tome-core/utilities/layerFactoryConfigto every collection it returns — previously accepted onEconomyLayerConfigbut 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'ssessionHasCapabilityOrLegacyAdminprimitive 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 forthcomingbillinggroup (prerequisite PR (c)) aspersistCustomerRefgets 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 implementPaymentProviderAdapterin full, includingwebhookSignatureHeader: 'X-ANET-Signature'andcancelSubscription/refund.- New `Subscriptions` collection + `createSubscriptionCheckoutAction`.
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.**- Refunds never resolved their Order.
StripeAdapter.handleWebhookmappedcharge.refundedtoorderId = 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 withorderId: '', the handler skipped the Payment and Order writes, returned 200, and the consumer's refund-revocation hooks (keyed onstatus: 'refunded') never fired — a refunded buyer kept the entitlement. Fix, in the adapter:createCheckoutSessionnow stampspayment_intent_data.metadata(the payment-mode twin of thesubscription_data.metadatamirror 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 bypayment_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. - Every paid order granted twice. The handler's
payload.update({ status: 'completed' })fires the Orders collection's afterChange hook, which callsonOrderCompleteDispatch; the handler then invoked the consumer'sonOrderCompleteextra 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 recognizesonOrderComplete === onOrderCompleteDispatch, skips it, and logs a warning naming the consumer fix (drop the line).StripeWebhookHandlerConfig.onOrderCompleteis documented as site-level side effects only.
Consumer follow-up: remove
onOrderComplete: onOrderCompleteDispatchfrom 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 apayment_intent_datastamp assertion on session creation. - Refunds never resolved their Order.
0.9.xMinor
7d0949f4: Three additive checkout/webhook seams, surfaced by the first non-LMS consumer (2026-09-04).
- `items[].productType` is now stamped at checkout. Orders has declared the field since 0.3.0 and
onOrderCompleteDispatchreads it, butcreateCheckoutSessionActionnever 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'stypeonto the line item and into the provider session metadata (metadata.productType). Products with notypeare unchanged (the key is omitted, never written empty).
- Promotion codes and site-applied discounts pass through to Stripe Checkout.
CheckoutSessionInputandCreateCheckoutSessionArgsgain optionalallowPromotionCodes?: boolean(Stripeallow_promotion_codes) anddiscounts?: Array<{ coupon?: string; promotionCode?: string }>(Stripediscounts). 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,discountsis applied and the code box is suppressed for that session.FreeAdapterignores both.
- `createStripeWebhookHandler` gains `onSetupCompleted`. The adapter has emitted
setup.completed(amode: '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 callsonSetupCompleted(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 asSetupCompletedWebhookEvent.
Consumer follow-ups: wabbit-site-core can retire the request-clone peek in
src/app/api/webhooks/stripe/route.tsby passingonSetupCompleted: (e) => handleSetupCompleted(payload, e); the other consumer can drop its consumer-sideitems[].productTypere-stamp and passallowPromotionCodes: truefor the founder/early-bird codes.- `items[].productType` is now stamped at checkout. Orders has declared the field since 0.3.0 and
0.8.xMinor
Breaking (0.x) — `createCheckoutSessionAction`'s returned action now takes a required second argument.
The action creates Orders with
overrideAccess: trueand previously trusted thememberId/memberEmailit 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).authis{ user }— pass a Payloadreq.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'sresolveMemberFromSession, onefindon the members collection) and asserts that row isinput.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.memberEmailmust match the session's email or the resolved member's, case-insensitively. A failure throws the new exportedCheckoutIdentityMismatchError(.reasonis'missing-auth' | 'no-member' | 'id-mismatch' | 'email-mismatch',.codeis'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 auseris 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 throughownershipOrBypass()from@wabbit/tome-core/access. All three declare@wabbit/tome-coreas a required, explicitly non-optional peer, so these are plain static imports.The legacy path is unchanged: a
rolesarray 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'ssuperadmin/super-adminlegacy aliases now pass too — the point of adopting the shared primitive.The Access functions are async now; Payload's
Accesstype has always allowed aPromise, and the capability path needs an await.hasAdminRolestays exported from crowdfund and fulfillment as a@deprecatedback-compat shim with byte-identical semantics;CROWDFUND_ADMIN_CAPABILITYandFULFILLMENT_ADMIN_CAPABILITYare new named exports.economy deliberately does NOT adopt the core primitive, and the reason is a constraint rather than an oversight:
@wabbit/tome-coreis a declared OPTIONAL peer here, the README states in two places that core is genuinely optional, and the only core reference insrc/is a guarded lazyrequire()ininitEconomy. 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) andVendorEarnings(×1) collapse into one internalisEconomyAdmin()insrc/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 extrareq.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). Existingcollections.test.tsassertions 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.mjsgains ano-restricted-syntaxwarn-ratchet banning hand-rolled.roles.includes(...)admin checks inaccess/**,collections/**and*access*.ts, pointing atsessionHasCapabilityOrLegacyAdmin/can. The repo-wide count is 0 (down from 13), with three writteneslint-disableexemptions: the two deprecated back-compat exports and economy's single gate.One
LayerFactoryConfigevery 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 whatconfigmay 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 learnedadminGroupfrom org andhooksfrom 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/layerFactoryConfigexports theLayerFactoryConfiginterface —adminGroup,access(per-collection override map),hooks(appended viamergeHooks, never replacing),extraFields,fieldOverrides,omitFields,fieldOrder,slugs— andapplyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated tofields/fieldShape'sapplyFieldShapeso the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.It is a separate subpath from
./utilities/layerRegistrydeliberately — that module is in core'ssideEffectsarray, 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'sRecord<string, string | undefined>), and named<name>Slug?: stringscalars 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 extendLayerFactoryConfigdirectly and APPLY it throughapplyLayerFactoryConfig(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-coreas an OPTIONAL peer and hold zero runtime imports of it — gamification reachesregisterLayerthrough a lazyrequire()in a try/catch for exactly this reason.applyLayerFactoryConfigis 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 byassert: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 againstCollectionConfig, 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 carryadmin.groupexactly as collections do. rpg extendsOmit<LayerFactoryConfig, 'access'>becauseCharacterSheetsConfigis a single collection's config that doubles as the layer factory's config, and its ownaccessalready 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 EMPTYCollectionConfig[]: 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
WorkflowLayerConfigomits every seam for the same reason, and exists as the stable place a real option will land.createGamificationLayerandcreateRpgLayerare pure aliases ofregisterGamificationLayer/registerRpgLayer.initWorkflow,registerGamificationLayerandregisterRpgLayerare all@deprecatedwith sunset at each package's next major; none is removed.Forcing function:
scripts/assert-layer-factory-contract.mjs+pnpm assert:layer-factory-contract, wired intoplatform-discipline.ymlafterassert:layer-version(source reading only, pre-build). Every exportedcreate*Layermust take a config parameter whose type resolves toLayerFactoryConfig— throughextends, an intersection, or an explicitOmit<…>— with verb aliases followed to theirregister*/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.mdgains a "The factory contract" section stating the rule and the three permitted responses.Three factories are ALLOWLISTED with a reason each:
createAiLayerreturns credential wiring and owns no collections, so every seam is meaningless to it;createFormsLayerandcreateIntakeLayerare 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-coreto>=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is howERR_PACKAGE_PATH_NOT_EXPORTEDreached crowdfund's consumers once already.These are marked
patchbecause 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.
homepagedeep-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.urlpoints at the repo issue tracker, so a paying customer has a place to report a defect that is not email.engines.nodeis>=22, matching the rootenginesand.nvmrcset the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use fornode --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(rootpnpm assert:manifest-metadata, wired intoplatform-discipline.ymlbesideassert:license-metadata) fails when any publishable manifest lacksdescription,repository.directorymatching its own folder,homepage,bugs,engines.nodeequal to the repo floor,license,filesorsideEffects. 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
@deprecatedalias 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 gaincreate*Collectionnames for their formerdefine*Collectionfactories. Layer entries converge oncreateXLayer(config?) → bundle:createCrmLayer/createDealsLayer/createMarketingLayer/createCatalogLayer/createEconomyLayer/createChromeLayer/createLmsLayer/createAiLayer(+createFormsLayer/createIntakeLayer), returning bareCollectionConfig[]where the layer contributes only collections or an honest named bundle where it hands back more (chrome:{ globals }; lms/ai:{ collections, hooks }); void-returninginitCatalog/initEconomystay as the single registration call sites, delegated to internally.Naming note for forms consumers:
createFormsCollection(singular factory) vscreateFormsCollections(plural composer) vscreateFormsLayer(layer entry) — each docblock states the distinction.Every package now declares an explicit
sideEffectsfield (38 added; motion/engine/forms already correct).Registration-bearing modules (render files'
registerRenderer,blocks/*/index.tsdefineBlockself-registration, widgetregister.tsfiles, 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 blanketfalseon a package with registration or CSS.
0.4.xMinor
Add an opt-in
subscription.payment_faileddispatch seam for dunning.New exports:
registerSubscriptionPaymentFailedHandler,onSubscriptionPaymentFailedDispatch, and theSubscriptionPaymentFailedEvent/SubscriptionPaymentFailedHandlertypes. 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-corenow ships a standalone Node ESM CLI atscripts/emit-docs-manifests.mjsthat emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free<pkg>/metablock-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
./metapayload-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'sBlockMetawas 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.mdto its publishedfilesarray 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
createSubscriptionSessionandcreatePortalSession, 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 newonSubscriptionCompleteevent bus. AddsPrices.interval(one-time / month / year) andOrders.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_failedacross Stripe API shapes.The handler previously read only the top-level
invoice.subscriptionfield, which Stripe removed in API2025-03-31.basil(the SDK is pinned to2026-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-lineparent.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 PayloadonInit) andonOrderCompleteDispatchalways 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).onOrderCompleteDispatchnow 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 inpeerDependenciesreplaced 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-crmranges 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.0exactly. devDependencies keepworkspace:*for the local link. (@wabbit/tome-admin-progot 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