Ledger

Community enginePreview
@wabbit/tome-ledgerv0.2.2

Community-currency ledger for Tome sites — currency, wallet, and transaction collections with a strong integrity contract: balance floors, atomic transfers, idempotency keys, and an audit trail. Separate from @wabbit/tome-economy, which handles real-money payments.

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

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

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

    npm install @wabbit/tome-ledger

Overview

@wabbit/tome-ledger

Community-currency ledger for the Tome platform — Currency, Wallet, and Transaction collection factories with a strong integrity contract. Points, credits, in-community currencies, reward balances: money that lives inside your site and never touches a payment processor.

Deliberately distinct from @wabbit/tome-economy, which is real-money Stripe commerce (orders, payments, prices). A community wants wallets without a checkout; a shop does not want a community currency. They compose side by side and share nothing.

Layer: domain · Family / tier: ledger / pro · Version: tracked by LEDGER_LAYER_VERSION (./version), which a test pins to package.json.

Install

pnpm add @wabbit/tome-ledger payload

| Peer | Range | Required | |---|---|---| | payload | >=3.67.0 | yes | | @wabbit/tome-core | >=1.2.0 <2.0.0 | no — optional; this package imports nothing from it at runtime. Declared so a consumer's resolver can see the platform edge. |

server-only is a regular dependency (used by the ./server subpath).

Quick start

Opt-in composition — there is no createLedgerLayer() or registerLedgerLayer(). Import the three factories and add them to your own Payload config. Order matters: Currency and Wallet before Transaction, because Transaction's relationship fields reference both by slug.

import { buildConfig } from 'payload'
import {
  createCurrencyCollection,
  createWalletCollection,
  createTransactionCollection,
} from '@wabbit/tome-ledger'

export default buildConfig({
  collections: [
    createCurrencyCollection({ adminGroup: 'Community' }),
    createWalletCollection({ adminGroup: 'Community', ownerRelationTo: ['members'] }),
    createTransactionCollection({
      adminGroup: 'Community',
      // strict is the default; shown for emphasis
      compatibilityMode: 'strict',
      auditSink: async (entry, req) => {
        await req.payload.create({ collection: 'audit-logs', data: entry, overrideAccess: true })
      },
    }),
  ],
})

Read a balance on the server:

import { getWalletBalance } from '@wabbit/tome-ledger/server'

const balance = await getWalletBalance(walletId, currencyId, payload) // 0 when no entry exists yet

Move money by writing a transaction from server code. All three default collection gates deny writes (and Transaction denies reads too), so pass overrideAccess: true or supply your own access. Balances change only when a transaction reaches completed — on create, or on an update that newly sets it. Moving a completed transaction to pending or rejected reverses it. In strict mode an actor is required for that transition, so a system job sets context: { ledgerSystemContext: true }.

await payload.create({
  collection: 'transactions',
  data: { type: 'transfer', status: 'completed', amount: 50, currency: currencyId, fromWallet: aliceWallet, toWallet: bobWallet },
  overrideAccess: true,
  context: { ledgerSystemContext: true }, // no req.user here, so declare a system write
})

Public API

