stable

Accounting app

Build an auditable, period-sealing accounting app — version history, closed periods, file attachments, localised labels, consent logging, and reports.

Audience: compliance-heavy verticals — accounting, legal, healthcare records, insurance — where every change needs to be auditable, periods need to seal at year-end, attachments are normal, and labels need translation. Bundle: core + withHistory + withPeriods + withBlobs + withI18n + withConsent + withReduce (~13,000 LOC).

Verified by: showcases/src/recipe-accounting-app.recipe.test.ts — the runnable form of this recipe.

noy-db is an encrypted document store: records are encrypted inside @noy-db/hub on the client before they ever reach a storage backend, and each tenant's data lives in its own vault — an isolated, independently keyed space of collections. Every capability beyond basic CRUD is an opt-in service: you import a with*() factory from a hub subpath and pass it to createNoydb(); services you don't import never enter your bundle. This recipe composes six of them into a working accounting backend.

What this gets you

ServiceWhat it adds
withHistory()Per-record version snapshots (collection.history(), getVersion(), revert(), diff()), a hash-chained audit ledger, point-in-time reads via vault.at(timestamp), and vault.verifyBackupIntegrity() for tamper detection
withPeriods()vault.closePeriod() / openPeriod() with a write-guard that rejects writes to records whose business date falls inside a closed period
withBlobs()Binary attachments (receipts, invoice PDFs) with content-addressed deduplication and MIME detection from magic bytes
withI18n()dictKey() for shared label dictionaries (status, taxClass) + i18nText() for free-form translatable fields
withConsent()GDPR/PIPL-friendly consent audit log: vault.withConsent() scopes, vault.consentAudit() queries
withReduce()sum, count, groupBy and friends for reports

Setup

The toPostgres store takes any client exposing query(sql, params) → { rows } — pg's Client or Pool works as-is. For local development or tests, swap in toMemory() from @noy-db/to-memory; nothing else changes.

import { createNoydb } from '@noy-db/hub'
import { withHistory } from '@noy-db/hub/history'
import { withPeriods } from '@noy-db/hub/periods'
import { withBlobs } from '@noy-db/hub/blobs'
import { withI18n } from '@noy-db/hub/i18n'
import { withConsent } from '@noy-db/hub/consent'
import { withReduce } from '@noy-db/hub/reduce'
import { toPostgres } from '@noy-db/to-postgres'
import { Pool } from 'pg'

const db = await createNoydb({
  store: toPostgres({ client: new Pool({ connectionString: process.env.DATABASE_URL }) }),
  user: 'admin@firm.example',
  secret: process.env.NOYDB_SECRET!,

  historyStrategy: withHistory(),
  periodsStrategy: withPeriods(),
  blobsStrategy: withBlobs(),
  i18nStrategy: withI18n(),
  consentStrategy: withConsent(),
  reduceStrategy: withReduce(),
})

Define collections with the right options

A dictKey field stores a stable key ('paid') and resolves to a per-locale label on read; an i18nText field stores a { locale: string } map and resolves to one locale's string on read.

import { dictKey, i18nText } from '@noy-db/hub'

interface Invoice {
  id: string
  clientId: string
  status: string         // dictKey — stores the stable key, labels resolve per locale
  description: Record<string, string>  // i18nText — one string per language
  amount: number
  date: string           // YYYY-MM-DD — the business date used by closePeriod
}

const vault = await db.openVault('firm-2026')

// Seed the shared status dictionary once per vault.
await vault.dictionary('status').putAll({
  draft:   { en: 'Draft',    th: 'ฉบับร่าง',     ar: 'مسودة' },
  open:    { en: 'Open',     th: 'เปิดใช้งาน',   ar: 'مفتوح' },
  paid:    { en: 'Paid',     th: 'ชำระแล้ว',     ar: 'مدفوع' },
  overdue: { en: 'Overdue',  th: 'เกินกำหนด',    ar: 'متأخر' },
})

