A double-entry accounting ledger for TypeScript, targeting Cloudflare Durable Objects (SQLite-backed storage).
Pluts is a TypeScript refactor of the Ruby Plutus gem. It implements a complete double-entry bookkeeping system: accounts (Asset, Liability, Equity, Revenue, Expense), journal entries with balanced debit/credit amounts, and reporting (trial balance, balance sheet, income statement).
All monetary amounts are stored as exact integer minor units (no floating-point errors), with a configurable scale (currently 2 decimal places, suitable for AUD, USD, NZD).
Money is represented by the Amount value object, stored internally as a bigint of minor units (cents at scale 2). The SCALE constant in src/domain/amount.ts is the single source of truth for precision. Rounding to the supported scale happens only at the input boundary, using half-up rounding; stored values are never rounded, so posted entries and the trial balance remain exact.
At public input boundaries (entry amounts, account creation), amounts accept number | string | Amount. A Zod schema parses raw values to Amount via half-up rounding at the boundary, so float imprecision (e.g. 0.1 + 0.2 = 0.30000000000000004) is resolved before storage. The stored integer is exact.
To support higher-precision currencies in the future (e.g. 3 decimals for KWD, 8 for crypto), raise SCALE and run a rescale migration on stored minor units. The rest of the domain is scale-agnostic.
The five account types are represented by the AccountType enum. Balance logic is driven by normalCreditBalance(type) plus the account's contra flag:
- Normal credit balance (Liability, Equity, Revenue), non-contra:
credits - debits - Normal credit balance, contra:
debits - credits - Normal debit balance (Asset, Expense), non-contra:
debits - credits - Normal debit balance, contra:
credits - debits
Contra accounts are automatically subtracted from type-level balance aggregation.
An entry requires a description, at least one debit and one credit, and the sum of debits must exactly equal the sum of credits. Validation is driven by Zod schemas: input shape (per-line account/amount presence, amount positivity) is validated by the schema, and entry-level invariants (≥1 debit, ≥1 credit, debits-sum === credits-sum) are enforced via superRefine. On failure, Ledger.postEntry throws a ValidationError carrying a flat list of path-tagged issues.
Entry (the persisted read model) and EntryPayload (the validated, pre-persistence input) are distinct immutable types — there is no mutable "new record" state.
All public input boundaries are Zod-gated: entry input, account creation, amount parsing, and date ranges. ValidationError.issues is a flat array of { path: PropertyKey[]; message: string } (Zod-native); record-level invariants use an empty path. Use errorsByField() to collapse paths to a field-keyed map for form binding.
Pluts persists over a SQLite-backed Durable Object's own storage (ctx.storage.sql), using the synchronous SqlStorage API (sql.exec(sql, ...binds).toArray()/.one()). The Repository interface decouples the domain from persistence, so the domain can be unit-tested with an in-memory repository. SqlStorageRepository is the production implementation.
src/db/schema.tsis the single source of truth for DDL: idempotentCREATE TABLE IF NOT EXISTS/CREATE INDEX IF NOT EXISTSstatements.migrate(sql)applies the schema viasql.exec(...). It is idempotent and safe to run on every cold start; existing tables and indexes are left untouched. There is no separate migrations tracking table and no code generator to run.- A ledger is hosted inside a Durable Object: the DO's private SQLite database (declared via
new_sqlite_classesinwrangler.jsonc) is the ledger. One DO instance = one isolated ledger.migrate(ctx.storage.sql)self-provisions each one, typically in the DO constructor underblockConcurrencyWhile.
Pluts domain objects cannot cross either exit from a Durable Object:
- Workers RPC serializes method arguments/returns with structured clone.
bigintis clone-safe, but class instances likeAmountandEntryare rejected withDataCloneError. - JSON REST uses
JSON.stringify, which throwsTypeErroronbigint.
Returning a raw Entry (or any object carrying Amount/Account instances) from a DO RPC method or HTTP handler fails at runtime.
RPC methods must return DTOs, never domain objects. Use the mappers exported from pluts:
toEntryDTO(entry)→EntryDTOtoAmountLineDTO(line)→AmountLineDTOtoAccountDTO(account)→AccountDTO
These produce deep-plain objects whose monetary fields are fixed-precision decimal strings ("10.00"), safe for both structured clone and JSON.stringify.
Amount#toJSON() returns the major-units decimal string, which makes JSON.stringify safe for objects containing an Amount — but it does not help Workers RPC, because structured clone ignores toJSON. Use the DTO mappers at RPC boundaries.
// Inside a Durable Object RPC method:
async postEntry(input: EntryInput): Promise<EntryDTO> {
const payload = buildEntry(input, (name) => this.repository.getAccountByName(name));
const entry = await this.repository.insertEntry(payload);
return toEntryDTO(entry);
}Pluts's production persistence is implemented against Cloudflare Durable Objects' embedded SQLite via the synchronous SqlStorage API. The concrete implementation lives in src/db/sqlite-storage-repository.ts and runs the schema statements defined in src/db/schema.ts using migrate(ctx.storage.sql).
If you want to run Pluts outside of Cloudflare (or support additional backends), implement the Repository interface in src/db/repository.ts for your chosen storage. Key guidance:
- Implement the same transactional semantics for
insertEntry(all row inserts + idempotency-key insert must be atomic). Use your DB's transactions. - Apply the schema in
SCHEMA_STATEMENTS(seesrc/db/schema.ts) or translate it to your DB's DDL before first use. - Persist amounts as integer minor units (the library uses
bigintinternally; convert to/from your driver's numeric type safely). - Ensure
getAccountByName,sumByType,sumCredits/sumDebits, andamountsForAccountmatch the SQL semantics expected by the domain code.
Examples:
- Node + SQLite: implement a
NodeSqliteRepositoryusingbetter-sqlite3orsqlite3, run theSCHEMA_STATEMENTSonce on startup, and wrapinsertEntryin a transaction. - Postgres: translate the DDL (types, index syntax) and implement
Repositorymethods withpg/knex; be mindful of integer sizes and usebigint/numeric as appropriate.
Because the domain is decoupled via Repository and tests exercise the domain with an in-memory repository, porting is limited to a single new repository adapter — the rest of Pluts should work unchanged.
Tenancy is intentionally not included. Multi-tenancy is provided by Durable Object isolation: one DO instance = one ledger. Account names are unique within a ledger.
Four tables (prefixed pluts_), defined in src/db/schema.ts:
pluts_accounts— id, name, type (CHECK-constrained to the five account types), contra, created_atpluts_entries— id, description, date, posted_atpluts_amounts— id, type ('credit'|'debit'), account_id, entry_id, amount (integer minor units)pluts_entry_keys— key, entry_id (idempotency-key dedup table)
Run migrate(ctx.storage.sql) to apply the schema; it is idempotent and a no-op on an up-to-date database.
bun add plutsPeer dependency: zod. Types for the Workers runtime (SqlStorage, DurableObjectStorage, crypto) come from @cloudflare/workers-types.
Hosted inside a Durable Object, where ctx.storage.sql is the ledger's database:
import { DurableObject } from "cloudflare:workers";
import {
AccountType,
Amount,
Ledger,
migrate,
SqlStorageRepository,
} from "pluts";
export class LedgerDO extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Provision the schema before any request is served.
ctx.blockConcurrencyWhile(() => {
migrate(ctx.storage.sql);
return Promise.resolve();
});
}
async fetch(req: Request): Promise<Response> {
const ledger = new Ledger(new SqlStorageRepository(this.ctx.storage));
// ...route requests to ledger.createAccount / ledger.postEntry / reports...
return new Response("ok");
}
}
// Create accounts
const cash = await ledger.createAccount({
name: "Cash",
type: AccountType.Asset,
});
await ledger.createAccount({
name: "Sales Revenue",
type: AccountType.Revenue,
});
await ledger.createAccount({
name: "Sales Tax Payable",
type: AccountType.Liability,
});
// Post a balanced entry (debits === credits). Amounts accept number|string|Amount.
await ledger.postEntry({
description: "Sold widgets",
debits: [{ accountName: "Cash", amount: 50 }],
credits: [
{ accountName: "Sales Revenue", amount: 45 },
{ accountName: "Sales Tax Payable", amount: "5.00" },
],
});
// Balances
await ledger.accountBalance(cash); // per-account
await ledger.balanceByType(AccountType.Asset); // per-type
await ledger.trialBalance(); // should be zero
// Reports
await ledger.balanceSheet(); // { assets, liabilities, equity, balanced }
await ledger.incomeStatement({ fromDate: "2024-01-01" }); // { revenue, expenses, netIncome }A complete runnable example lives in the pluts-ledger-do app, which wraps this pattern in a DO with a JSON REST surface and a seed route.
A contra account has its normal balance swapped:
await ledger.createAccount({
name: "Drawing",
type: AccountType.Equity,
contra: true,
});Balance methods accept an optional { fromDate, toDate } (Date or yyyy-mm-dd string):
await ledger.accountBalance(cash, {
fromDate: "2024-01-01",
toDate: new Date(),
});Ledger.postEntry and Ledger.createAccount throw ValidationError with a flat list of path-tagged issues on failure:
try {
await ledger.postEntry({ ... });
} catch (e) {
if (e instanceof ValidationError) {
// e.issues: { path: PropertyKey[]; message: string }[]
// e.g. [{ path: [], message: 'The credit and debit amounts are not equal' }]
console.log(e.issues);
// For form binding, collapse paths to a field-keyed map:
console.log(e.errorsByField()); // { _base: ['The credit and debit amounts are not equal'] }
}
}Rules (enforced via Zod schema + superRefine):
descriptionrequired (non-empty)- at least one debit and one credit
- every amount requires an account and a non-negative value
- sum(debits) === sum(credits) (exact)
datedefaults to today if omitted- account names must resolve to existing accounts
bun run test # all tests
bun run test:unit # domain unit tests (in-memory, fast)
bun run typecheck # tsc --noEmit
bun run lint # biomeThe unit tests cover amount math, balance computation for all account types and contra variants, entry validation, and the trial balance invariant, all against an in-memory Repository. The SqlStorageRepository (production) is exercised end-to-end by the pluts-ledger-do app's Durable Object.
bun install
bun run test # vitest (domain unit tests, in-memory)Pluts is a library; the runnable Durable Object app that consumes it lives in pluts-ledger-do project. Run npm run dev there for a local DO with SQLite storage.
The schema is defined as idempotent DDL in src/db/schema.ts (the SCHEMA_STATEMENTS array). To change the schema, edit that file and add/adjust the CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS statements. migrate(ctx.storage.sql) applies them on every cold start; existing objects are skipped, so there is no separate "generate migrations" step or tracking table.
Fresh-DBs only: if you change a CREATE TABLE definition in a way that conflicts with an already-provisioned DO SQLite database, reset the local DO storage so it is recreated cleanly:
rm -rf ../ledger/.wrangler/state/v3/doThe next npm run dev in ledger provisions a fresh DO and migrate applies the schema cleanly.
MIT