| Export | Subpath | Purpose | |---|---|---| | createCurrencyCollection(config?) | . · ./collections/currency | Currency definitions. Default access: read = anyone (currency definitions are public reference data — no balances or owners); create/update/delete = none (pass access to open writes). | | createWalletCollection(config?), DEFAULT_WALLET_TYPE_OPTIONS | . · ./collections/wallet | Wallets with a balances[] array of { currency, amount }. ownerRelationTo defaults to ['members'] — widen it for org-shaped owners. Default access is fail-closed: read = the wallet's owner or an admin; create/update/delete = none. balances is locked at field level (update always denied) so only the transaction hook moves money. Tune the default read with adminPredicate, resolveOwners and membersSlug, or replace it with access. | | walletOwnerOrAdminRead(options?), isLedgerAdmin + types WalletDefaultAccessOptions, WalletOwnerRef, LedgerAdminPredicate, LedgerOwnerResolver | . | The wallet's default read gate and its built-in admin check (admin / super-admin role slugs), exported so you can reuse or wrap them. With @wabbit/tome-core installed, pass core's admin gate as adminPredicate instead of relying on the minimal local check. | | createTransactionCollection(config?), DEFAULT_TRANSACTION_TYPE_OPTIONS | . · ./collections/transaction | The strong integrity contract (below). Default access denies read, create, update and delete. Statuses: pending (default), completed, rejected, plus the additive failed / inconsistent. Default types: transfer, deposit, withdrawal, payment, payout, fine, adjustment (override with typeOptions). If you widen access.create, also set statusFieldAccess, or any creator can submit status: 'completed' directly. Hooks run in beforeValidate (idempotency) → beforeChange (actor + balance-floor guards) → afterChange (balance apply). | | computeLedgerTransition(...), extractId(...) + types LedgerDocLike, LedgerLeg, LedgerTransition, ComputeLedgerTransitionOptions | . | Pure ledger math — which wallet legs a status transition moves, exported for a consumer's own tests and tooling. | | applyFieldShape, applyFieldOverrides, applyOmitFields, applyFieldOrder, insertFieldsAfter, mergeHooks | . | The field-shape and hook-merge pipeline every factory uses, re-exported so consumers composing extraFields shape them the same way. | | LEDGER_DEFAULT_SLUGS, resolveSelectOptions + config types | . | Default slugs (currencies, wallets, transactions, members, media) and the *CollectionConfig / FieldShapeConfig / LedgerCompatibilityMode / LedgerSyncFailureEvent / LedgerAuditEntry types. | | LedgerError, NegativeBalanceError, MissingActorError, WalletStoreUnreachableError, DuplicateIdempotencyKeyError, DoubleEntryNotImplementedError | . · ./errors | Typed errors. Every rejected write throws one of these — never a bare Error, never a silent no-op. (Invalid field-shape config, such as a duplicate fieldOrder name, throws a plain Error when the factory runs, before any write.) | | getWalletBalance(walletId, currencyId, payload, opts?) | ./server | Server-only balance read (import 'server-only' guard; reads with overrideAccess: true, so check the caller's right to see that wallet yourself). Returns 0 when the wallet has no entry for that currency; a wallet id that does not exist throws Payload's not-found error. opts.walletsSlug overrides 'wallets'. | | LEDGER_LAYER_VERSION | . · ./version | The version constant; tests/layer-version.test.ts fails if it drifts from package.json. |

Every factory accepts the house seam vocabulary: slug, adminGroup, labels, access (replaces the default ladder entirely), hooks (appended alongside the built-ins via mergeHooks, never replacing them), extraFields, plus the FieldShapeConfig pipeline (fieldOverrides, omitFields, extraFieldsAfter, fieldOrder).

The integrity contract (createTransactionCollection)

compatibilityMode: 'strict' (default) turns every guarantee on. 'legacy-vngd' reproduces the donor implementation's exact holes for a migrating consumer. An explicit per-guarantee flag always wins over the mode default.

| Guarantee | Flag | strict | legacy-vngd | What it closes | |---|---|---|---|---| | Balance floor — a debit that would take a wallet below zero throws NegativeBalanceError before any write | allowNegativeBalances | false (floor enforced) | true (no floor) | destroyable money | | Actor required — a money-moving status transition with no req.user throws MissingActorError unless req.context.ledgerSystemContext = true | requireActorOnTransition | true | false | audit rows with a null actor | | Idempotency — a duplicate idempotencyKey on create throws DuplicateIdempotencyKeyError | enableIdempotencyKey | true | true | double-apply on retry (additive field; inert when unset) | | Reversal scope — moving a completed row to failed / inconsistent does not reverse it | (mode only) | exempt | any exit from completed reverses | a failure marker being applied as a real reversal | | Atomic apply — uses the DB adapter's transaction when available | useAtomicTransactionWhenAvailable | true | false | half-applied multi-leg writes | | Loud failures — an unreachable wallet store throws WalletStoreUnreachableError and reaches onSyncFailure | walletStoreUnreachablePolicy | 'loud' | 'silent' | the donor's completely invisible no-op |

Individual guards can also be disabled outright (disableBalanceFloorGuard, disableActorGuard, disableIdempotencyGuard) — these remove a built-in hook while your own hooks still append.

Two additive status values, failed and inconsistent, are written by the balance-apply hook when a wallet-side write could not be completed or compensated; LedgerSyncFailureEvent.reversalFailed marks the genuinely inconsistent case. Existing rows are unaffected — only the vocabulary widens.

Injectable seams. onSyncFailure(event, req) replaces the donor's hardcoded failure reporter (default: req.payload.logger.error) — reporting is delegated, not additive: when you supply a handler, it is the sole report and logger.error is NOT also called for the same failure (a handler that itself logs or persists the failure would otherwise see every failure logged twice). If your handler throws, the factory falls back to logger.error so a failing reporter never makes the failure silent. auditSink(entry, req) receives a WALLET_BALANCE_UPDATE entry per applied leg (default: no-op — this package ships no audit-log collection; bring your own).

Not implemented in 0.1.0: doubleEntry: true throws DoubleEntryNotImplementedError at construction. The paired-row shape is designed (DoubleEntryLedgerRow) but not built; the trigger to build it is a consumer that needs per-leg rows for reporting rather than the current signed amount on the transaction.

Server / client posture

Collections and factories are server-side Payload config. The main barrel is free of server-only so types, errors, and the pure computeLedgerTransition remain importable from shared or client code. Only ./server carries the server-only guard. There are no React components.

Decisions that shaped this package

  • Stronger than the donor by default. The exception to byte-identical porting was deliberate: the donor's invariants — no floor, destroyable money, silent absence, null actors — are holes, not features. strict is the default; legacy-vngd is the migration hatch for a consumer that needs the donor's exact (weaker) behavior during cutover.
  • No auto-registration. Unlike most domain layers there is no createLedgerLayer() and the package does not call registerLayer(). Community currency is composed deliberately, one collection at a time; this also keeps @wabbit/tome-core a genuinely optional peer.
  • Own license family. ledger is the thirteenth family so a community site can license wallets without buying the real-money commerce family, and vice versa.
  • Balance hook kept local over a proven double-log divergence in the shared candidate that ports were meant to converge on — the divergence was reproducible, so isolation won over forcing a shared implementation.

Tests

pnpm --filter @wabbit/tome-ledger test

Vitest suites cover the strict guarantees, compat-mode parity, transition math, collection shapes, seams, and the version pin.

Exports

  • @wabbit/tome-ledger
  • @wabbit/tome-ledger/server
  • @wabbit/tome-ledger/collections/currency
  • @wabbit/tome-ledger/collections/wallet
  • @wabbit/tome-ledger/collections/transaction
  • @wabbit/tome-ledger/errors
  • @wabbit/tome-ledger/version

Changelog

v0.2.2patch

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

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

f57ad0c: Two transactions that first touch a wallet in the same new currency at the same time no longer create two balance entries for that currency. The balance-apply hook's first-touch `$push` now only matches a wallet that still has no entry for the currency, so the existence check and the push are one atomic update; a writer that loses the race retries the `$inc` instead. A leg that still cannot be applied (for example, the wallet does not exist) now throws into the existing failure path — reported, and in `strict` mode compensated and marked `failed` — instead of being dropped without a report.

  • f57ad0c: Two transactions that first touch a wallet in the same new currency at the same time no longer create two balance entries for that currency. The balance-apply hook's first-touch `$push` now only matches a wallet that still has no entry for the currency, so the existence check and the push are one atomic update; a writer that loses the race retries the `$inc` instead. A leg that still cannot be applied (for example, the wallet does not exist) now throws into the existing failure path — reported, and in `strict` mode compensated and marked `failed` — instead of being dropped without a report.
  • 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.2.0minor

485e778: **BREAKING:** `createWalletCollection()`'s default access no longer makes every wallet publicly readable. The default `read` is now owner-or-admin: anonymous callers are denied, a signed-in member sees only the wallet(s) whose `owner` is their `members` row (`members.user == req.user.id`), and `admin`/`super-admin` see all. Create/update/delete stay denied. New optional config: `adminPredicate` (pass core's admin gate when `@wabbit/tome-core` is installed — core stays an optional peer), `resolveOwners` (for units/wings/accounts-owned wallets), and `membersSlug`. A consumer that wants public wallets passes `access` explicitly, as before. New exports: `walletOwnerOrAdminRead`, `isLedgerAdmin`.

  • 485e778: **BREAKING:** `createWalletCollection()`'s default access no longer makes every wallet publicly readable. The default `read` is now owner-or-admin: anonymous callers are denied, a signed-in member sees only the wallet(s) whose `owner` is their `members` row (`members.user == req.user.id`), and `admin`/`super-admin` see all. Create/update/delete stay denied. New optional config: `adminPredicate` (pass core's admin gate when `@wabbit/tome-core` is installed — core stays an optional peer), `resolveOwners` (for units/wings/accounts-owned wallets), and `membersSlug`. A consumer that wants public wallets passes `access` explicitly, as before. New exports: `walletOwnerOrAdminRead`, `isLedgerAdmin`.