const invoices = vault.collection<Invoice>('invoices', {
  dictKeyFields: {
    status: dictKey('status', ['draft', 'open', 'paid', 'overdue'] as const),
  },
  i18nFields: {
    description: i18nText({ languages: ['en', 'th', 'ar'], required: 'all' }),
  },
})

Write — every change is auditable

await invoices.put('inv-001', {
  id: 'inv-001',
  clientId: 'client-42',
  status: 'open',
  description: {
    en: 'Quarterly tax filing',
    th: 'การยื่นภาษีรายไตรมาส',
    ar: 'الإقرار الضريبي الفصلي',
  },
  amount: 15_000,
  date: '2026-03-15',
})

// History is recorded automatically. Edit again (say, in April):
await invoices.put('inv-001', {
  ...(await invoices.get('inv-001'))!,
  status: 'paid',
})

A locale-aware read resolves i18nText fields to the requested language and adds a <field>Label virtual field for each dictKey field:

const th = await invoices.get('inv-001', { locale: 'th' })
{
  "id": "inv-001",
  "clientId": "client-42",
  "status": "paid",
  "statusLabel": "ชำระแล้ว",
  "description": "การยื่นภาษีรายไตรมาส",
  "amount": 15000,
  "date": "2026-03-15"
}

collection.history(id) returns the superseded versions, newest first — the current version lives in the collection itself. Each entry is { version, timestamp, userId, record }:

const history = await invoices.history('inv-001')
[
  {
    "version": 1,
    "timestamp": "2026-03-15T09:12:44.318Z",
    "userId": "admin@firm.example",
    "record": {
      "id": "inv-001",
      "clientId": "client-42",
      "status": "open",
      "description": {
        "en": "Quarterly tax filing",
        "th": "การยื่นภาษีรายไตรมาส",
        "ar": "الإقرار الضريبي الفصلي"
      },
      "amount": 15000,
      "date": "2026-03-15"
    }
  }
]

To read what a record looked like at a past instant, vault.at() returns a read-only view of the whole vault at that timestamp (an ISO string or a Date):

const q1End = vault.at('2026-03-31T23:59:59Z')
const asOfQ1 = await q1End.collection<Invoice>('invoices').get('inv-001')
// → { id: 'inv-001', status: 'open', amount: 15000, date: '2026-03-15', ... }
//   — version 1: the April status edit hadn't happened yet at that instant

Writes on a time-machine view throw ReadOnlyAtInstantError, and accuracy is bounded by history retention: if per-collection retention pruned older versions, reads before the oldest retained snapshot return null.

Close the period — Q1 books are sealed

closePeriod takes a unique name, an inclusive ISO endDate cutoff, and an optional dateField naming the record field that carries the business date. Pass dateField for accounting workflows: an invoice booked late (written after the close) is still rejected when its date falls inside the closed period. Without dateField, the guard falls back to the write timestamp on the record's envelope (the encrypted wrapper each record is stored as), which rarely matches accounting semantics.

import { PeriodClosedError } from '@noy-db/hub'

const period = await vault.closePeriod({
  name: 'Q1-2026',
  endDate: '2026-03-31',
  dateField: 'date',
})
{
  "name": "Q1-2026",
  "kind": "closed",
  "endDate": "2026-03-31",
  "closedAt": "2026-04-01T08:00:00.000Z",
  "closedBy": "admin@firm.example",
  "priorPeriodHash": "",
  "dateField": "date"
}