v0.1.2patch

80c99e3: Fix: the balance-apply hook's failure reporting is now delegated, not additive. When a consumer supplies `onSyncFailure`, that handler is the sole report for a given failure and `payload.logger.error` is no longer also called — previously both fired unconditionally in every mode (`strict` and `legacy-vngd`), so a consumer whose handler itself logs or persists the failure (e.g. a consumer's `reportSyncFailure`, which writes an audit row and logs) saw every leg failure, atomic-transaction rollback, compensation failure, `auditSink` throw, and `walletStoreUnreachablePolicy: 'loud'` event double-logged. With no handler supplied, behavior is unchanged: `logger.error` reports loudly, exactly as before. If the supplied handler itself throws, the factory falls back to `logger.error`, logging once with both the original failure and the handler's own error surfaced — a failing reporter must never make the failure silent.

  • 80c99e3: Fix: the balance-apply hook's failure reporting is now delegated, not additive. When a consumer supplies `onSyncFailure`, that handler is the sole report for a given failure and `payload.logger.error` is no longer also called — previously both fired unconditionally in every mode (`strict` and `legacy-vngd`), so a consumer whose handler itself logs or persists the failure (e.g. a consumer's `reportSyncFailure`, which writes an audit row and logs) saw every leg failure, atomic-transaction rollback, compensation failure, `auditSink` throw, and `walletStoreUnreachablePolicy: 'loud'` event double-logged. With no handler supplied, behavior is unchanged: `logger.error` reports loudly, exactly as before. If the supplied handler itself throws, the factory falls back to `logger.error`, logging once with both the original failure and the handler's own error surfaced — a failing reporter must never make the failure silent.