closePeriod returns the stored PeriodRecord: priorPeriodHash chains to the previous close (empty string for a vault's first period; priorPeriodName is only present from the second close onward), and closedBy is the calling user. The record itself is written through the same ledger-instrumented path as any other write, so its own closure is tamper-evident.

// Any later attempt to put a record whose `date` is on or before
// 2026-03-31 throws PeriodClosedError:
try {
  await invoices.put('inv-late', {
    id: 'inv-late',
    clientId: 'client-42',
    status: 'open',
    description: { en: 'Late entry', th: 'รายการล่าช้า', ar: 'قيد متأخر' },
    amount: 1_000,
    date: '2026-03-15',  // inside Q1 — but Q1 is closed
  })
} catch (err) {
  if (err instanceof PeriodClosedError) {
    // user-facing: "you can't book to a closed period"
  }
}

// A record dated after the cutoff is accepted as usual:
await invoices.put('inv-042', {
  id: 'inv-042',
  clientId: 'client-42',
  status: 'open',
  description: { en: 'April retainer', th: 'ค่าบริการเมษายน', ar: 'أتعاب أبريل' },
  amount: 8_000,
  date: '2026-04-02',  // after endDate — fine
})

Attach files

Blobs are named slots on a record. blob(id).put(slotName, bytes, opts?) uploads and attaches; identical content is stored once (content-addressed deduplication), and the MIME type is detected from the file's magic bytes when you don't pass one.

import { readFile } from 'node:fs/promises'

const pdfBytes = new Uint8Array(await readFile('./receipt.pdf'))

const receipt = invoices.blob('inv-001')
await receipt.put('receipt.pdf', pdfBytes, { mimeType: 'application/pdf' })

// Read it back — returns the raw bytes, or null if the slot doesn't exist.
const bytes = await receipt.get('receipt.pdf')
// → Uint8Array(48213) — the PDF, decrypted

Log who read what, and why

Reads and writes performed inside a vault.withConsent() scope each append one entry to the encrypted consent-audit log — outside a scope, nothing is logged. Store the SHA-256 of the consent receipt the actor presented (a signed banner click, an authorisation PDF), not the receipt itself.

await vault.withConsent(
  { purpose: 'quarterly-review', consentHash: '7f3a…' },
  async () => invoices.get('inv-001'),
)

const log = await vault.consentAudit({ since: '2026-01-01T00:00:00Z' })
[
  {
    "id": "01JZX8Q4Y3…",
    "timestamp": "2026-04-14T10:02:11.907Z",
    "actor": "admin@firm.example",
    "purpose": "quarterly-review",
    "consentHash": "7f3a…",
    "op": "get",
    "collection": "invoices",
    "recordId": "inv-001"
  }
]

Run reports

Queries run inside the hub after decryption, so .run() is synchronous. Each result row carries the group key under the groupBy field's name plus your named reducers:

import { sum, count } from '@noy-db/hub/reduce'

const byClient = invoices.query()
  .where('status', '==', 'paid')
  .groupBy('clientId')
  .aggregate({ total: sum('amount'), invoiceCount: count() })
  .run()
// → [{ clientId: 'client-42', total: 78500, invoiceCount: 12 }, ...]

Verify nothing was tampered with

vault.verifyBackupIntegrity() walks the hash-chained ledger and cross-checks it against the stored data envelopes. It returns { ok: true, head, length } on a clean chain; on failure, ok: false with kind: 'chain' (the ledger chain diverged, divergedAt is the index) or kind: 'data' (a data envelope doesn't match the ledger, with the offending collection and id):

const verdict = await vault.verifyBackupIntegrity()
{ "ok": true, "head": "9c41f7…", "length": 128 }
if (!verdict.ok) {
  // verdict.kind === 'chain' → ledger modified out-of-band; verdict.divergedAt
  // verdict.kind === 'data'  → an envelope was altered; verdict.collection, verdict.id
  console.error(verdict.message)
}

See also

  • history — versions, ledger, time machine, integrity
  • periods — the close-period write-guard in depth
  • blobs — slots, chunking, external projections
  • i18n — dictionaries, dictKey, i18nText policies
  • consent — consent scopes and audit queries
  • aggregate — reducers, groupBy, live aggregates
  • Services overview — the full opt-in catalog