v0.1.1patch

637db74: Add the README the `files` field already promised (install, composition order, full public API, the integrity-contract matrix, compat mode, decisions) and a seeded CHANGELOG. `wabbit.family: "ledger"` is now a canonical family in `assert-license-metadata` (adopted 2026-09-01), so the licensing scope generator can place the package.

  • 637db74: Add the README the `files` field already promised (install, composition order, full public API, the integrity-contract matrix, compat mode, decisions) and a seeded CHANGELOG. `wabbit.family: "ledger"` is now a canonical family in `assert-license-metadata` (adopted 2026-09-01), so the licensing scope generator can place the package.
  • 8fc9702: Fix `resolveSelectOptions`: an `{ extend: [...] }` override now dedupes by `value`, so an option whose value already exists in a factory's defaults is skipped and the default wins. This was a real behaviour divergence, not a tidy-up. Every other `resolveSelectOptions` in the platform deduped; this one appended blindly, so `createWalletCollection({ walletTypeOptions: { extend: [{ value: 'personal', … }] } })` emitted a Payload `select` carrying `personal` twice — duplicate keys in the admin dropdown and whichever label Payload happened to render. The 2026-09-01 sale-readiness audit (§5.1) flagged it as the sharp edge of "same option name, silently different behaviour per layer". Covered by a regression test in `tests/wallet-collection.test.ts`. The package keeps its own copies of `mergeHooks` and `fieldShape` rather than importing `@wabbit/tome-core`, because it declares core as an OPTIONAL peer and a module-scope import would make that peer hard in practice while still advertising it as optional. Both files now say so, and `mergeHooks` is the single allowlisted entry in the new `assert:no-forked-primitives` gate, with that reason recorded there. Core's canonical `@wabbit/tome-core/fields/fieldShape` was promoted FROM ledger's copy — it was the superset, the only one carrying `applyFieldShape`.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
  • 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behaviour changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
v0.1.0minor

bc6569de: NEW package — community-currency ledger: `createCurrencyCollection`, `createWalletCollection`, `createTransactionCollection` factories with the strong integrity contract (balance floor, actor guard, idempotency key, atomic transaction when the adapter supports it, loud wallet-store failures) and `compatibilityMode: 'legacy-vngd'` as the byte-identical migration escape hatch. Server-only `getWalletBalance` on `./server`. Deliberately distinct from `@wabbit/tome-economy` (real-money Stripe commerce). Adopted 2026-09-01 as the thirteenth license family, `ledger`.

  • bc6569de: NEW package — community-currency ledger: `createCurrencyCollection`, `createWalletCollection`, `createTransactionCollection` factories with the strong integrity contract (balance floor, actor guard, idempotency key, atomic transaction when the adapter supports it, loud wallet-store failures) and `compatibilityMode: 'legacy-vngd'` as the byte-identical migration escape hatch. Server-only `getWalletBalance` on `./server`. Deliberately distinct from `@wabbit/tome-economy` (real-money Stripe commerce). Adopted 2026-09-01 as the thirteenth license family, `ledger`.